| 33 | 33 | # @param align Right-align numbers (default TRUE). |
| 34 | 34 | # ----------------------------------------------------------------------------- |
| 35 | csv2md <- function( f, decimals = 2, totals = T, align = T ) { | |
| 35 | csv2md <- function( f, decimals = 2, totals = T, align = T, caption = "" ) { | |
| 36 | 36 | # Read the CVS data from the file; ensure strings become characters. |
| 37 | 37 | df <- read.table( f, sep=',', header=T, stringsAsFactors=F ) |
| ... | ||
| 74 | 74 | else { |
| 75 | 75 | dashes <- paste( rep( '---', length( df ) ), collapse = '|' ) |
| 76 | } | |
| 77 | ||
| 78 | # Use pandoc syntax for table captions. | |
| 79 | if( caption != "" ) { | |
| 80 | caption <- paste( '\n[', caption, ']\n', sep='' ) | |
| 76 | 81 | } |
| 77 | 82 | |
| ... | ||
| 86 | 91 | }, df |
| 87 | 92 | ), |
| 88 | collapse = '|\n', sep='' | |
| 93 | collapse = '|\n',sep='' | |
| 89 | 94 | ), |
| 90 | '|\n', | |
| 95 | '|', | |
| 96 | caption, | |
| 91 | 97 | sep='' |
| 92 | 98 | ) |
| 3 | 3 | The following documents have additional details about using the editor: |
| 4 | 4 | |
| 5 | * [cmd.md](cmd.md) -- Command-line argument usage | |
| 5 | 6 | * [div.md](div.md) -- Syntax for annotated text (fenced divs) |
| 6 | 7 | * [i18n.md](i18n.md) -- Internationalization features |
| 1 | # Command-line arguments | |
| 2 | ||
| 3 | The application may be run from the command-line to convert Markdown and | |
| 4 | R Markdown files to a variety of output formats. Without specifying any | |
| 5 | command-line arguments, the application will launch a graphical user interface. | |
| 6 | ||
| 7 | ## Common arguments | |
| 8 | ||
| 9 | The most common command-line arguments to use include: | |
| 10 | ||
| 11 | * `-h` -- displays all command-line arguments, then exits. | |
| 12 | * `-i` -- sets the input file name, must be a full path. | |
| 13 | * `-o` -- sets the output file name, can be a relative path. | |
| 14 | ||
| 15 | ## Example usage | |
| 16 | ||
| 17 | On Linux, simple usages include: | |
| 18 | ||
| 19 | keenwrite.bin -i $HOME/document/01.md -o document.xhtml | |
| 20 | ||
| 21 | keenwrite.bin -i $HOME/document/01.md -o document.md \ | |
| 22 | -v $HOME/document/variables.yaml | |
| 23 | ||
| 24 | That command will convert `01.md` into the respective file formats. In | |
| 25 | the first case, it will become an HTML page. In the second case, it will | |
| 26 | become a Markdown document with all variables interpolated and replaced. | |
| 27 | ||
| 28 | A more complex example follows: | |
| 29 | ||
| 30 | keenwrite.bin -i $HOME/document/01.Rmd -o document.pdf \ | |
| 31 | --image-dir=$HOME/document/images -v $HOME/document/variables.yaml \ | |
| 32 | --metadata="title={{book.title}}" --metadata="author={{book.author}}" \ | |
| 33 | --r-dir=$HOME/document/r --r-script=$HOME/document/r/bootstrap.R \ | |
| 34 | --theme-dir=$HOME/document/themes/boschet | |
| 35 | ||
| 36 | That command will convert `01.Rmd` to `document.pdf` and replace the metadata | |
| 37 | using values from the variable definitions file. | |
| 38 | ||
| 39 | Directory names containing spaces must be quoted. For example, on Windows: | |
| 40 | ||
| 41 | keenwrite.bin -i "C:\Users\My Documents\01.Rmd" -o document.pdf | |
| 42 | ||
| 1 | 43 |
| 33 | 33 | ARG_JAVA_OS="linux" |
| 34 | 34 | ARG_JAVA_ARCH="amd64" |
| 35 | ARG_JAVA_VERSION="17" | |
| 36 | ARG_JAVA_UPDATE="35" | |
| 35 | ARG_JAVA_VERSION="17.0.1" | |
| 36 | ARG_JAVA_UPDATE="12" | |
| 37 | 37 | ARG_JAVA_DIR="java" |
| 38 | 38 | |
| ... | ||
| 157 | 157 | readonly SCRIPT_SRC="\$(dirname "\${BASH_SOURCE[\${#BASH_SOURCE[@]} - 1]}")" |
| 158 | 158 | |
| 159 | "\${SCRIPT_SRC}/${ARG_JAVA_DIR}/bin/java" ${OPT_JAVA} -jar "\${SCRIPT_SRC}/${FILE_APP_JAR}" "\$@" 2>/dev/null & | |
| 159 | "\${SCRIPT_SRC}/${ARG_JAVA_DIR}/bin/java" ${OPT_JAVA} -jar "\${SCRIPT_SRC}/${FILE_APP_JAR}" "\$@" 2>/dev/null | |
| 160 | 160 | __EOT |
| 161 | 161 | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite; | |
| 3 | ||
| 4 | import com.keenwrite.io.FileType; | |
| 5 | ||
| 6 | import java.nio.file.Path; | |
| 7 | ||
| 8 | import static com.keenwrite.constants.Constants.GLOB_PREFIX_FILE; | |
| 9 | import static com.keenwrite.constants.Constants.sSettings; | |
| 10 | import static com.keenwrite.io.FileType.UNKNOWN; | |
| 11 | import static com.keenwrite.predicates.PredicateFactory.createFileTypePredicate; | |
| 12 | ||
| 13 | /** | |
| 14 | * Provides common behaviours for factories that instantiate classes based on | |
| 15 | * file type. | |
| 16 | */ | |
| 17 | public abstract class AbstractFileFactory { | |
| 18 | ||
| 19 | /** | |
| 20 | * Determines the file type from the path extension. This should only be | |
| 21 | * called when it is known that the file type won't be a definition file | |
| 22 | * (e.g., YAML or other definition source), but rather an editable file | |
| 23 | * (e.g., Markdown, R Markdown, etc.). | |
| 24 | * | |
| 25 | * @param path The path with a file name extension. | |
| 26 | * @return The FileType for the given path. | |
| 27 | */ | |
| 28 | public static FileType lookup( final Path path ) { | |
| 29 | assert path != null; | |
| 30 | ||
| 31 | return lookup( path, GLOB_PREFIX_FILE ); | |
| 32 | } | |
| 33 | ||
| 34 | /** | |
| 35 | * Creates a file type that corresponds to the given path. | |
| 36 | * | |
| 37 | * @param path Reference to a variable definition file. | |
| 38 | * @param prefix One of GLOB_PREFIX_DEFINITION or GLOB_PREFIX_FILE. | |
| 39 | * @return The file type that corresponds to the given path. | |
| 40 | */ | |
| 41 | protected static FileType lookup( final Path path, final String prefix ) { | |
| 42 | assert path != null; | |
| 43 | assert prefix != null; | |
| 44 | ||
| 45 | final var keys = sSettings.getKeys( prefix ); | |
| 46 | ||
| 47 | var found = false; | |
| 48 | var fileType = UNKNOWN; | |
| 49 | ||
| 50 | while( keys.hasNext() && !found ) { | |
| 51 | final var key = keys.next(); | |
| 52 | final var patterns = sSettings.getStringSettingList( key ); | |
| 53 | final var predicate = createFileTypePredicate( patterns ); | |
| 54 | ||
| 55 | if( predicate.test( path.toFile() ) ) { | |
| 56 | // Remove the EXTENSIONS_PREFIX to get the file name extension mapped | |
| 57 | // to a standard name (as defined in the settings.properties file). | |
| 58 | final String suffix = key.replace( prefix + '.', "" ); | |
| 59 | fileType = FileType.from( suffix ); | |
| 60 | found = true; | |
| 61 | } | |
| 62 | } | |
| 63 | ||
| 64 | return fileType; | |
| 65 | } | |
| 66 | } | |
| 67 | 1 |
| 2 | 2 | |
| 3 | 3 | import com.keenwrite.cmdline.Arguments; |
| 4 | import com.keenwrite.processors.ProcessorContext; | |
| 5 | import com.keenwrite.typesetting.Typesetter; | |
| 6 | import com.keenwrite.ui.dialogs.ThemePicker; | |
| 7 | 4 | import com.keenwrite.util.AlphanumComparator; |
| 8 | 5 | |
| 9 | 6 | import java.io.IOException; |
| 10 | 7 | import java.nio.file.Path; |
| 11 | 8 | import java.util.ArrayList; |
| 12 | 9 | import java.util.concurrent.Callable; |
| 13 | 10 | import java.util.concurrent.CompletableFuture; |
| 14 | 11 | import java.util.concurrent.ExecutorService; |
| 12 | import java.util.concurrent.atomic.AtomicInteger; | |
| 15 | 13 | |
| 16 | import static com.keenwrite.ExportFormat.*; | |
| 14 | import static com.keenwrite.Launcher.terminate; | |
| 15 | import static com.keenwrite.events.StatusEvent.clue; | |
| 17 | 16 | import static com.keenwrite.processors.ProcessorFactory.createProcessors; |
| 18 | 17 | import static com.keenwrite.util.FileWalker.walk; |
| ... | ||
| 41 | 40 | |
| 42 | 41 | public static void run( final Arguments args ) { |
| 43 | final var context = args.createProcessorContext(); | |
| 42 | final var exitCode = new AtomicInteger(); | |
| 43 | ||
| 44 | final var future = new CompletableFuture<Path>() { | |
| 45 | @Override | |
| 46 | public boolean complete( final Path path ) { | |
| 47 | return super.complete( path ); | |
| 48 | } | |
| 49 | ||
| 50 | @Override | |
| 51 | public boolean completeExceptionally( final Throwable ex ) { | |
| 52 | clue( ex ); | |
| 53 | exitCode.set( 1 ); | |
| 54 | ||
| 55 | return super.completeExceptionally( ex ); | |
| 56 | } | |
| 57 | }; | |
| 58 | ||
| 59 | file_export( args, future ); | |
| 60 | sExecutor.shutdown(); | |
| 61 | future.join(); | |
| 62 | terminate( exitCode.get() ); | |
| 44 | 63 | } |
| 45 | 64 | |
| 46 | 65 | /** |
| 47 | 66 | * Converts one or more files into the given file format. If {@code dir} |
| 48 | 67 | * is set to true, this will first append all files in the same directory |
| 49 | 68 | * as the actively edited file. |
| 50 | 69 | * |
| 51 | * @param inputPath The source document to export in the given file format. | |
| 52 | * @param format The destination file format. | |
| 53 | * @param concat Export all files in the actively edited file's directory. | |
| 54 | * @param future Indicates whether the export succeeded or failed. | |
| 70 | * @param future Indicates whether the export succeeded or failed. | |
| 55 | 71 | */ |
| 56 | private void file_export( | |
| 57 | final Path inputPath, | |
| 58 | final ExportFormat format, | |
| 59 | final boolean concat, | |
| 60 | final CompletableFuture<Path> future ) { | |
| 72 | private static void file_export( | |
| 73 | final Arguments args, final CompletableFuture<Path> future ) { | |
| 74 | assert args != null; | |
| 75 | assert future != null; | |
| 76 | ||
| 61 | 77 | final Callable<Path> callableTask = () -> { |
| 62 | 78 | try { |
| 63 | final var context = ProcessorContext.create( inputPath, format ); | |
| 64 | final var outputPath = format.toExportPath( inputPath ); | |
| 79 | final var context = args.createProcessorContext(); | |
| 80 | final var concat = context.getConcatenate(); | |
| 81 | final var inputPath = context.getInputPath(); | |
| 82 | final var outputPath = context.getOutputPath(); | |
| 65 | 83 | final var chain = createProcessors( context ); |
| 66 | 84 | final var inputDoc = read( inputPath, concat ); |
| 67 | 85 | final var outputDoc = chain.apply( inputDoc ); |
| 68 | 86 | |
| 69 | 87 | // Processors can export binary files. In such cases, processors will |
| 70 | 88 | // return null to prevent further processing. |
| 71 | 89 | final var result = |
| 72 | 90 | outputDoc == null ? null : writeString( outputPath, outputDoc ); |
| 73 | 91 | |
| 74 | future.complete( result ); | |
| 92 | future.complete( outputPath ); | |
| 75 | 93 | return result; |
| 76 | } catch( final Exception ex ) { | |
| 94 | } catch( final Throwable ex ) { | |
| 77 | 95 | future.completeExceptionally( ex ); |
| 78 | 96 | return null; |
| 79 | 97 | } |
| 80 | 98 | }; |
| 81 | 99 | |
| 82 | 100 | // Prevent the application from blocking while the processor executes. |
| 83 | 101 | sExecutor.submit( callableTask ); |
| 84 | } | |
| 85 | ||
| 86 | /** | |
| 87 | * @param concat {@code true} means to export all files in the active file | |
| 88 | * editor's directory; {@code false} means to export only the | |
| 89 | * actively edited file. | |
| 90 | * | |
| 91 | private void file_export_pdf( final Path theme, final boolean concat ) { | |
| 92 | if( Typesetter.canRun() ) { | |
| 93 | // If the typesetter is installed, allow the user to select a theme. If | |
| 94 | // the themes aren't installed, a status message will appear. | |
| 95 | if( ThemePicker.choose( themes, theme ) ) { | |
| 96 | file_export( APPLICATION_PDF, concat ); | |
| 97 | } | |
| 98 | } | |
| 99 | else { | |
| 100 | fireExportFailedEvent(); | |
| 101 | } | |
| 102 | } | |
| 103 | ||
| 104 | public void file_export_pdf() { | |
| 105 | file_export_pdf( false ); | |
| 106 | } | |
| 107 | ||
| 108 | public void file_export_pdf_dir() { | |
| 109 | file_export_pdf( true ); | |
| 110 | } | |
| 111 | ||
| 112 | public void file_export_html_svg() { | |
| 113 | file_export( HTML_TEX_SVG ); | |
| 114 | } | |
| 115 | ||
| 116 | public void file_export_html_tex() { | |
| 117 | file_export( HTML_TEX_DELIMITED ); | |
| 118 | } | |
| 119 | ||
| 120 | public void file_export_xhtml_tex() { | |
| 121 | file_export( XHTML_TEX ); | |
| 122 | 102 | } |
| 123 | 103 | |
| 124 | public void file_export_markdown() { | |
| 125 | file_export( MARKDOWN_PLAIN ); | |
| 126 | } | |
| 127 | */ | |
| 128 | 104 | /** |
| 129 | 105 | * Concatenates all the files in the same directory as the given file into |
| ... | ||
| 146 | 122 | * concatenated into a single string. |
| 147 | 123 | */ |
| 148 | private String read( final Path inputPath, final boolean concat ) | |
| 124 | private static String read( final Path inputPath, final boolean concat ) | |
| 149 | 125 | throws IOException { |
| 150 | 126 | final var parent = inputPath.getParent(); |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite; | |
| 3 | ||
| 4 | import com.keenwrite.util.GenericBuilder; | |
| 5 | import javafx.beans.value.ObservableValue; | |
| 6 | import org.fxmisc.richtext.StyleClassedTextArea; | |
| 7 | import org.fxmisc.richtext.model.Paragraph; | |
| 8 | import org.reactfx.collection.LiveList; | |
| 9 | ||
| 10 | import java.util.Collection; | |
| 11 | ||
| 12 | import static com.keenwrite.Messages.get; | |
| 13 | import static com.keenwrite.constants.Constants.STATUS_BAR_LINE; | |
| 14 | ||
| 15 | /** | |
| 16 | * Represents the absolute, relative, and maximum position of the caret. The | |
| 17 | * caret position is a character offset into the text. | |
| 18 | */ | |
| 19 | public class Caret { | |
| 20 | ||
| 21 | private final Mutator mMutator; | |
| 22 | ||
| 23 | public static GenericBuilder<Caret.Mutator, Caret> builder() { | |
| 24 | return GenericBuilder.of( Caret.Mutator::new, Caret::new ); | |
| 25 | } | |
| 26 | ||
| 27 | /** | |
| 28 | * Used for building a new {@link Caret} instance. | |
| 29 | */ | |
| 30 | public static class Mutator { | |
| 31 | /** | |
| 32 | * Caret's current paragraph index (i.e., current caret line number). | |
| 33 | */ | |
| 34 | private ObservableValue<Integer> mParagraph; | |
| 35 | ||
| 36 | /** | |
| 37 | * Used to count the number of lines in the text editor document. | |
| 38 | */ | |
| 39 | private LiveList<Paragraph<Collection<String>, String, | |
| 40 | Collection<String>>> mParagraphs; | |
| 41 | ||
| 42 | /** | |
| 43 | * Caret offset into the full text, represented as a string index. | |
| 44 | */ | |
| 45 | private ObservableValue<Integer> mTextOffset; | |
| 46 | ||
| 47 | /** | |
| 48 | * Caret offset into the current paragraph, represented as a string index. | |
| 49 | */ | |
| 50 | private ObservableValue<Integer> mParaOffset; | |
| 51 | ||
| 52 | /** | |
| 53 | * Total number of characters in the document. | |
| 54 | */ | |
| 55 | private ObservableValue<Integer> mTextLength; | |
| 56 | ||
| 57 | /** | |
| 58 | * Configures this caret position using properties from the given editor. | |
| 59 | * | |
| 60 | * @param editor The text editor that has a caret with position properties. | |
| 61 | */ | |
| 62 | public void setEditor( final StyleClassedTextArea editor ) { | |
| 63 | mParagraph = editor.currentParagraphProperty(); | |
| 64 | mParagraphs = editor.getParagraphs(); | |
| 65 | mParaOffset = editor.caretColumnProperty(); | |
| 66 | mTextOffset = editor.caretPositionProperty(); | |
| 67 | mTextLength = editor.lengthProperty(); | |
| 68 | } | |
| 69 | } | |
| 70 | ||
| 71 | /** | |
| 72 | * Force using the builder pattern. | |
| 73 | */ | |
| 74 | private Caret( final Mutator mutator ) { | |
| 75 | assert mutator != null; | |
| 76 | ||
| 77 | mMutator = mutator; | |
| 78 | } | |
| 79 | ||
| 80 | /** | |
| 81 | * Allows observers to be notified when the value of the caret changes. | |
| 82 | * | |
| 83 | * @return An observer for the caret's document offset. | |
| 84 | */ | |
| 85 | public ObservableValue<Integer> textOffsetProperty() { | |
| 86 | return mMutator.mTextOffset; | |
| 87 | } | |
| 88 | ||
| 89 | /** | |
| 90 | * Answers whether the caret's offset into the text is between the given | |
| 91 | * offsets. | |
| 92 | * | |
| 93 | * @param began Starting value compared against the caret's text offset. | |
| 94 | * @param ended Ending value compared against the caret's text offset. | |
| 95 | * @return {@code true} when the caret's text offset is between the given | |
| 96 | * values, inclusively (for either value). | |
| 97 | */ | |
| 98 | public boolean isBetweenText( final int began, final int ended ) { | |
| 99 | final var offset = getTextOffset(); | |
| 100 | return began <= offset && offset <= ended; | |
| 101 | } | |
| 102 | ||
| 103 | /** | |
| 104 | * Answers whether the caret's offset into the paragraph is before the given | |
| 105 | * offset. | |
| 106 | * | |
| 107 | * @param offset Compared against the caret's paragraph offset. | |
| 108 | * @return {@code true} the caret's offset is before the given offset. | |
| 109 | */ | |
| 110 | public boolean isBeforeColumn( final int offset ) { | |
| 111 | return getParaOffset() < offset; | |
| 112 | } | |
| 113 | ||
| 114 | /** | |
| 115 | * Answers whether the caret's offset into the text is before the given | |
| 116 | * text offset. | |
| 117 | * | |
| 118 | * @param offset Compared against the caret's text offset. | |
| 119 | * @return {@code true} the caret's offset is after the given offset. | |
| 120 | */ | |
| 121 | public boolean isAfterColumn( final int offset ) { | |
| 122 | return getParaOffset() > offset; | |
| 123 | } | |
| 124 | ||
| 125 | /** | |
| 126 | * Answers whether the caret's offset into the text exceeds the length of | |
| 127 | * the text. | |
| 128 | * | |
| 129 | * @return {@code true} when the caret is at the end of the text boundary. | |
| 130 | */ | |
| 131 | public boolean isAfterText() { | |
| 132 | return getTextOffset() >= getTextLength(); | |
| 133 | } | |
| 134 | ||
| 135 | public boolean isAfter( final int offset ) { | |
| 136 | return offset >= getTextOffset(); | |
| 137 | } | |
| 138 | ||
| 139 | private int getParagraph() { | |
| 140 | return mMutator.mParagraph.getValue(); | |
| 141 | } | |
| 142 | ||
| 143 | /** | |
| 144 | * Returns the number of lines in the text editor. | |
| 145 | * | |
| 146 | * @return The size of the text editor's paragraph list plus one. | |
| 147 | */ | |
| 148 | private int getParagraphCount() { | |
| 149 | return mMutator.mParagraphs.size() + 1; | |
| 150 | } | |
| 151 | ||
| 152 | /** | |
| 153 | * Returns the absolute position of the caret within the entire document. | |
| 154 | * | |
| 155 | * @return A zero-based index of the caret position. | |
| 156 | */ | |
| 157 | private int getTextOffset() { | |
| 158 | return mMutator.mTextOffset.getValue(); | |
| 159 | } | |
| 160 | ||
| 161 | /** | |
| 162 | * Returns the position of the caret within the current paragraph being | |
| 163 | * edited. | |
| 164 | * | |
| 165 | * @return A zero-based index of the caret position relative to the | |
| 166 | * current paragraph. | |
| 167 | */ | |
| 168 | private int getParaOffset() { | |
| 169 | return mMutator.mParaOffset.getValue(); | |
| 170 | } | |
| 171 | ||
| 172 | /** | |
| 173 | * Returns the total number of characters in the document being edited. | |
| 174 | * | |
| 175 | * @return A zero-based count of the total characters in the document. | |
| 176 | */ | |
| 177 | private int getTextLength() { | |
| 178 | return mMutator.mTextLength.getValue(); | |
| 179 | } | |
| 180 | ||
| 181 | /** | |
| 182 | * Returns a human-readable string that shows the current caret position | |
| 183 | * within the text. Typically this will include the current line number, | |
| 184 | * the number of lines, and the character offset into the text. | |
| 185 | * <p> | |
| 186 | * If the {@link Caret} has not been properly built, this will return a | |
| 187 | * string for the status bar having all values set to zero. This can happen | |
| 188 | * during unit testing, but should not happen any other time. | |
| 189 | * </p> | |
| 190 | * | |
| 191 | * @return A string to present to an end user. | |
| 192 | */ | |
| 193 | @Override | |
| 194 | public String toString() { | |
| 195 | try { | |
| 196 | return get( STATUS_BAR_LINE, | |
| 197 | getParagraph() + 1, | |
| 198 | getParagraphCount(), | |
| 199 | getTextOffset() + 1 ); | |
| 200 | } catch( final Exception ex ) { | |
| 201 | return get( STATUS_BAR_LINE, 0, 0, 0 ); | |
| 202 | } | |
| 203 | } | |
| 204 | } | |
| 205 | 1 |
| 14 | 14 | |
| 15 | 15 | import static com.keenwrite.Bootstrap.*; |
| 16 | import static com.keenwrite.PermissiveCertificate.installTrustManager; | |
| 16 | import static com.keenwrite.security.PermissiveCertificate.installTrustManager; | |
| 17 | 17 | import static java.lang.String.format; |
| 18 | 18 | |
| ... | ||
| 31 | 31 | */ |
| 32 | 32 | private final String[] mArgs; |
| 33 | ||
| 34 | private static void parse( final String[] args ) { | |
| 35 | assert args != null; | |
| 36 | ||
| 37 | final var arguments = new Arguments( new Launcher( args ) ); | |
| 38 | final var parser = new CommandLine( arguments ); | |
| 39 | ||
| 40 | parser.setColorScheme( ColourScheme.create() ); | |
| 41 | ||
| 42 | final var exitCode = parser.execute( args ); | |
| 43 | final var parseResult = parser.getParseResult(); | |
| 44 | ||
| 45 | if( parseResult.isUsageHelpRequested() ) { | |
| 46 | System.exit( exitCode ); | |
| 47 | } | |
| 48 | } | |
| 49 | ||
| 50 | /** | |
| 51 | * Suppress writing to standard error, suppresses writing log messages. | |
| 52 | */ | |
| 53 | private static void disableLogging() { | |
| 54 | LogManager.getLogManager().reset(); | |
| 55 | System.err.close(); | |
| 56 | } | |
| 57 | ||
| 58 | private static void showAppInfo() { | |
| 59 | out( "%n%s version %s", APP_TITLE, APP_VERSION ); | |
| 60 | out( "Copyright 2016-%s White Magic Software, Ltd.", APP_YEAR ); | |
| 61 | out( "Portions copyright 2015-2020 Karl Tauber.%n" ); | |
| 62 | } | |
| 63 | 33 | |
| 64 | 34 | /** |
| ... | ||
| 76 | 46 | } catch( final Exception ex ) { |
| 77 | 47 | throw new RuntimeException( ex ); |
| 48 | } | |
| 49 | } | |
| 50 | ||
| 51 | /** | |
| 52 | * Immediately exits the application. | |
| 53 | * | |
| 54 | * @param exitCode Code to provide back to the calling shell. | |
| 55 | */ | |
| 56 | public static void terminate( final int exitCode ) { | |
| 57 | System.exit( exitCode ); | |
| 58 | } | |
| 59 | ||
| 60 | private static void parse( final String[] args ) { | |
| 61 | assert args != null; | |
| 62 | ||
| 63 | final var arguments = new Arguments( new Launcher( args ) ); | |
| 64 | final var parser = new CommandLine( arguments ); | |
| 65 | ||
| 66 | parser.setColorScheme( ColourScheme.create() ); | |
| 67 | ||
| 68 | final var exitCode = parser.execute( args ); | |
| 69 | final var parseResult = parser.getParseResult(); | |
| 70 | ||
| 71 | if( parseResult.isUsageHelpRequested() ) { | |
| 72 | terminate( exitCode ); | |
| 73 | } | |
| 74 | else if( parseResult.isVersionHelpRequested() ) { | |
| 75 | showAppInfo(); | |
| 76 | terminate( exitCode ); | |
| 78 | 77 | } |
| 79 | 78 | } |
| ... | ||
| 183 | 182 | log( t ); |
| 184 | 183 | } |
| 184 | } | |
| 185 | ||
| 186 | /** | |
| 187 | * Suppress writing to standard error, suppresses writing log messages. | |
| 188 | */ | |
| 189 | private static void disableLogging() { | |
| 190 | LogManager.getLogManager().reset(); | |
| 191 | System.err.close(); | |
| 185 | 192 | } |
| 186 | 193 | |
| 194 | private static void showAppInfo() { | |
| 195 | out( "%n%s version %s", APP_TITLE, APP_VERSION ); | |
| 196 | out( "Copyright 2016-%s White Magic Software, Ltd.", APP_YEAR ); | |
| 197 | out( "Portions copyright 2015-2020 Karl Tauber.%n" ); | |
| 198 | } | |
| 187 | 199 | } |
| 188 | 200 | |
| 5 | 5 | import com.keenwrite.editors.TextEditor; |
| 6 | 6 | import com.keenwrite.editors.TextResource; |
| 7 | import com.keenwrite.editors.definition.DefinitionEditor; | |
| 8 | import com.keenwrite.editors.definition.TreeTransformer; | |
| 9 | import com.keenwrite.editors.definition.yaml.YamlTreeTransformer; | |
| 10 | import com.keenwrite.editors.markdown.MarkdownEditor; | |
| 11 | import com.keenwrite.events.*; | |
| 12 | import com.keenwrite.io.MediaType; | |
| 13 | import com.keenwrite.preferences.Key; | |
| 14 | import com.keenwrite.preferences.Workspace; | |
| 15 | import com.keenwrite.preview.HtmlPreview; | |
| 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() ); | |
| 7 | import com.keenwrite.editors.common.ScrollEventHandler; | |
| 8 | import com.keenwrite.editors.common.VariableNameInjector; | |
| 9 | import com.keenwrite.editors.definition.DefinitionEditor; | |
| 10 | import com.keenwrite.editors.definition.TreeTransformer; | |
| 11 | import com.keenwrite.editors.definition.yaml.YamlTreeTransformer; | |
| 12 | import com.keenwrite.editors.markdown.MarkdownEditor; | |
| 13 | import com.keenwrite.events.*; | |
| 14 | import com.keenwrite.io.MediaType; | |
| 15 | import com.keenwrite.preferences.Key; | |
| 16 | import com.keenwrite.preferences.Workspace; | |
| 17 | import com.keenwrite.preview.HtmlPreview; | |
| 18 | import com.keenwrite.processors.HtmlPreviewProcessor; | |
| 19 | import com.keenwrite.processors.Processor; | |
| 20 | import com.keenwrite.processors.ProcessorContext; | |
| 21 | import com.keenwrite.processors.ProcessorFactory; | |
| 22 | import com.keenwrite.processors.r.InlineRProcessor; | |
| 23 | import com.keenwrite.service.events.Notifier; | |
| 24 | import com.keenwrite.sigils.PropertyKeyOperator; | |
| 25 | import com.keenwrite.sigils.RKeyOperator; | |
| 26 | import com.keenwrite.ui.explorer.FilePickerFactory; | |
| 27 | import com.keenwrite.ui.heuristics.DocumentStatistics; | |
| 28 | import com.keenwrite.ui.outline.DocumentOutline; | |
| 29 | import com.keenwrite.util.GenericBuilder; | |
| 30 | import com.panemu.tiwulfx.control.dock.DetachableTab; | |
| 31 | import com.panemu.tiwulfx.control.dock.DetachableTabPane; | |
| 32 | import javafx.application.Platform; | |
| 33 | import javafx.beans.property.*; | |
| 34 | import javafx.collections.ListChangeListener; | |
| 35 | import javafx.concurrent.Task; | |
| 36 | import javafx.event.ActionEvent; | |
| 37 | import javafx.event.Event; | |
| 38 | import javafx.event.EventHandler; | |
| 39 | import javafx.scene.Node; | |
| 40 | import javafx.scene.Scene; | |
| 41 | import javafx.scene.control.*; | |
| 42 | import javafx.scene.control.TreeItem.TreeModificationEvent; | |
| 43 | import javafx.scene.input.KeyEvent; | |
| 44 | import javafx.scene.layout.FlowPane; | |
| 45 | import javafx.stage.Stage; | |
| 46 | import javafx.stage.Window; | |
| 47 | import org.greenrobot.eventbus.Subscribe; | |
| 48 | ||
| 49 | import java.io.File; | |
| 50 | import java.io.FileNotFoundException; | |
| 51 | import java.nio.file.Path; | |
| 52 | import java.util.*; | |
| 53 | import java.util.concurrent.ExecutorService; | |
| 54 | import java.util.concurrent.ScheduledExecutorService; | |
| 55 | import java.util.concurrent.ScheduledFuture; | |
| 56 | import java.util.concurrent.atomic.AtomicBoolean; | |
| 57 | import java.util.concurrent.atomic.AtomicReference; | |
| 58 | import java.util.function.Function; | |
| 59 | import java.util.function.UnaryOperator; | |
| 60 | import java.util.stream.Collectors; | |
| 61 | ||
| 62 | import static com.keenwrite.ExportFormat.NONE; | |
| 63 | import static com.keenwrite.Launcher.terminate; | |
| 64 | import static com.keenwrite.Messages.get; | |
| 65 | import static com.keenwrite.constants.Constants.*; | |
| 66 | import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG_NODE; | |
| 67 | import static com.keenwrite.events.Bus.register; | |
| 68 | import static com.keenwrite.events.StatusEvent.clue; | |
| 69 | import static com.keenwrite.io.MediaType.*; | |
| 70 | import static com.keenwrite.preferences.AppKeys.*; | |
| 71 | import static com.keenwrite.processors.IdentityProcessor.IDENTITY; | |
| 72 | import static com.keenwrite.processors.ProcessorContext.Mutator; | |
| 73 | import static com.keenwrite.processors.ProcessorContext.builder; | |
| 74 | import static com.keenwrite.processors.ProcessorFactory.createProcessors; | |
| 75 | import static java.lang.String.format; | |
| 76 | import static java.lang.System.getProperty; | |
| 77 | import static java.util.concurrent.Executors.newFixedThreadPool; | |
| 78 | import static java.util.concurrent.Executors.newScheduledThreadPool; | |
| 79 | import static java.util.concurrent.TimeUnit.SECONDS; | |
| 80 | import static java.util.stream.Collectors.groupingBy; | |
| 81 | import static javafx.application.Platform.runLater; | |
| 82 | import static javafx.scene.control.Alert.AlertType.ERROR; | |
| 83 | import static javafx.scene.control.ButtonType.*; | |
| 84 | import static javafx.scene.control.TabPane.TabClosingPolicy.ALL_TABS; | |
| 85 | import static javafx.scene.input.KeyCode.SPACE; | |
| 86 | import static javafx.scene.input.KeyCombination.CONTROL_DOWN; | |
| 87 | import static javafx.util.Duration.millis; | |
| 88 | import static javax.swing.SwingUtilities.invokeLater; | |
| 89 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 90 | ||
| 91 | /** | |
| 92 | * Responsible for wiring together the main application components for a | |
| 93 | * particular {@link Workspace} (project). These include the definition views, | |
| 94 | * text editors, and preview pane along with any corresponding controllers. | |
| 95 | */ | |
| 96 | public final class MainPane extends SplitPane { | |
| 97 | ||
| 98 | private static final ExecutorService sExecutor = newFixedThreadPool( 1 ); | |
| 99 | private static final Notifier sNotifier = Services.load( Notifier.class ); | |
| 100 | ||
| 101 | /** | |
| 102 | * Used when opening files to determine how each file should be binned and | |
| 103 | * therefore what tab pane to be opened within. | |
| 104 | */ | |
| 105 | private static final Set<MediaType> PLAIN_TEXT_FORMAT = Set.of( | |
| 106 | TEXT_MARKDOWN, TEXT_R_MARKDOWN, UNDEFINED | |
| 107 | ); | |
| 108 | ||
| 109 | private final ScheduledExecutorService mSaver = newScheduledThreadPool( 1 ); | |
| 110 | private final AtomicReference<ScheduledFuture<?>> mSaveTask = | |
| 111 | new AtomicReference<>(); | |
| 112 | ||
| 113 | /** | |
| 114 | * Prevents re-instantiation of processing classes. | |
| 115 | */ | |
| 116 | private final Map<TextResource, Processor<String>> mProcessors = | |
| 117 | new HashMap<>(); | |
| 118 | ||
| 119 | private final Workspace mWorkspace; | |
| 120 | ||
| 121 | /** | |
| 122 | * Groups similar file type tabs together. | |
| 123 | */ | |
| 124 | private final List<TabPane> mTabPanes = new ArrayList<>(); | |
| 125 | ||
| 126 | /** | |
| 127 | * Renders the actively selected plain text editor tab. | |
| 128 | */ | |
| 129 | private final HtmlPreview mPreview; | |
| 130 | ||
| 131 | /** | |
| 132 | * Provides an interactive document outline. | |
| 133 | */ | |
| 134 | private final DocumentOutline mOutline = new DocumentOutline(); | |
| 135 | ||
| 136 | /** | |
| 137 | * Changing the active editor fires the value changed event. This allows | |
| 138 | * refreshes to happen when external definitions are modified and need to | |
| 139 | * trigger the processing chain. | |
| 140 | */ | |
| 141 | private final ObjectProperty<TextEditor> mTextEditor = | |
| 142 | createActiveTextEditor(); | |
| 143 | ||
| 144 | /** | |
| 145 | * Changing the active definition editor fires the value changed event. This | |
| 146 | * allows refreshes to happen when external definitions are modified and need | |
| 147 | * to trigger the processing chain. | |
| 148 | */ | |
| 149 | private final ObjectProperty<TextDefinition> mDefinitionEditor; | |
| 150 | ||
| 151 | /** | |
| 152 | * Called when the definition data is changed. | |
| 153 | */ | |
| 154 | private final EventHandler<TreeModificationEvent<Event>> mTreeHandler = | |
| 155 | event -> { | |
| 156 | process( getTextEditor() ); | |
| 157 | save( getTextDefinition() ); | |
| 158 | }; | |
| 159 | ||
| 160 | /** | |
| 161 | * Tracks the number of detached tab panels opened into their own windows, | |
| 162 | * which allows unique identification of subordinate windows by their title. | |
| 163 | * It is doubtful more than 128 windows, much less 256, will be created. | |
| 164 | */ | |
| 165 | private byte mWindowCount; | |
| 166 | ||
| 167 | private final DocumentStatistics mStatistics; | |
| 168 | ||
| 169 | /** | |
| 170 | * Adds all content panels to the main user interface. This will load the | |
| 171 | * configuration settings from the workspace to reproduce the settings from | |
| 172 | * a previous session. | |
| 173 | */ | |
| 174 | public MainPane( final Workspace workspace ) { | |
| 175 | mWorkspace = workspace; | |
| 176 | mPreview = new HtmlPreview( workspace ); | |
| 177 | mStatistics = new DocumentStatistics( workspace ); | |
| 178 | mTextEditor.set( new MarkdownEditor( workspace ) ); | |
| 179 | mDefinitionEditor = createActiveDefinitionEditor( mTextEditor ); | |
| 180 | ||
| 181 | open( collect( getRecentFiles() ) ); | |
| 182 | viewPreview(); | |
| 183 | setDividerPositions( calculateDividerPositions() ); | |
| 184 | ||
| 185 | // Once the main scene's window regains focus, update the active definition | |
| 186 | // editor to the currently selected tab. | |
| 187 | runLater( () -> getWindow().setOnCloseRequest( event -> { | |
| 188 | // Order matters: Open file names must be persisted before closing all. | |
| 189 | mWorkspace.save(); | |
| 190 | ||
| 191 | if( closeAll() ) { | |
| 192 | Platform.exit(); | |
| 193 | terminate( 0 ); | |
| 194 | } | |
| 195 | ||
| 196 | event.consume(); | |
| 197 | } ) ); | |
| 198 | ||
| 199 | register( this ); | |
| 200 | initAutosave( workspace ); | |
| 201 | } | |
| 202 | ||
| 203 | @Subscribe | |
| 204 | public void handle( final TextEditorFocusEvent event ) { | |
| 205 | mTextEditor.set( event.get() ); | |
| 206 | } | |
| 207 | ||
| 208 | @Subscribe | |
| 209 | public void handle( final TextDefinitionFocusEvent event ) { | |
| 210 | mDefinitionEditor.set( event.get() ); | |
| 211 | } | |
| 212 | ||
| 213 | /** | |
| 214 | * Typically called when a file name is clicked in the preview panel. | |
| 215 | * | |
| 216 | * @param event The event to process, must contain a valid file reference. | |
| 217 | */ | |
| 218 | @Subscribe | |
| 219 | public void handle( final FileOpenEvent event ) { | |
| 220 | final File eventFile; | |
| 221 | final var eventUri = event.getUri(); | |
| 222 | ||
| 223 | if( eventUri.isAbsolute() ) { | |
| 224 | eventFile = new File( eventUri.getPath() ); | |
| 225 | } | |
| 226 | else { | |
| 227 | final var activeFile = getTextEditor().getFile(); | |
| 228 | final var parent = activeFile.getParentFile(); | |
| 229 | ||
| 230 | if( parent == null ) { | |
| 231 | clue( new FileNotFoundException( eventUri.getPath() ) ); | |
| 232 | return; | |
| 233 | } | |
| 234 | else { | |
| 235 | final var parentPath = parent.getAbsolutePath(); | |
| 236 | eventFile = Path.of( parentPath, eventUri.getPath() ).toFile(); | |
| 237 | } | |
| 238 | } | |
| 239 | ||
| 240 | runLater( () -> open( eventFile ) ); | |
| 241 | } | |
| 242 | ||
| 243 | @Subscribe | |
| 244 | public void handle( final CaretNavigationEvent event ) { | |
| 245 | runLater( () -> { | |
| 246 | final var textArea = getTextEditor().getTextArea(); | |
| 247 | textArea.moveTo( event.getOffset() ); | |
| 248 | textArea.requestFollowCaret(); | |
| 249 | textArea.requestFocus(); | |
| 250 | } ); | |
| 251 | } | |
| 252 | ||
| 253 | @Subscribe | |
| 254 | @SuppressWarnings( "unused" ) | |
| 255 | public void handle( final ExportFailedEvent event ) { | |
| 256 | final var os = getProperty( "os.name" ); | |
| 257 | final var arch = getProperty( "os.arch" ).toLowerCase(); | |
| 258 | final var bits = getProperty( "sun.arch.data.model" ); | |
| 259 | ||
| 260 | final var title = Messages.get( "Alert.typesetter.missing.title" ); | |
| 261 | final var header = Messages.get( "Alert.typesetter.missing.header" ); | |
| 262 | final var version = Messages.get( | |
| 263 | "Alert.typesetter.missing.version", | |
| 264 | os, | |
| 265 | arch | |
| 266 | .replaceAll( "amd.*|i.*|x86.*", "X86" ) | |
| 267 | .replaceAll( "mips.*", "MIPS" ) | |
| 268 | .replaceAll( "armv.*", "ARM" ), | |
| 269 | bits ); | |
| 270 | final var text = Messages.get( "Alert.typesetter.missing.installer.text" ); | |
| 271 | ||
| 272 | // Download and install ConTeXt for {0} {1} {2}-bit | |
| 273 | final var content = format( "%s %s", text, version ); | |
| 274 | final var flowPane = new FlowPane(); | |
| 275 | final var link = new Hyperlink( text ); | |
| 276 | final var label = new Label( version ); | |
| 277 | flowPane.getChildren().addAll( link, label ); | |
| 278 | ||
| 279 | final var alert = new Alert( ERROR, content, OK ); | |
| 280 | alert.setTitle( title ); | |
| 281 | alert.setHeaderText( header ); | |
| 282 | alert.getDialogPane().contentProperty().set( flowPane ); | |
| 283 | alert.setGraphic( ICON_DIALOG_NODE ); | |
| 284 | ||
| 285 | link.setOnAction( ( e ) -> { | |
| 286 | alert.close(); | |
| 287 | final var url = Messages.get( "Alert.typesetter.missing.installer.url" ); | |
| 288 | runLater( () -> HyperlinkOpenEvent.fire( url ) ); | |
| 289 | } ); | |
| 290 | ||
| 291 | alert.showAndWait(); | |
| 292 | } | |
| 293 | ||
| 294 | private void initAutosave( final Workspace workspace ) { | |
| 295 | final var rate = workspace.integerProperty( KEY_EDITOR_AUTOSAVE ); | |
| 296 | ||
| 297 | rate.addListener( | |
| 298 | ( c, o, n ) -> { | |
| 299 | final var taskRef = mSaveTask.get(); | |
| 300 | ||
| 301 | // Prevent multiple autosaves from running. | |
| 302 | if( taskRef != null ) { | |
| 303 | taskRef.cancel( false ); | |
| 304 | } | |
| 305 | ||
| 306 | initAutosave( rate ); | |
| 307 | } | |
| 308 | ); | |
| 309 | ||
| 310 | // Start the save listener (avoids duplicating some code). | |
| 311 | initAutosave( rate ); | |
| 312 | } | |
| 313 | ||
| 314 | private void initAutosave( final IntegerProperty rate ) { | |
| 315 | mSaveTask.set( | |
| 316 | mSaver.scheduleAtFixedRate( | |
| 317 | () -> { | |
| 318 | if( getTextEditor().isModified() ) { | |
| 319 | // Ensure the modified indicator is cleared by running on EDT. | |
| 320 | runLater( this::save ); | |
| 321 | } | |
| 322 | }, 0, rate.intValue(), SECONDS | |
| 323 | ) | |
| 324 | ); | |
| 325 | } | |
| 326 | ||
| 327 | /** | |
| 328 | * TODO: Load divider positions from exported settings, see | |
| 329 | * {@link #collect(SetProperty)} comment. | |
| 330 | */ | |
| 331 | private double[] calculateDividerPositions() { | |
| 332 | final var ratio = 100f / getItems().size() / 100; | |
| 333 | final var positions = getDividerPositions(); | |
| 334 | ||
| 335 | for( int i = 0; i < positions.length; i++ ) { | |
| 336 | positions[ i ] = ratio * i; | |
| 337 | } | |
| 338 | ||
| 339 | return positions; | |
| 340 | } | |
| 341 | ||
| 342 | /** | |
| 343 | * Opens all the files into the application, provided the paths are unique. | |
| 344 | * This may only be called for any type of files that a user can edit | |
| 345 | * (i.e., update and persist), such as definitions and text files. | |
| 346 | * | |
| 347 | * @param files The list of files to open. | |
| 348 | */ | |
| 349 | public void open( final List<File> files ) { | |
| 350 | files.forEach( this::open ); | |
| 351 | } | |
| 352 | ||
| 353 | /** | |
| 354 | * This opens the given file. Since the preview pane is not a file that | |
| 355 | * can be opened, it is safe to add a listener to the detachable pane. | |
| 356 | * This will exit early if the given file is not a regular file (i.e., a | |
| 357 | * directory). | |
| 358 | * | |
| 359 | * @param inputFile The file to open. | |
| 360 | */ | |
| 361 | private void open( final File inputFile ) { | |
| 362 | // Prevent opening directories (a non-existent "untitled.md" is fine). | |
| 363 | if( !inputFile.isFile() && inputFile.exists() ) { | |
| 364 | return; | |
| 365 | } | |
| 366 | ||
| 367 | final var tab = createTab( inputFile ); | |
| 368 | final var node = tab.getContent(); | |
| 369 | final var mediaType = MediaType.valueFrom( inputFile ); | |
| 370 | final var tabPane = obtainTabPane( mediaType ); | |
| 371 | ||
| 372 | tab.setTooltip( createTooltip( inputFile ) ); | |
| 373 | tabPane.setFocusTraversable( false ); | |
| 374 | tabPane.setTabClosingPolicy( ALL_TABS ); | |
| 375 | tabPane.getTabs().add( tab ); | |
| 376 | ||
| 377 | // Attach the tab scene factory for new tab panes. | |
| 378 | if( !getItems().contains( tabPane ) ) { | |
| 379 | addTabPane( | |
| 380 | node instanceof TextDefinition ? 0 : getItems().size(), tabPane | |
| 381 | ); | |
| 382 | } | |
| 383 | ||
| 384 | if( inputFile.isFile() ) { | |
| 385 | getRecentFiles().add( inputFile.getAbsolutePath() ); | |
| 386 | } | |
| 387 | } | |
| 388 | ||
| 389 | /** | |
| 390 | * Opens a new text editor document using the default document file name. | |
| 391 | */ | |
| 392 | public void newTextEditor() { | |
| 393 | open( DOCUMENT_DEFAULT ); | |
| 394 | } | |
| 395 | ||
| 396 | /** | |
| 397 | * Opens a new definition editor document using the default definition | |
| 398 | * file name. | |
| 399 | */ | |
| 400 | public void newDefinitionEditor() { | |
| 401 | open( DEFINITION_DEFAULT ); | |
| 402 | } | |
| 403 | ||
| 404 | /** | |
| 405 | * Iterates over all tab panes to find all {@link TextEditor}s and request | |
| 406 | * that they save themselves. | |
| 407 | */ | |
| 408 | public void saveAll() { | |
| 409 | mTabPanes.forEach( | |
| 410 | tp -> tp.getTabs().forEach( tab -> { | |
| 411 | final var node = tab.getContent(); | |
| 412 | ||
| 413 | if( node instanceof final TextEditor editor ) { | |
| 414 | save( editor ); | |
| 415 | } | |
| 416 | } ) | |
| 417 | ); | |
| 418 | } | |
| 419 | ||
| 420 | /** | |
| 421 | * Requests that the active {@link TextEditor} saves itself. Don't bother | |
| 422 | * checking if modified first because if the user swaps external media from | |
| 423 | * an external source (e.g., USB thumb drive), save should not second-guess | |
| 424 | * the user: save always re-saves. Also, it's less code. | |
| 425 | */ | |
| 426 | public void save() { | |
| 427 | save( getTextEditor() ); | |
| 428 | } | |
| 429 | ||
| 430 | /** | |
| 431 | * Saves the active {@link TextEditor} under a new name. | |
| 432 | * | |
| 433 | * @param files The new active editor {@link File} reference, must contain | |
| 434 | * at least one element. | |
| 435 | */ | |
| 436 | public void saveAs( final List<File> files ) { | |
| 437 | assert files != null; | |
| 438 | assert !files.isEmpty(); | |
| 439 | final var editor = getTextEditor(); | |
| 440 | final var tab = getTab( editor ); | |
| 441 | final var file = files.get( 0 ); | |
| 442 | ||
| 443 | editor.rename( file ); | |
| 444 | tab.ifPresent( t -> { | |
| 445 | t.setText( editor.getFilename() ); | |
| 446 | t.setTooltip( createTooltip( file ) ); | |
| 447 | } ); | |
| 448 | ||
| 449 | save(); | |
| 450 | } | |
| 451 | ||
| 452 | /** | |
| 453 | * Saves the given {@link TextResource} to a file. This is typically used | |
| 454 | * to save either an instance of {@link TextEditor} or {@link TextDefinition}. | |
| 455 | * | |
| 456 | * @param resource The resource to export. | |
| 457 | */ | |
| 458 | private void save( final TextResource resource ) { | |
| 459 | try { | |
| 460 | resource.save(); | |
| 461 | } catch( final Exception ex ) { | |
| 462 | clue( ex ); | |
| 463 | sNotifier.alert( | |
| 464 | getWindow(), resource.getPath(), "TextResource.saveFailed", ex | |
| 465 | ); | |
| 466 | } | |
| 467 | } | |
| 468 | ||
| 469 | /** | |
| 470 | * Closes all open {@link TextEditor}s; all {@link TextDefinition}s stay open. | |
| 471 | * | |
| 472 | * @return {@code true} when all editors, modified or otherwise, were | |
| 473 | * permitted to close; {@code false} when one or more editors were modified | |
| 474 | * and the user requested no closing. | |
| 475 | */ | |
| 476 | public boolean closeAll() { | |
| 477 | var closable = true; | |
| 478 | ||
| 479 | for( final var tabPane : mTabPanes ) { | |
| 480 | final var tabIterator = tabPane.getTabs().iterator(); | |
| 481 | ||
| 482 | while( tabIterator.hasNext() ) { | |
| 483 | final var tab = tabIterator.next(); | |
| 484 | final var resource = tab.getContent(); | |
| 485 | ||
| 486 | // The definition panes auto-save, so being specific here prevents | |
| 487 | // closing the definitions in the situation where the user wants to | |
| 488 | // continue editing (i.e., possibly save unsaved work). | |
| 489 | if( !(resource instanceof TextEditor) ) { | |
| 490 | continue; | |
| 491 | } | |
| 492 | ||
| 493 | if( canClose( (TextEditor) resource ) ) { | |
| 494 | tabIterator.remove(); | |
| 495 | close( tab ); | |
| 496 | } | |
| 497 | else { | |
| 498 | closable = false; | |
| 499 | } | |
| 500 | } | |
| 501 | } | |
| 502 | ||
| 503 | return closable; | |
| 504 | } | |
| 505 | ||
| 506 | /** | |
| 507 | * Calls the tab's {@link Tab#getOnClosed()} handler to carry out a close | |
| 508 | * event. | |
| 509 | * | |
| 510 | * @param tab The {@link Tab} that was closed. | |
| 511 | */ | |
| 512 | private void close( final Tab tab ) { | |
| 513 | assert tab != null; | |
| 514 | ||
| 515 | final var handler = tab.getOnClosed(); | |
| 516 | ||
| 517 | if( handler != null ) { | |
| 518 | handler.handle( new ActionEvent() ); | |
| 519 | } | |
| 520 | } | |
| 521 | ||
| 522 | /** | |
| 523 | * Closes the active tab; delegates to {@link #canClose(TextResource)}. | |
| 524 | */ | |
| 525 | public void close() { | |
| 526 | final var editor = getTextEditor(); | |
| 527 | ||
| 528 | if( canClose( editor ) ) { | |
| 529 | close( editor ); | |
| 530 | } | |
| 531 | } | |
| 532 | ||
| 533 | /** | |
| 534 | * Closes the given {@link TextResource}. This must not be called from within | |
| 535 | * a loop that iterates over the tab panes using {@code forEach}, lest a | |
| 536 | * concurrent modification exception be thrown. | |
| 537 | * | |
| 538 | * @param resource The {@link TextResource} to close, without confirming with | |
| 539 | * the user. | |
| 540 | */ | |
| 541 | private void close( final TextResource resource ) { | |
| 542 | getTab( resource ).ifPresent( | |
| 543 | ( tab ) -> { | |
| 544 | close( tab ); | |
| 545 | tab.getTabPane().getTabs().remove( tab ); | |
| 546 | } | |
| 547 | ); | |
| 548 | } | |
| 549 | ||
| 550 | /** | |
| 551 | * Answers whether the given {@link TextResource} may be closed. | |
| 552 | * | |
| 553 | * @param editor The {@link TextResource} to try closing. | |
| 554 | * @return {@code true} when the editor may be closed; {@code false} when | |
| 555 | * the user has requested to keep the editor open. | |
| 556 | */ | |
| 557 | private boolean canClose( final TextResource editor ) { | |
| 558 | final var editorTab = getTab( editor ); | |
| 559 | final var canClose = new AtomicBoolean( true ); | |
| 560 | ||
| 561 | if( editor.isModified() ) { | |
| 562 | final var filename = new StringBuilder(); | |
| 563 | editorTab.ifPresent( ( tab ) -> filename.append( tab.getText() ) ); | |
| 564 | ||
| 565 | final var message = sNotifier.createNotification( | |
| 566 | Messages.get( "Alert.file.close.title" ), | |
| 567 | Messages.get( "Alert.file.close.text" ), | |
| 568 | filename.toString() | |
| 569 | ); | |
| 570 | ||
| 571 | final var dialog = sNotifier.createConfirmation( getWindow(), message ); | |
| 572 | ||
| 573 | dialog.showAndWait().ifPresent( | |
| 574 | save -> canClose.set( save == YES ? editor.save() : save == NO ) | |
| 575 | ); | |
| 576 | } | |
| 577 | ||
| 578 | return canClose.get(); | |
| 579 | } | |
| 580 | ||
| 581 | private ObjectProperty<TextEditor> createActiveTextEditor() { | |
| 582 | final var editor = new SimpleObjectProperty<TextEditor>(); | |
| 583 | ||
| 584 | editor.addListener( ( c, o, n ) -> { | |
| 585 | if( n != null ) { | |
| 586 | mPreview.setBaseUri( n.getPath() ); | |
| 587 | process( n ); | |
| 588 | } | |
| 589 | } ); | |
| 590 | ||
| 591 | return editor; | |
| 592 | } | |
| 593 | ||
| 594 | /** | |
| 595 | * Adds the HTML preview tab to its own, singular tab pane. | |
| 596 | */ | |
| 597 | public void viewPreview() { | |
| 598 | viewTab( mPreview, TEXT_HTML, "Pane.preview.title" ); | |
| 599 | } | |
| 600 | ||
| 601 | /** | |
| 602 | * Adds the document outline tab to its own, singular tab pane. | |
| 603 | */ | |
| 604 | public void viewOutline() { | |
| 605 | viewTab( mOutline, APP_DOCUMENT_OUTLINE, "Pane.outline.title" ); | |
| 606 | } | |
| 607 | ||
| 608 | public void viewStatistics() { | |
| 609 | viewTab( mStatistics, APP_DOCUMENT_STATISTICS, "Pane.statistics.title" ); | |
| 610 | } | |
| 611 | ||
| 612 | public void viewFiles() { | |
| 613 | try { | |
| 614 | final var factory = new FilePickerFactory( getWorkspace() ); | |
| 615 | final var fileManager = factory.createModeless(); | |
| 616 | viewTab( fileManager, APP_FILE_MANAGER, "Pane.files.title" ); | |
| 617 | } catch( final Exception ex ) { | |
| 618 | clue( ex ); | |
| 619 | } | |
| 620 | } | |
| 621 | ||
| 622 | private void viewTab( | |
| 623 | final Node node, final MediaType mediaType, final String key ) { | |
| 624 | final var tabPane = obtainTabPane( mediaType ); | |
| 625 | ||
| 626 | for( final var tab : tabPane.getTabs() ) { | |
| 627 | if( tab.getContent() == node ) { | |
| 628 | return; | |
| 629 | } | |
| 630 | } | |
| 631 | ||
| 632 | tabPane.getTabs().add( createTab( get( key ), node ) ); | |
| 633 | addTabPane( tabPane ); | |
| 634 | } | |
| 635 | ||
| 636 | public void viewRefresh() { | |
| 637 | mPreview.refresh(); | |
| 638 | } | |
| 639 | ||
| 640 | /** | |
| 641 | * Returns the tab that contains the given {@link TextEditor}. | |
| 642 | * | |
| 643 | * @param editor The {@link TextEditor} instance to find amongst the tabs. | |
| 644 | * @return The first tab having content that matches the given tab. | |
| 645 | */ | |
| 646 | private Optional<Tab> getTab( final TextResource editor ) { | |
| 647 | return mTabPanes.stream() | |
| 648 | .flatMap( pane -> pane.getTabs().stream() ) | |
| 649 | .filter( tab -> editor.equals( tab.getContent() ) ) | |
| 650 | .findFirst(); | |
| 651 | } | |
| 652 | ||
| 653 | /** | |
| 654 | * Creates a new {@link DefinitionEditor} wrapped in a listener that | |
| 655 | * is used to detect when the active {@link DefinitionEditor} has changed. | |
| 656 | * Upon changing, the variables are interpolated and the active text editor | |
| 657 | * is refreshed. | |
| 658 | * | |
| 659 | * @param textEditor Text editor to update with the revised resolved map. | |
| 660 | * @return A newly configured property that represents the active | |
| 661 | * {@link DefinitionEditor}, never null. | |
| 662 | */ | |
| 663 | private ObjectProperty<TextDefinition> createActiveDefinitionEditor( | |
| 664 | final ObjectProperty<TextEditor> textEditor ) { | |
| 665 | final var defEditor = new SimpleObjectProperty<>( | |
| 666 | createDefinitionEditor() | |
| 667 | ); | |
| 668 | ||
| 669 | defEditor.addListener( ( c, o, n ) -> process( textEditor.get() ) ); | |
| 670 | ||
| 671 | return defEditor; | |
| 672 | } | |
| 673 | ||
| 674 | private Tab createTab( final String filename, final Node node ) { | |
| 675 | return new DetachableTab( filename, node ); | |
| 676 | } | |
| 677 | ||
| 678 | private Tab createTab( final File file ) { | |
| 679 | final var r = createTextResource( file ); | |
| 680 | final var tab = createTab( r.getFilename(), r.getNode() ); | |
| 681 | ||
| 682 | r.modifiedProperty().addListener( | |
| 683 | ( c, o, n ) -> tab.setText( r.getFilename() + (n ? "*" : "") ) | |
| 684 | ); | |
| 685 | ||
| 686 | // This is called when either the tab is closed by the user clicking on | |
| 687 | // the tab's close icon or when closing (all) from the file menu. | |
| 688 | tab.setOnClosed( | |
| 689 | ( __ ) -> getRecentFiles().remove( file.getAbsolutePath() ) | |
| 690 | ); | |
| 691 | ||
| 692 | // When closing a tab, give focus to the newly revealed tab. | |
| 693 | tab.selectedProperty().addListener( ( c, o, n ) -> { | |
| 694 | if( n != null && n ) { | |
| 695 | final var pane = tab.getTabPane(); | |
| 696 | ||
| 697 | if( pane != null ) { | |
| 698 | pane.requestFocus(); | |
| 699 | } | |
| 700 | } | |
| 701 | } ); | |
| 702 | ||
| 703 | tab.tabPaneProperty().addListener( ( cPane, oPane, nPane ) -> { | |
| 704 | if( nPane != null ) { | |
| 705 | nPane.focusedProperty().addListener( ( c, o, n ) -> { | |
| 706 | if( n != null && n ) { | |
| 707 | final var selected = nPane.getSelectionModel().getSelectedItem(); | |
| 708 | final var node = selected.getContent(); | |
| 709 | node.requestFocus(); | |
| 710 | } | |
| 711 | } ); | |
| 712 | } | |
| 713 | } ); | |
| 714 | ||
| 715 | return tab; | |
| 716 | } | |
| 717 | ||
| 718 | /** | |
| 719 | * Creates bins for the different {@link MediaType}s, which eventually are | |
| 720 | * added to the UI as separate tab panes. If ever a general-purpose scene | |
| 721 | * exporter is developed to serialize a scene to an FXML file, this could | |
| 722 | * be replaced by such a class. | |
| 723 | * <p> | |
| 724 | * When binning the files, this makes sure that at least one file exists | |
| 725 | * for every type. If the user has opted to close a particular type (such | |
| 726 | * as the definition pane), the view will suppressed elsewhere. | |
| 727 | * </p> | |
| 728 | * <p> | |
| 729 | * The order that the binned files are returned will be reflected in the | |
| 730 | * order that the corresponding panes are rendered in the UI. | |
| 731 | * </p> | |
| 732 | * | |
| 733 | * @param paths The file paths to bin according to their type. | |
| 734 | * @return An in-order list of files, first by structured definition files, | |
| 735 | * then by plain text documents. | |
| 736 | */ | |
| 737 | private List<File> collect( final SetProperty<String> paths ) { | |
| 738 | // Treat all files destined for the text editor as plain text documents | |
| 739 | // so that they are added to the same pane. Grouping by TEXT_PLAIN is a | |
| 740 | // bit arbitrary, but means explicitly capturing TEXT_PLAIN isn't needed. | |
| 741 | final Function<MediaType, MediaType> bin = | |
| 742 | m -> PLAIN_TEXT_FORMAT.contains( m ) ? TEXT_PLAIN : m; | |
| 743 | ||
| 744 | // Create two groups: YAML files and plain text files. The order that | |
| 745 | // the elements are listed in the enumeration for media types determines | |
| 746 | // what files are loaded first. Variable definitions come before all other | |
| 747 | // plain text documents. | |
| 748 | final var bins = paths | |
| 749 | .stream() | |
| 750 | .collect( | |
| 751 | groupingBy( | |
| 752 | path -> bin.apply( MediaType.fromFilename( path ) ), | |
| 753 | () -> new TreeMap<>( Enum::compareTo ), | |
| 754 | Collectors.toList() | |
| 755 | ) | |
| 756 | ); | |
| 757 | ||
| 758 | bins.putIfAbsent( TEXT_YAML, List.of( DEFINITION_DEFAULT.toString() ) ); | |
| 759 | bins.putIfAbsent( TEXT_PLAIN, List.of( DOCUMENT_DEFAULT.toString() ) ); | |
| 760 | ||
| 761 | final var result = new LinkedList<File>(); | |
| 762 | ||
| 763 | // Ensure that the same types are listed together (keep insertion order). | |
| 764 | bins.forEach( ( mediaType, files ) -> result.addAll( | |
| 765 | files.stream().map( File::new ).toList() ) | |
| 766 | ); | |
| 767 | ||
| 768 | return result; | |
| 769 | } | |
| 770 | ||
| 771 | /** | |
| 772 | * Force the active editor to update, which will cause the processor | |
| 773 | * to re-evaluate the interpolated definition map thereby updating the | |
| 774 | * preview pane. | |
| 775 | * | |
| 776 | * @param editor Contains the source document to update in the preview pane. | |
| 777 | */ | |
| 778 | private void process( final TextEditor editor ) { | |
| 779 | // Ensure processing does not run on the JavaFX thread, which frees the | |
| 780 | // text editor immediately for caret movement. The preview will have a | |
| 781 | // slight delay when catching up to the caret position. | |
| 782 | final var task = new Task<Void>() { | |
| 783 | @Override | |
| 784 | public Void call() { | |
| 785 | try { | |
| 786 | final var p = mProcessors.getOrDefault( editor, IDENTITY ); | |
| 787 | p.apply( editor == null ? "" : editor.getText() ); | |
| 788 | } catch( final Exception ex ) { | |
| 789 | clue( ex ); | |
| 790 | } | |
| 791 | ||
| 792 | return null; | |
| 793 | } | |
| 794 | }; | |
| 795 | ||
| 796 | // TODO: Each time the editor successfully runs the processor the task is | |
| 797 | // considered successful. Due to the rapid-fire nature of processing | |
| 798 | // (e.g., keyboard navigation, fast typing), it isn't necessary to | |
| 799 | // scroll each time. | |
| 800 | // The algorithm: | |
| 801 | // 1. Peek at the oldest time. | |
| 802 | // 2. If the difference between the oldest time and current time exceeds | |
| 803 | // 250 milliseconds, then invoke the scrolling. | |
| 804 | // 3. Insert the current time into the circular queue. | |
| 805 | task.setOnSucceeded( | |
| 806 | e -> invokeLater( () -> mPreview.scrollTo( CARET_ID ) ) | |
| 807 | ); | |
| 808 | ||
| 809 | // Prevents multiple process requests from executing simultaneously (due | |
| 810 | // to having a restricted queue size). | |
| 811 | sExecutor.execute( task ); | |
| 812 | } | |
| 813 | ||
| 814 | /** | |
| 815 | * Lazily creates a {@link TabPane} configured to listen for tab select | |
| 816 | * events. The tab pane is associated with a given media type so that | |
| 817 | * similar files can be grouped together. | |
| 818 | * | |
| 819 | * @param mediaType The media type to associate with the tab pane. | |
| 820 | * @return An instance of {@link TabPane} that will handle tab docking. | |
| 821 | */ | |
| 822 | private TabPane obtainTabPane( final MediaType mediaType ) { | |
| 823 | for( final var pane : mTabPanes ) { | |
| 824 | for( final var tab : pane.getTabs() ) { | |
| 825 | final var node = tab.getContent(); | |
| 826 | ||
| 827 | if( node instanceof TextResource r && r.supports( mediaType ) ) { | |
| 828 | return pane; | |
| 829 | } | |
| 830 | } | |
| 831 | } | |
| 832 | ||
| 833 | final var pane = createTabPane(); | |
| 834 | mTabPanes.add( pane ); | |
| 835 | return pane; | |
| 836 | } | |
| 837 | ||
| 838 | /** | |
| 839 | * Creates an initialized {@link TabPane} instance. | |
| 840 | * | |
| 841 | * @return A new {@link TabPane} with all listeners configured. | |
| 842 | */ | |
| 843 | private TabPane createTabPane() { | |
| 844 | final var tabPane = new DetachableTabPane(); | |
| 845 | ||
| 846 | initStageOwnerFactory( tabPane ); | |
| 847 | initTabListener( tabPane ); | |
| 848 | ||
| 849 | return tabPane; | |
| 850 | } | |
| 851 | ||
| 852 | /** | |
| 853 | * When any {@link DetachableTabPane} is detached from the main window, | |
| 854 | * the stage owner factory must be given its parent window, which will | |
| 855 | * own the child window. The parent window is the {@link MainPane}'s | |
| 856 | * {@link Scene}'s {@link Window} instance. | |
| 857 | * | |
| 858 | * <p> | |
| 859 | * This will derives the new title from the main window title, incrementing | |
| 860 | * the window count to help uniquely identify the child windows. | |
| 861 | * </p> | |
| 862 | * | |
| 863 | * @param tabPane A new {@link DetachableTabPane} to configure. | |
| 864 | */ | |
| 865 | private void initStageOwnerFactory( final DetachableTabPane tabPane ) { | |
| 866 | tabPane.setStageOwnerFactory( ( stage ) -> { | |
| 867 | final var title = get( | |
| 868 | "Detach.tab.title", | |
| 869 | ((Stage) getWindow()).getTitle(), ++mWindowCount | |
| 870 | ); | |
| 871 | stage.setTitle( title ); | |
| 872 | ||
| 873 | return getScene().getWindow(); | |
| 874 | } ); | |
| 875 | } | |
| 876 | ||
| 877 | /** | |
| 878 | * Responsible for configuring the content of each {@link DetachableTab} when | |
| 879 | * it is added to the given {@link DetachableTabPane} instance. | |
| 880 | * <p> | |
| 881 | * For {@link TextEditor} contents, an instance of {@link ScrollEventHandler} | |
| 882 | * is initialized to perform synchronized scrolling between the editor and | |
| 883 | * its preview window. Additionally, the last tab in the tab pane's list of | |
| 884 | * tabs is given focus. | |
| 885 | * </p> | |
| 886 | * <p> | |
| 887 | * Note that multiple tabs can be added simultaneously. | |
| 888 | * </p> | |
| 889 | * | |
| 890 | * @param tabPane A new {@link TabPane} to configure. | |
| 891 | */ | |
| 892 | private void initTabListener( final TabPane tabPane ) { | |
| 893 | tabPane.getTabs().addListener( | |
| 894 | ( final ListChangeListener.Change<? extends Tab> listener ) -> { | |
| 895 | while( listener.next() ) { | |
| 896 | if( listener.wasAdded() ) { | |
| 897 | final var tabs = listener.getAddedSubList(); | |
| 898 | ||
| 899 | tabs.forEach( tab -> { | |
| 900 | final var node = tab.getContent(); | |
| 901 | ||
| 902 | if( node instanceof TextEditor ) { | |
| 903 | initScrollEventListener( tab ); | |
| 904 | } | |
| 905 | } ); | |
| 906 | ||
| 907 | // Select and give focus to the last tab opened. | |
| 908 | final var index = tabs.size() - 1; | |
| 909 | if( index >= 0 ) { | |
| 910 | final var tab = tabs.get( index ); | |
| 911 | tabPane.getSelectionModel().select( tab ); | |
| 912 | tab.getContent().requestFocus(); | |
| 913 | } | |
| 914 | } | |
| 915 | } | |
| 916 | } | |
| 917 | ); | |
| 918 | } | |
| 919 | ||
| 920 | /** | |
| 921 | * Synchronizes scrollbar positions between the given {@link Tab} that | |
| 922 | * contains an instance of {@link TextEditor} and {@link HtmlPreview} pane. | |
| 923 | * | |
| 924 | * @param tab The container for an instance of {@link TextEditor}. | |
| 925 | */ | |
| 926 | private void initScrollEventListener( final Tab tab ) { | |
| 927 | final var editor = (TextEditor) tab.getContent(); | |
| 928 | final var scrollPane = editor.getScrollPane(); | |
| 929 | final var scrollBar = mPreview.getVerticalScrollBar(); | |
| 930 | final var handler = new ScrollEventHandler( scrollPane, scrollBar ); | |
| 931 | ||
| 932 | handler.enabledProperty().bind( tab.selectedProperty() ); | |
| 933 | } | |
| 934 | ||
| 935 | private void addTabPane( final int index, final TabPane tabPane ) { | |
| 936 | final var items = getItems(); | |
| 937 | ||
| 938 | if( !items.contains( tabPane ) ) { | |
| 939 | items.add( index, tabPane ); | |
| 940 | } | |
| 941 | } | |
| 942 | ||
| 943 | private void addTabPane( final TabPane tabPane ) { | |
| 944 | addTabPane( getItems().size(), tabPane ); | |
| 945 | } | |
| 946 | ||
| 947 | private GenericBuilder<Mutator, ProcessorContext> createProcessorContextBuilder() { | |
| 948 | final var w = getWorkspace(); | |
| 949 | ||
| 950 | return builder() | |
| 951 | .with( Mutator::setDefinitions, this::getDefinitions ) | |
| 952 | .with( Mutator::setLocale, w::getLocale ) | |
| 953 | .with( Mutator::setMetadata, w::getMetadata ) | |
| 954 | .with( Mutator::setThemePath, w::getThemePath ) | |
| 955 | .with( Mutator::setCaret, | |
| 956 | () -> getTextEditor().getCaret() ) | |
| 957 | .with( Mutator::setImageDir, | |
| 958 | () -> w.getFile( KEY_IMAGES_DIR ) ) | |
| 959 | .with( Mutator::setImageOrder, | |
| 960 | () -> w.getString( KEY_IMAGES_ORDER ) ) | |
| 961 | .with( Mutator::setImageServer, | |
| 962 | () -> w.getString( KEY_IMAGES_SERVER ) ) | |
| 963 | .with( Mutator::setSigilBegan, | |
| 964 | () -> w.getString( KEY_DEF_DELIM_BEGAN ) ) | |
| 965 | .with( Mutator::setSigilEnded, | |
| 966 | () -> w.getString( KEY_DEF_DELIM_ENDED ) ) | |
| 967 | .with( Mutator::setRScript, | |
| 968 | () -> w.getString( KEY_R_SCRIPT ) ) | |
| 969 | .with( Mutator::setRWorkingDir, | |
| 970 | () -> w.getFile( KEY_R_DIR ).toPath() ) | |
| 971 | .with( Mutator::setCurlQuotes, | |
| 972 | () -> w.getBoolean( KEY_TYPESET_TYPOGRAPHY_QUOTES ) ) | |
| 973 | .with( Mutator::setAutoClean, | |
| 974 | () -> w.getBoolean( KEY_TYPESET_CONTEXT_CLEAN ) ); | |
| 975 | } | |
| 976 | ||
| 977 | public ProcessorContext createProcessorContext() { | |
| 978 | return createProcessorContext( null, NONE ); | |
| 979 | } | |
| 980 | ||
| 981 | /** | |
| 982 | * @param outputPath Used when exporting to a PDF file (binary). | |
| 983 | * @param format Used when processors export to a new text format. | |
| 984 | * @return A new {@link ProcessorContext} to use when creating an instance of | |
| 985 | * {@link Processor}. | |
| 986 | */ | |
| 987 | public ProcessorContext createProcessorContext( | |
| 988 | final Path outputPath, final ExportFormat format ) { | |
| 989 | final var textEditor = getTextEditor(); | |
| 990 | final var inputPath = textEditor.getPath(); | |
| 991 | ||
| 992 | return createProcessorContextBuilder() | |
| 993 | .with( Mutator::setInputPath, inputPath ) | |
| 994 | .with( Mutator::setOutputPath, outputPath ) | |
| 995 | .with( Mutator::setExportFormat, format ) | |
| 996 | .build(); | |
| 997 | } | |
| 998 | ||
| 999 | /** | |
| 1000 | * @param inputPath Used by {@link ProcessorFactory} to determine | |
| 1001 | * {@link Processor} type to create based on file type. | |
| 1002 | * @return A new {@link ProcessorContext} to use when creating an instance of | |
| 1003 | * {@link Processor}. | |
| 1004 | */ | |
| 1005 | private ProcessorContext createProcessorContext( final Path inputPath ) { | |
| 1006 | return createProcessorContextBuilder() | |
| 1007 | .with( Mutator::setInputPath, inputPath ) | |
| 1008 | .with( Mutator::setExportFormat, NONE ) | |
| 1009 | .build(); | |
| 1010 | } | |
| 1011 | ||
| 1012 | private TextResource createTextResource( final File file ) { | |
| 1013 | // TODO: Create PlainTextEditor that's returned by default. | |
| 1014 | return MediaType.valueFrom( file ) == TEXT_YAML | |
| 1015 | ? createDefinitionEditor( file ) | |
| 1016 | : createMarkdownEditor( file ); | |
| 1017 | } | |
| 1018 | ||
| 1019 | /** | |
| 1020 | * Creates an instance of {@link MarkdownEditor} that listens for both | |
| 1021 | * caret change events and text change events. Text change events must | |
| 1022 | * take priority over caret change events because it's possible to change | |
| 1023 | * the text without moving the caret (e.g., delete selected text). | |
| 1024 | * | |
| 1025 | * @param inputFile The file containing contents for the text editor. | |
| 1026 | * @return A non-null text editor. | |
| 1027 | */ | |
| 1028 | private TextResource createMarkdownEditor( final File inputFile ) { | |
| 1029 | final var editor = new MarkdownEditor( inputFile, getWorkspace() ); | |
| 1030 | ||
| 1031 | mProcessors.computeIfAbsent( | |
| 1032 | editor, p -> createProcessors( | |
| 1033 | createProcessorContext( inputFile.toPath() ), | |
| 1034 | createHtmlPreviewProcessor() | |
| 1035 | ) | |
| 1036 | ); | |
| 1037 | ||
| 1038 | // Listener for editor modifications or caret position changes. | |
| 1039 | editor.addDirtyListener( ( c, o, n ) -> { | |
| 1040 | if( n ) { | |
| 1041 | // Reset the status bar after changing the text. | |
| 1042 | clue(); | |
| 1043 | ||
| 1044 | // Processing the text may update the status bar. | |
| 1045 | process( getTextEditor() ); | |
| 1046 | ||
| 1047 | // Update the caret position in the status bar. | |
| 1048 | CaretMovedEvent.fire( editor.getCaret() ); | |
| 1020 | 1049 | } |
| 1021 | 1050 | } ); |
| 6 | 6 | import com.keenwrite.preferences.Workspace; |
| 7 | 7 | import com.keenwrite.ui.actions.GuiCommands; |
| 8 | import com.keenwrite.ui.listeners.CaretListener; | |
| 8 | import com.keenwrite.ui.listeners.CaretStatus; | |
| 9 | 9 | import javafx.scene.Node; |
| 10 | 10 | import javafx.scene.Parent; |
| ... | ||
| 21 | 21 | import static com.keenwrite.events.ScrollLockEvent.fireScrollLockEvent; |
| 22 | 22 | import static com.keenwrite.events.StatusEvent.clue; |
| 23 | import static com.keenwrite.preferences.SkinProperty.toFilename; | |
| 24 | 23 | import static com.keenwrite.preferences.AppKeys.KEY_UI_SKIN_CUSTOM; |
| 25 | 24 | import static com.keenwrite.preferences.AppKeys.KEY_UI_SKIN_SELECTION; |
| 25 | import static com.keenwrite.preferences.SkinProperty.toFilename; | |
| 26 | 26 | import static com.keenwrite.ui.actions.ApplicationBars.*; |
| 27 | 27 | import static javafx.application.Platform.runLater; |
| ... | ||
| 45 | 45 | final var mainPane = createMainPane( workspace ); |
| 46 | 46 | final var actions = createApplicationActions( mainPane ); |
| 47 | final var caretListener = createCaretListener( mainPane ); | |
| 47 | final var caretStatus = createCaretStatus(); | |
| 48 | ||
| 48 | 49 | mMenuBar = setManagedLayout( createMenuBar( actions ) ); |
| 49 | 50 | mToolBar = setManagedLayout( createToolBar() ); |
| 50 | 51 | mStatusBar = setManagedLayout( createStatusBar() ); |
| 51 | 52 | |
| 52 | mStatusBar.getRightItems().add( caretListener ); | |
| 53 | mStatusBar.getRightItems().add( caretStatus ); | |
| 53 | 54 | |
| 54 | 55 | final var appPane = new BorderPane(); |
| ... | ||
| 94 | 95 | } |
| 95 | 96 | |
| 96 | public StatusBar getStatusBar() { return mStatusBar; } | |
| 97 | public StatusBar getStatusBar() {return mStatusBar;} | |
| 97 | 98 | |
| 98 | 99 | private void initStylesheets( final Scene scene, final Workspace workspace ) { |
| ... | ||
| 176 | 177 | * based on the active text editor. |
| 177 | 178 | * |
| 178 | * @return The {@link CaretListener} responsible for updating the | |
| 179 | * @return The {@link CaretStatus} responsible for updating the | |
| 179 | 180 | * {@link StatusBar} whenever the caret changes position. |
| 180 | 181 | */ |
| 181 | private CaretListener createCaretListener( final MainPane mainPane ) { | |
| 182 | return new CaretListener( mainPane.textEditorProperty() ); | |
| 182 | private CaretStatus createCaretStatus() { | |
| 183 | return new CaretStatus(); | |
| 183 | 184 | } |
| 184 | 185 | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite; | |
| 3 | ||
| 4 | import javax.net.ssl.*; | |
| 5 | import java.security.SecureRandom; | |
| 6 | import java.security.cert.X509Certificate; | |
| 7 | ||
| 8 | import static javax.net.ssl.HttpsURLConnection.setDefaultHostnameVerifier; | |
| 9 | import static javax.net.ssl.HttpsURLConnection.setDefaultSSLSocketFactory; | |
| 10 | ||
| 11 | /** | |
| 12 | * Responsible for trusting all certificate chains. The purpose of this class | |
| 13 | * is to work-around certificate issues caused by software that blocks | |
| 14 | * HTTP requests. For example, zscaler may block HTTP requests to kroki.io | |
| 15 | * when generating diagrams. | |
| 16 | */ | |
| 17 | public final class PermissiveCertificate { | |
| 18 | /** | |
| 19 | * Create a trust manager that does not validate certificate chains. | |
| 20 | */ | |
| 21 | private final static TrustManager[] TRUST_ALL_CERTS = new TrustManager[]{ | |
| 22 | new X509TrustManager() { | |
| 23 | @Override | |
| 24 | public X509Certificate[] getAcceptedIssuers() { | |
| 25 | return new X509Certificate[ 0 ]; | |
| 26 | } | |
| 27 | ||
| 28 | @Override | |
| 29 | public void checkClientTrusted( | |
| 30 | X509Certificate[] certs, String authType ) { | |
| 31 | } | |
| 32 | ||
| 33 | @Override | |
| 34 | public void checkServerTrusted( | |
| 35 | X509Certificate[] certs, String authType ) { | |
| 36 | } | |
| 37 | } | |
| 38 | }; | |
| 39 | ||
| 40 | /** | |
| 41 | * Responsible for permitting all hostnames for making HTTP requests. | |
| 42 | */ | |
| 43 | private static class PermissiveHostNameVerifier implements HostnameVerifier { | |
| 44 | @Override | |
| 45 | public boolean verify( final String hostname, final SSLSession session ) { | |
| 46 | return true; | |
| 47 | } | |
| 48 | } | |
| 49 | ||
| 50 | /** | |
| 51 | * Install the all-trusting trust manager. If this fails it means that in | |
| 52 | * certain situations the HTML preview may fail to render diagrams. A way | |
| 53 | * to work around the issue is to install a local server for generating | |
| 54 | * diagrams. | |
| 55 | */ | |
| 56 | public static boolean installTrustManager() { | |
| 57 | try { | |
| 58 | final var context = SSLContext.getInstance( "SSL" ); | |
| 59 | context.init( null, TRUST_ALL_CERTS, new SecureRandom() ); | |
| 60 | setDefaultSSLSocketFactory( context.getSocketFactory() ); | |
| 61 | setDefaultHostnameVerifier( new PermissiveHostNameVerifier() ); | |
| 62 | return true; | |
| 63 | } catch( final Exception ex ) { | |
| 64 | return false; | |
| 65 | } | |
| 66 | } | |
| 67 | ||
| 68 | /** | |
| 69 | * Use {@link #installTrustManager()}. | |
| 70 | */ | |
| 71 | private PermissiveCertificate() { | |
| 72 | } | |
| 73 | } | |
| 74 | 1 |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite; | |
| 3 | ||
| 4 | import com.keenwrite.events.ScrollLockEvent; | |
| 5 | import javafx.beans.property.BooleanProperty; | |
| 6 | import javafx.beans.property.SimpleBooleanProperty; | |
| 7 | import javafx.event.Event; | |
| 8 | import javafx.event.EventHandler; | |
| 9 | import javafx.scene.control.ScrollBar; | |
| 10 | import javafx.scene.control.skin.ScrollBarSkin; | |
| 11 | import javafx.scene.input.MouseEvent; | |
| 12 | import javafx.scene.input.ScrollEvent; | |
| 13 | import javafx.scene.layout.StackPane; | |
| 14 | import org.fxmisc.flowless.VirtualizedScrollPane; | |
| 15 | import org.fxmisc.richtext.StyleClassedTextArea; | |
| 16 | import org.greenrobot.eventbus.Subscribe; | |
| 17 | ||
| 18 | import javax.swing.*; | |
| 19 | import java.util.function.Consumer; | |
| 20 | ||
| 21 | import static com.keenwrite.events.Bus.register; | |
| 22 | import static java.lang.Math.max; | |
| 23 | import static java.lang.Math.min; | |
| 24 | import static javafx.geometry.Orientation.VERTICAL; | |
| 25 | import static javax.swing.SwingUtilities.invokeLater; | |
| 26 | ||
| 27 | /** | |
| 28 | * Converts scroll events from {@link VirtualizedScrollPane} scroll bars to | |
| 29 | * an instance of {@link JScrollBar}. | |
| 30 | * <p> | |
| 31 | * Called to synchronize the scrolling areas for either scrolling with the | |
| 32 | * mouse or scrolling using the scrollbar's thumb. Both are required to avoid | |
| 33 | * scrolling on the estimatedScrollYProperty that occurs when text events | |
| 34 | * fire. Scrolling performed for text events are handled separately to ensure | |
| 35 | * the preview panel scrolls to the same position in the Markdown editor, | |
| 36 | * taking into account things like images, tables, and other potentially | |
| 37 | * long vertical presentation items. | |
| 38 | * </p> | |
| 39 | */ | |
| 40 | public final class ScrollEventHandler implements EventHandler<Event> { | |
| 41 | ||
| 42 | private final class MouseHandler implements EventHandler<MouseEvent> { | |
| 43 | private final EventHandler<? super MouseEvent> mOldHandler; | |
| 44 | ||
| 45 | /** | |
| 46 | * Constructs a new handler for mouse scrolling events. | |
| 47 | * | |
| 48 | * @param oldHandler Receives the event after scrolling takes place. | |
| 49 | */ | |
| 50 | private MouseHandler( final EventHandler<? super MouseEvent> oldHandler ) { | |
| 51 | mOldHandler = oldHandler; | |
| 52 | } | |
| 53 | ||
| 54 | @Override | |
| 55 | public void handle( final MouseEvent event ) { | |
| 56 | ScrollEventHandler.this.handle( event ); | |
| 57 | mOldHandler.handle( event ); | |
| 58 | } | |
| 59 | } | |
| 60 | ||
| 61 | private final class ScrollHandler implements EventHandler<ScrollEvent> { | |
| 62 | @Override | |
| 63 | public void handle( final ScrollEvent event ) { | |
| 64 | ScrollEventHandler.this.handle( event ); | |
| 65 | } | |
| 66 | } | |
| 67 | ||
| 68 | private final VirtualizedScrollPane<StyleClassedTextArea> mEditorScrollPane; | |
| 69 | private final JScrollBar mPreviewScrollBar; | |
| 70 | private final BooleanProperty mEnabled = new SimpleBooleanProperty(); | |
| 71 | ||
| 72 | private boolean mLocked; | |
| 73 | ||
| 74 | /** | |
| 75 | * @param editorScrollPane Scroll event source (human movement). | |
| 76 | * @param previewScrollBar Scroll event destination (corresponding movement). | |
| 77 | */ | |
| 78 | public ScrollEventHandler( | |
| 79 | final VirtualizedScrollPane<StyleClassedTextArea> editorScrollPane, | |
| 80 | final JScrollBar previewScrollBar ) { | |
| 81 | mEditorScrollPane = editorScrollPane; | |
| 82 | mPreviewScrollBar = previewScrollBar; | |
| 83 | ||
| 84 | mEditorScrollPane.addEventFilter( ScrollEvent.ANY, new ScrollHandler() ); | |
| 85 | ||
| 86 | initVerticalScrollBarThumb( | |
| 87 | mEditorScrollPane, | |
| 88 | thumb -> { | |
| 89 | final var handler = new MouseHandler( thumb.getOnMouseDragged() ); | |
| 90 | thumb.setOnMouseDragged( handler ); | |
| 91 | } | |
| 92 | ); | |
| 93 | ||
| 94 | register( this ); | |
| 95 | } | |
| 96 | ||
| 97 | /** | |
| 98 | * Gets a property intended to be bound to selected property of the tab being | |
| 99 | * scrolled. This is required because there's only one preview pane but | |
| 100 | * multiple editor panes. Each editor pane maintains its own scroll position. | |
| 101 | * | |
| 102 | * @return A {@link BooleanProperty} representing whether the scroll | |
| 103 | * events for this tab are to be executed. | |
| 104 | */ | |
| 105 | public BooleanProperty enabledProperty() { | |
| 106 | return mEnabled; | |
| 107 | } | |
| 108 | ||
| 109 | /** | |
| 110 | * Scrolls the preview scrollbar relative to the edit scrollbar. Algorithm | |
| 111 | * is based on Karl Tauber's ratio calculation. | |
| 112 | * | |
| 113 | * @param event Unused; either {@link MouseEvent} or {@link ScrollEvent} | |
| 114 | */ | |
| 115 | @Override | |
| 116 | public void handle( final Event event ) { | |
| 117 | invokeLater( () -> { | |
| 118 | if( isEnabled() ) { | |
| 119 | // e is for editor pane | |
| 120 | final var eScrollPane = getEditorScrollPane(); | |
| 121 | final var eScrollY = | |
| 122 | eScrollPane.estimatedScrollYProperty().getValue().intValue(); | |
| 123 | final var eHeight = (int) | |
| 124 | (eScrollPane.totalHeightEstimateProperty().getValue().intValue() | |
| 125 | - eScrollPane.getHeight()); | |
| 126 | final var eRatio = eHeight > 0 | |
| 127 | ? min( max( eScrollY / (float) eHeight, 0 ), 1 ) : 0; | |
| 128 | ||
| 129 | // p is for preview pane | |
| 130 | final var pScrollBar = getPreviewScrollBar(); | |
| 131 | final var pHeight = pScrollBar.getMaximum() - pScrollBar.getHeight(); | |
| 132 | final var pScrollY = (int) (pHeight * eRatio); | |
| 133 | ||
| 134 | pScrollBar.setValue( pScrollY ); | |
| 135 | pScrollBar.getParent().repaint(); | |
| 136 | } | |
| 137 | } ); | |
| 138 | } | |
| 139 | ||
| 140 | @Subscribe | |
| 141 | public void handle( final ScrollLockEvent event ) { | |
| 142 | mLocked = event.isLocked(); | |
| 143 | } | |
| 144 | ||
| 145 | private void initVerticalScrollBarThumb( | |
| 146 | final VirtualizedScrollPane<StyleClassedTextArea> pane, | |
| 147 | final Consumer<StackPane> consumer ) { | |
| 148 | // When the skin property is set, the stack pane is available (not null). | |
| 149 | getVerticalScrollBar( pane ).skinProperty().addListener( ( c, o, n ) -> { | |
| 150 | for( final var node : ((ScrollBarSkin) n).getChildren() ) { | |
| 151 | // Brittle, but what can you do? | |
| 152 | if( node.getStyleClass().contains( "thumb" ) ) { | |
| 153 | consumer.accept( (StackPane) node ); | |
| 154 | } | |
| 155 | } | |
| 156 | } ); | |
| 157 | } | |
| 158 | ||
| 159 | /** | |
| 160 | * Returns the vertical {@link ScrollBar} instance associated with the | |
| 161 | * given scroll pane. This is {@code null}-safe because the scroll pane | |
| 162 | * initializes its vertical {@link ScrollBar} upon construction. | |
| 163 | * | |
| 164 | * @param pane The scroll pane that contains a vertical {@link ScrollBar}. | |
| 165 | * @return The vertical {@link ScrollBar} associated with the scroll pane. | |
| 166 | * @throws IllegalStateException Could not obtain the vertical scroll bar. | |
| 167 | */ | |
| 168 | private ScrollBar getVerticalScrollBar( | |
| 169 | final VirtualizedScrollPane<StyleClassedTextArea> pane ) { | |
| 170 | ||
| 171 | for( final var node : pane.getChildrenUnmodifiable() ) { | |
| 172 | if( node instanceof final ScrollBar scrollBar && | |
| 173 | scrollBar.getOrientation() == VERTICAL ) { | |
| 174 | return scrollBar; | |
| 175 | } | |
| 176 | } | |
| 177 | ||
| 178 | throw new IllegalStateException( "No vertical scroll bar found." ); | |
| 179 | } | |
| 180 | ||
| 181 | private boolean isEnabled() { | |
| 182 | // TODO: As a minor optimization, when this is set to false, it could remove | |
| 183 | // the MouseHandler and ScrollHandler so that events only dispatch to one | |
| 184 | // object (instead of one per editor tab). | |
| 185 | return mEnabled.get() && !mLocked; | |
| 186 | } | |
| 187 | ||
| 188 | private VirtualizedScrollPane<StyleClassedTextArea> getEditorScrollPane() { | |
| 189 | return mEditorScrollPane; | |
| 190 | } | |
| 191 | ||
| 192 | private JScrollBar getPreviewScrollBar() { | |
| 193 | return mPreviewScrollBar; | |
| 194 | } | |
| 195 | } | |
| 196 | 1 |
| 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 | } | |
| 85 | 1 |
| 1 | 1 | package com.keenwrite.cmdline; |
| 2 | 2 | |
| 3 | import com.fasterxml.jackson.databind.JsonNode; | |
| 4 | import com.fasterxml.jackson.databind.ObjectMapper; | |
| 5 | import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; | |
| 3 | 6 | import com.keenwrite.ExportFormat; |
| 4 | import com.keenwrite.preferences.Key; | |
| 5 | import com.keenwrite.preferences.KeyConfiguration; | |
| 6 | 7 | import com.keenwrite.processors.ProcessorContext; |
| 7 | 8 | import com.keenwrite.processors.ProcessorContext.Mutator; |
| 8 | 9 | import picocli.CommandLine; |
| 9 | 10 | |
| 10 | 11 | import java.io.File; |
| 12 | import java.io.IOException; | |
| 13 | import java.nio.file.Files; | |
| 11 | 14 | import java.nio.file.Path; |
| 12 | 15 | import java.util.HashMap; |
| 16 | import java.util.Locale; | |
| 13 | 17 | import java.util.Map; |
| 14 | import java.util.Set; | |
| 18 | import java.util.Map.Entry; | |
| 15 | 19 | import java.util.concurrent.Callable; |
| 16 | 20 | import java.util.function.Consumer; |
| 17 | 21 | |
| 18 | import static com.keenwrite.preferences.AppKeys.*; | |
| 22 | import static com.keenwrite.constants.Constants.DIAGRAM_SERVER_NAME; | |
| 19 | 23 | |
| 20 | 24 | /** |
| 21 | 25 | * 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. | |
| 26 | * the application. | |
| 25 | 27 | */ |
| 26 | 28 | @CommandLine.Command( |
| 27 | 29 | name = "KeenWrite", |
| 28 | 30 | mixinStandardHelpOptions = true, |
| 29 | description = "Plain text editor for editing with variables." | |
| 31 | description = "Plain text editor for editing with variables" | |
| 30 | 32 | ) |
| 31 | 33 | @SuppressWarnings( "unused" ) |
| 32 | public final class Arguments implements Callable<Integer>, KeyConfiguration { | |
| 34 | public final class Arguments implements Callable<Integer> { | |
| 33 | 35 | @CommandLine.Option( |
| 34 | names = {"-a", "--all"}, | |
| 36 | names = {"--all"}, | |
| 35 | 37 | description = |
| 36 | "Concatenate files in directory before processing (${DEFAULT-VALUE}).", | |
| 38 | "Concatenate files before processing (${DEFAULT-VALUE})", | |
| 37 | 39 | defaultValue = "false" |
| 38 | 40 | ) |
| 39 | private boolean mAll; | |
| 41 | private boolean mConcatenate; | |
| 40 | 42 | |
| 41 | 43 | @CommandLine.Option( |
| 42 | names = {"-k", "--keep-files"}, | |
| 44 | names = {"--keep-files"}, | |
| 43 | 45 | description = |
| 44 | "Keep temporary build files (${DEFAULT-VALUE}).", | |
| 46 | "Retain temporary build files (${DEFAULT-VALUE})", | |
| 45 | 47 | defaultValue = "false" |
| 46 | 48 | ) |
| 47 | 49 | private boolean mKeepFiles; |
| 50 | ||
| 51 | @CommandLine.Option( | |
| 52 | names = {"--curl-quotes"}, | |
| 53 | description = | |
| 54 | "Replace straight quotes with curly quotes (${DEFAULT-VALUE})", | |
| 55 | defaultValue = "true" | |
| 56 | ) | |
| 57 | private Boolean mCurlQuotes; | |
| 48 | 58 | |
| 49 | 59 | @CommandLine.Option( |
| 50 | 60 | names = {"-d", "--debug"}, |
| 51 | 61 | description = |
| 52 | "Enable logging to the console (${DEFAULT-VALUE}).", | |
| 62 | "Enable logging to the console (${DEFAULT-VALUE})", | |
| 63 | paramLabel = "Boolean", | |
| 53 | 64 | defaultValue = "false" |
| 54 | 65 | ) |
| 55 | 66 | private boolean mDebug; |
| 56 | 67 | |
| 57 | 68 | @CommandLine.Option( |
| 58 | 69 | names = {"-i", "--input"}, |
| 59 | 70 | description = |
| 60 | "Set the file name to read.", | |
| 71 | "Source document file path", | |
| 61 | 72 | paramLabel = "PATH", |
| 62 | 73 | defaultValue = "stdin", |
| 63 | 74 | required = true |
| 64 | 75 | ) |
| 65 | 76 | private Path mPathInput; |
| 66 | 77 | |
| 67 | 78 | @CommandLine.Option( |
| 68 | names = {"-f", "--format-type"}, | |
| 79 | names = {"--format-type"}, | |
| 69 | 80 | description = |
| 70 | 81 | "Export type: html, md, pdf, xml (${DEFAULT-VALUE})", |
| 71 | 82 | paramLabel = "String", |
| 72 | 83 | defaultValue = "pdf", |
| 73 | 84 | required = true |
| 74 | 85 | ) |
| 75 | 86 | private String mFormatType; |
| 87 | ||
| 88 | @CommandLine.Option( | |
| 89 | names = {"--format-subtype-tex"}, | |
| 90 | description = | |
| 91 | "Export subtype for HTML formats: svg, delimited", | |
| 92 | defaultValue = "", | |
| 93 | paramLabel = "String" | |
| 94 | ) | |
| 95 | private String mFormatSubtype; | |
| 96 | ||
| 97 | @CommandLine.Option( | |
| 98 | names = {"--image-dir"}, | |
| 99 | description = | |
| 100 | "Directory containing images", | |
| 101 | paramLabel = "DIR" | |
| 102 | ) | |
| 103 | private File mImageDir; | |
| 104 | ||
| 105 | @CommandLine.Option( | |
| 106 | names = {"--image-order"}, | |
| 107 | description = | |
| 108 | "Comma-separated image order (${DEFAULT-VALUE})", | |
| 109 | paramLabel = "String", | |
| 110 | defaultValue = "svg,pdf,png,jpg,tiff" | |
| 111 | ) | |
| 112 | private String mImageOrder; | |
| 113 | ||
| 114 | @CommandLine.Option( | |
| 115 | names = {"--image-server"}, | |
| 116 | description = | |
| 117 | "SVG diagram rendering service (${DEFAULT-VALUE})", | |
| 118 | paramLabel = "String", | |
| 119 | defaultValue = DIAGRAM_SERVER_NAME | |
| 120 | ) | |
| 121 | private String mImageServer; | |
| 122 | ||
| 123 | @CommandLine.Option( | |
| 124 | names = {"--locale"}, | |
| 125 | description = | |
| 126 | "Set localization (${DEFAULT-VALUE})", | |
| 127 | paramLabel = "String", | |
| 128 | defaultValue = "en" | |
| 129 | ) | |
| 130 | private String mLocale; | |
| 76 | 131 | |
| 77 | 132 | @CommandLine.Option( |
| 78 | 133 | names = {"-m", "--metadata"}, |
| 79 | 134 | description = |
| 80 | "Map metadata keys to values, variable names allowed.", | |
| 135 | "Map metadata keys to values, variable names allowed", | |
| 81 | 136 | paramLabel = "key=value" |
| 82 | 137 | ) |
| 83 | 138 | private Map<String, String> mMetadata; |
| 84 | 139 | |
| 85 | 140 | @CommandLine.Option( |
| 86 | 141 | names = {"-o", "--output"}, |
| 87 | 142 | description = |
| 88 | "Set the file name to write.", | |
| 143 | "Destination document file path", | |
| 89 | 144 | paramLabel = "PATH", |
| 90 | 145 | defaultValue = "stdout", |
| 91 | 146 | required = true |
| 92 | ) | |
| 93 | private File mPathOutput; | |
| 94 | ||
| 95 | @CommandLine.Option( | |
| 96 | names = {"-p", "--images-path"}, | |
| 97 | description = | |
| 98 | "Absolute path to images directory", | |
| 99 | paramLabel = "PATH" | |
| 100 | 147 | ) |
| 101 | private Path mPathImages; | |
| 148 | private Path mPathOutput; | |
| 102 | 149 | |
| 103 | 150 | @CommandLine.Option( |
| 104 | 151 | names = {"-q", "--quiet"}, |
| 105 | 152 | description = |
| 106 | "Suppress all status messages (${DEFAULT-VALUE}).", | |
| 153 | "Suppress all status messages (${DEFAULT-VALUE})", | |
| 107 | 154 | defaultValue = "false" |
| 108 | 155 | ) |
| 109 | 156 | private boolean mQuiet; |
| 110 | 157 | |
| 111 | 158 | @CommandLine.Option( |
| 112 | names = {"-s", "--format-subtype-tex"}, | |
| 159 | names = {"--r-dir"}, | |
| 113 | 160 | description = |
| 114 | "Export subtype for HTML formats: svg, delimited", | |
| 115 | paramLabel = "String" | |
| 161 | "R working directory", | |
| 162 | paramLabel = "DIR" | |
| 116 | 163 | ) |
| 117 | private String mFormatSubtype; | |
| 164 | private Path mRWorkingDir; | |
| 118 | 165 | |
| 119 | 166 | @CommandLine.Option( |
| 120 | names = {"-t", "--theme"}, | |
| 167 | names = {"--r-script"}, | |
| 121 | 168 | description = |
| 122 | "Full theme name file path to use when exporting as a PDF file.", | |
| 169 | "R bootstrap script file path", | |
| 123 | 170 | paramLabel = "PATH" |
| 124 | 171 | ) |
| 125 | private Path mThemeName; | |
| 172 | private Path mRScriptPath; | |
| 126 | 173 | |
| 127 | 174 | @CommandLine.Option( |
| 128 | names = {"-x", "--image-extensions"}, | |
| 175 | names = {"--sigil-opening"}, | |
| 129 | 176 | description = |
| 130 | "Space-separated image file name extensions (${DEFAULT-VALUE}).", | |
| 177 | "Starting sigil for variable names (${DEFAULT-VALUE})", | |
| 131 | 178 | paramLabel = "String", |
| 132 | defaultValue = "svg pdf png jpg tiff" | |
| 179 | defaultValue = "{{" | |
| 133 | 180 | ) |
| 134 | private Set<String> mImageExtensions; | |
| 181 | private String mSigilBegan; | |
| 182 | ||
| 183 | @CommandLine.Option( | |
| 184 | names = {"--sigil-closing"}, | |
| 185 | description = | |
| 186 | "Ending sigil for variable names (${DEFAULT-VALUE})", | |
| 187 | paramLabel = "String", | |
| 188 | defaultValue = "}}" | |
| 189 | ) | |
| 190 | private String mSigilEnded; | |
| 191 | ||
| 192 | @CommandLine.Option( | |
| 193 | names = {"--theme-dir"}, | |
| 194 | description = | |
| 195 | "Theme directory", | |
| 196 | paramLabel = "DIR" | |
| 197 | ) | |
| 198 | private Path mDirTheme; | |
| 135 | 199 | |
| 136 | 200 | @CommandLine.Option( |
| 137 | 201 | names = {"-v", "--variables"}, |
| 138 | 202 | description = |
| 139 | "Set the file name containing variable definitions (${DEFAULT-VALUE}).", | |
| 140 | paramLabel = "FILE", | |
| 141 | defaultValue = "variables.yaml" | |
| 203 | "Variables file path", | |
| 204 | paramLabel = "PATH" | |
| 142 | 205 | ) |
| 143 | 206 | private Path mPathVariables; |
| 144 | 207 | |
| 145 | 208 | private final Consumer<Arguments> mLauncher; |
| 146 | ||
| 147 | private final Map<Key, Object> mValues = new HashMap<>(); | |
| 148 | 209 | |
| 149 | 210 | public Arguments( final Consumer<Arguments> launcher ) { |
| 150 | 211 | mLauncher = launcher; |
| 151 | 212 | } |
| 152 | ||
| 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 | 213 | |
| 214 | public ProcessorContext createProcessorContext() | |
| 215 | throws IOException { | |
| 216 | final var definitions = parse( mPathVariables ); | |
| 162 | 217 | final var format = ExportFormat.valueFrom( mFormatType, mFormatSubtype ); |
| 218 | final var locale = lookupLocale( mLocale ); | |
| 219 | final var rScript = read( mRScriptPath ); | |
| 163 | 220 | |
| 164 | 221 | return ProcessorContext |
| 165 | 222 | .builder() |
| 166 | 223 | .with( Mutator::setInputPath, mPathInput ) |
| 167 | 224 | .with( Mutator::setOutputPath, mPathOutput ) |
| 168 | 225 | .with( Mutator::setExportFormat, format ) |
| 226 | .with( Mutator::setDefinitions, () -> definitions ) | |
| 227 | .with( Mutator::setMetadata, () -> mMetadata ) | |
| 228 | .with( Mutator::setLocale, () -> locale ) | |
| 229 | .with( Mutator::setThemePath, () -> mDirTheme ) | |
| 230 | .with( Mutator::setConcatenate, mConcatenate ) | |
| 231 | .with( Mutator::setImageDir, () -> mImageDir ) | |
| 232 | .with( Mutator::setImageServer, () -> mImageServer ) | |
| 233 | .with( Mutator::setImageOrder, () -> mImageOrder ) | |
| 234 | .with( Mutator::setSigilBegan, () -> mSigilBegan ) | |
| 235 | .with( Mutator::setSigilEnded, () -> mSigilEnded ) | |
| 236 | .with( Mutator::setRWorkingDir, () -> mRWorkingDir ) | |
| 237 | .with( Mutator::setRScript, () -> rScript ) | |
| 238 | .with( Mutator::setCurlQuotes, () -> mCurlQuotes ) | |
| 239 | .with( Mutator::setAutoClean, () -> !mKeepFiles ) | |
| 169 | 240 | .build(); |
| 170 | 241 | } |
| ... | ||
| 191 | 262 | } |
| 192 | 263 | |
| 193 | @Override | |
| 194 | public String getString( final Key key ) { | |
| 195 | return null; | |
| 264 | private static String read( final Path path ) throws IOException { | |
| 265 | return Files.readString( path ); | |
| 196 | 266 | } |
| 197 | 267 | |
| 198 | @Override | |
| 199 | public boolean getBoolean( final Key key ) { | |
| 200 | return false; | |
| 268 | /** | |
| 269 | * Parses the given YAML document into a map of key-value pairs. | |
| 270 | * | |
| 271 | * @param vars Variable definition file to read, may be {@code null} if no | |
| 272 | * variables are specified. | |
| 273 | * @return A non-interpolated variable map, or an empty map. | |
| 274 | * @throws IOException Could not read the variable definition file | |
| 275 | */ | |
| 276 | private static Map<String, String> parse( final Path vars ) | |
| 277 | throws IOException { | |
| 278 | final var map = new HashMap<String, String>(); | |
| 279 | ||
| 280 | if( vars != null ) { | |
| 281 | final var yaml = read( vars ); | |
| 282 | final var factory = new YAMLFactory(); | |
| 283 | final var json = new ObjectMapper( factory ).readTree( yaml ); | |
| 284 | ||
| 285 | parse( json, "", map ); | |
| 286 | } | |
| 287 | ||
| 288 | return map; | |
| 201 | 289 | } |
| 202 | 290 | |
| 203 | @Override | |
| 204 | public int getInteger( final Key key ) { | |
| 205 | return 0; | |
| 291 | private static void parse( | |
| 292 | final JsonNode json, final String parent, final Map<String, String> map ) { | |
| 293 | assert json != null; | |
| 294 | assert parent != null; | |
| 295 | assert map != null; | |
| 296 | ||
| 297 | json.fields().forEachRemaining( node -> parse( node, parent, map ) ); | |
| 206 | 298 | } |
| 207 | 299 | |
| 208 | @Override | |
| 209 | public double getDouble( final Key key ) { | |
| 210 | return 0; | |
| 300 | private static void parse( | |
| 301 | final Entry<String, JsonNode> node, | |
| 302 | final String parent, | |
| 303 | final Map<String, String> map ) { | |
| 304 | assert node != null; | |
| 305 | assert parent != null; | |
| 306 | assert map != null; | |
| 307 | ||
| 308 | final var jsonNode = node.getValue(); | |
| 309 | final var keyName = parent + "." + node.getKey(); | |
| 310 | ||
| 311 | if( jsonNode.isValueNode() ) { | |
| 312 | // Trim the leading period, which is always present. | |
| 313 | map.put( keyName.substring( 1 ), node.getValue().asText() ); | |
| 314 | } | |
| 315 | else if( jsonNode.isObject() ) { | |
| 316 | parse( jsonNode, keyName, map ); | |
| 317 | } | |
| 211 | 318 | } |
| 212 | 319 | |
| 213 | @Override | |
| 214 | public File getFile( final Key key ) { | |
| 215 | return null; | |
| 320 | private static Locale lookupLocale( final String locale ) { | |
| 321 | try { | |
| 322 | return Locale.forLanguageTag( locale ); | |
| 323 | } catch( final Exception ex ) { | |
| 324 | return Locale.ENGLISH; | |
| 325 | } | |
| 216 | 326 | } |
| 217 | 327 | } |
| 52 | 52 | * @return {@code this} |
| 53 | 53 | */ |
| 54 | public Map<String, String> interpolate() { | |
| 54 | public InterpolatingMap interpolate() { | |
| 55 | 55 | for( final var k : keySet() ) { |
| 56 | 56 | replace( k, interpolate( get( k ) ) ); |
| 2 | 2 | package com.keenwrite.editors; |
| 3 | 3 | |
| 4 | import com.keenwrite.Caret; | |
| 4 | import com.keenwrite.editors.common.Caret; | |
| 5 | 5 | import javafx.scene.control.IndexRange; |
| 6 | 6 | import org.fxmisc.flowless.VirtualizedScrollPane; |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.editors.common; | |
| 3 | ||
| 4 | import com.keenwrite.util.GenericBuilder; | |
| 5 | ||
| 6 | import java.util.function.Supplier; | |
| 7 | ||
| 8 | import static com.keenwrite.Messages.get; | |
| 9 | import static com.keenwrite.constants.Constants.STATUS_BAR_LINE; | |
| 10 | ||
| 11 | /** | |
| 12 | * Represents the absolute, relative, and maximum position of the caret. The | |
| 13 | * caret position is a character offset into the text. | |
| 14 | */ | |
| 15 | public class Caret { | |
| 16 | ||
| 17 | private final Mutator mMutator; | |
| 18 | ||
| 19 | public static GenericBuilder<Caret.Mutator, Caret> builder() { | |
| 20 | return GenericBuilder.of( Caret.Mutator::new, Caret::new ); | |
| 21 | } | |
| 22 | ||
| 23 | /** | |
| 24 | * Configures a caret. | |
| 25 | */ | |
| 26 | public static class Mutator { | |
| 27 | /** | |
| 28 | * Caret's current paragraph index (i.e., current caret line number). | |
| 29 | */ | |
| 30 | private Supplier<Integer> mParagraph = () -> 0; | |
| 31 | ||
| 32 | /** | |
| 33 | * Used to count the number of lines in the text editor document. | |
| 34 | */ | |
| 35 | private Supplier<Integer> mParagraphs = () -> 1; | |
| 36 | ||
| 37 | /** | |
| 38 | * Caret offset into the current paragraph, represented as a string index. | |
| 39 | */ | |
| 40 | private Supplier<Integer> mParaOffset = () -> 0; | |
| 41 | ||
| 42 | /** | |
| 43 | * Caret offset into the full text, represented as a string index. | |
| 44 | */ | |
| 45 | private Supplier<Integer> mTextOffset = () -> 0; | |
| 46 | ||
| 47 | /** | |
| 48 | * Total number of characters in the document. | |
| 49 | */ | |
| 50 | private Supplier<Integer> mTextLength = () -> 0; | |
| 51 | ||
| 52 | /** | |
| 53 | * Sets the {@link Supplier} for the caret's current paragraph number. | |
| 54 | * | |
| 55 | * @param paragraph Returns the document caret paragraph index. | |
| 56 | */ | |
| 57 | public void setParagraph( final Supplier<Integer> paragraph ) { | |
| 58 | assert paragraph != null; | |
| 59 | mParagraph = paragraph; | |
| 60 | } | |
| 61 | ||
| 62 | /** | |
| 63 | * Sets the {@link Supplier} for the total number of document paragraphs. | |
| 64 | * | |
| 65 | * @param paragraphs Returns the document paragraph count. | |
| 66 | */ | |
| 67 | public void setParagraphs( final Supplier<Integer> paragraphs ) { | |
| 68 | assert paragraphs != null; | |
| 69 | mParagraphs = paragraphs; | |
| 70 | } | |
| 71 | ||
| 72 | /** | |
| 73 | * Sets the {@link Supplier} for the caret's current character offset | |
| 74 | * into the current paragraph. | |
| 75 | * | |
| 76 | * @param paraOffset Returns the caret's paragraph character index. | |
| 77 | */ | |
| 78 | public void setParaOffset( final Supplier<Integer> paraOffset ) { | |
| 79 | assert paraOffset != null; | |
| 80 | mParaOffset = paraOffset; | |
| 81 | } | |
| 82 | ||
| 83 | /** | |
| 84 | * Sets the {@link Supplier} for the caret's current document position. | |
| 85 | * A value of 0 represents the start of the document. | |
| 86 | * | |
| 87 | * @param textOffset Returns the text offset into the current document. | |
| 88 | */ | |
| 89 | public void setTextOffset( final Supplier<Integer> textOffset ) { | |
| 90 | assert textOffset != null; | |
| 91 | mTextOffset = textOffset; | |
| 92 | } | |
| 93 | ||
| 94 | /** | |
| 95 | * Sets the {@link Supplier} for the document's total character count. | |
| 96 | * | |
| 97 | * @param textLength Returns the total character count in the document. | |
| 98 | */ | |
| 99 | public void setTextLength( final Supplier<Integer> textLength ) { | |
| 100 | assert textLength != null; | |
| 101 | mTextLength = textLength; | |
| 102 | } | |
| 103 | } | |
| 104 | ||
| 105 | /** | |
| 106 | * Force using the builder pattern. | |
| 107 | */ | |
| 108 | private Caret( final Mutator mutator ) { | |
| 109 | assert mutator != null; | |
| 110 | ||
| 111 | mMutator = mutator; | |
| 112 | } | |
| 113 | ||
| 114 | /** | |
| 115 | * Answers whether the caret's offset into the text is between the given | |
| 116 | * offsets. | |
| 117 | * | |
| 118 | * @param began Starting value compared against the caret's text offset. | |
| 119 | * @param ended Ending value compared against the caret's text offset. | |
| 120 | * @return {@code true} when the caret's text offset is between the given | |
| 121 | * values, inclusively (for either value). | |
| 122 | */ | |
| 123 | public boolean isBetweenText( final int began, final int ended ) { | |
| 124 | final var offset = getTextOffset(); | |
| 125 | return began <= offset && offset <= ended; | |
| 126 | } | |
| 127 | ||
| 128 | /** | |
| 129 | * Answers whether the caret's offset into the paragraph is before the given | |
| 130 | * offset. | |
| 131 | * | |
| 132 | * @param offset Compared against the caret's paragraph offset. | |
| 133 | * @return {@code true} the caret's offset is before the given offset. | |
| 134 | */ | |
| 135 | public boolean isBeforeColumn( final int offset ) { | |
| 136 | return getParaOffset() < offset; | |
| 137 | } | |
| 138 | ||
| 139 | /** | |
| 140 | * Answers whether the caret's offset into the text is before the given | |
| 141 | * text offset. | |
| 142 | * | |
| 143 | * @param offset Compared against the caret's text offset. | |
| 144 | * @return {@code true} the caret's offset is after the given offset. | |
| 145 | */ | |
| 146 | public boolean isAfterColumn( final int offset ) { | |
| 147 | return getParaOffset() > offset; | |
| 148 | } | |
| 149 | ||
| 150 | /** | |
| 151 | * Answers whether the caret's offset into the text exceeds the length of | |
| 152 | * the text. | |
| 153 | * | |
| 154 | * @return {@code true} when the caret is at the end of the text boundary. | |
| 155 | */ | |
| 156 | public boolean isAfterText() { | |
| 157 | return getTextOffset() >= getTextLength(); | |
| 158 | } | |
| 159 | ||
| 160 | public boolean isAfter( final int offset ) { | |
| 161 | return offset >= getTextOffset(); | |
| 162 | } | |
| 163 | ||
| 164 | private int getParagraph() { | |
| 165 | return mMutator.mParagraph.get(); | |
| 166 | } | |
| 167 | ||
| 168 | /** | |
| 169 | * Returns the number of lines in the text editor. | |
| 170 | * | |
| 171 | * @return The size of the text editor's paragraph list plus one. | |
| 172 | */ | |
| 173 | private int getParagraphCount() { | |
| 174 | return mMutator.mParagraphs.get(); | |
| 175 | } | |
| 176 | ||
| 177 | /** | |
| 178 | * Returns the absolute position of the caret within the entire document. | |
| 179 | * | |
| 180 | * @return A zero-based index of the caret position. | |
| 181 | */ | |
| 182 | private int getTextOffset() { | |
| 183 | return mMutator.mTextOffset.get(); | |
| 184 | } | |
| 185 | ||
| 186 | /** | |
| 187 | * Returns the position of the caret within the current paragraph being | |
| 188 | * edited. | |
| 189 | * | |
| 190 | * @return A zero-based index of the caret position relative to the | |
| 191 | * current paragraph. | |
| 192 | */ | |
| 193 | private int getParaOffset() { | |
| 194 | return mMutator.mParaOffset.get(); | |
| 195 | } | |
| 196 | ||
| 197 | /** | |
| 198 | * Returns the total number of characters in the document being edited. | |
| 199 | * | |
| 200 | * @return A zero-based count of the total characters in the document. | |
| 201 | */ | |
| 202 | private int getTextLength() { | |
| 203 | return mMutator.mTextLength.get(); | |
| 204 | } | |
| 205 | ||
| 206 | /** | |
| 207 | * Returns a human-readable string that shows the current caret position | |
| 208 | * within the text. Typically, this will include the current line number, | |
| 209 | * the number of lines, and the character offset into the text. | |
| 210 | * <p> | |
| 211 | * If the {@link Caret} has not been properly built, this will return a | |
| 212 | * string for the status bar having all values set to zero. This can happen | |
| 213 | * during unit testing. | |
| 214 | * | |
| 215 | * @return A string to present to an end user. | |
| 216 | */ | |
| 217 | @Override | |
| 218 | public String toString() { | |
| 219 | try { | |
| 220 | return get( STATUS_BAR_LINE, | |
| 221 | getParagraph() + 1, | |
| 222 | getParagraphCount(), | |
| 223 | getTextOffset() + 1 ); | |
| 224 | } catch( final Exception ex ) { | |
| 225 | return get( STATUS_BAR_LINE, 0, 0, 0 ); | |
| 226 | } | |
| 227 | } | |
| 228 | } | |
| 1 | 229 |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.editors.common; | |
| 3 | ||
| 4 | import com.keenwrite.events.ScrollLockEvent; | |
| 5 | import javafx.beans.property.BooleanProperty; | |
| 6 | import javafx.beans.property.SimpleBooleanProperty; | |
| 7 | import javafx.event.Event; | |
| 8 | import javafx.event.EventHandler; | |
| 9 | import javafx.scene.control.ScrollBar; | |
| 10 | import javafx.scene.control.skin.ScrollBarSkin; | |
| 11 | import javafx.scene.input.MouseEvent; | |
| 12 | import javafx.scene.input.ScrollEvent; | |
| 13 | import javafx.scene.layout.StackPane; | |
| 14 | import org.fxmisc.flowless.VirtualizedScrollPane; | |
| 15 | import org.fxmisc.richtext.StyleClassedTextArea; | |
| 16 | import org.greenrobot.eventbus.Subscribe; | |
| 17 | ||
| 18 | import javax.swing.*; | |
| 19 | import java.util.function.Consumer; | |
| 20 | ||
| 21 | import static com.keenwrite.events.Bus.register; | |
| 22 | import static java.lang.Math.max; | |
| 23 | import static java.lang.Math.min; | |
| 24 | import static javafx.geometry.Orientation.VERTICAL; | |
| 25 | import static javax.swing.SwingUtilities.invokeLater; | |
| 26 | ||
| 27 | /** | |
| 28 | * Converts scroll events from {@link VirtualizedScrollPane} scroll bars to | |
| 29 | * an instance of {@link JScrollBar}. | |
| 30 | * <p> | |
| 31 | * Called to synchronize the scrolling areas for either scrolling with the | |
| 32 | * mouse or scrolling using the scrollbar's thumb. Both are required to avoid | |
| 33 | * scrolling on the estimatedScrollYProperty that occurs when text events | |
| 34 | * fire. Scrolling performed for text events are handled separately to ensure | |
| 35 | * the preview panel scrolls to the same position in the Markdown editor, | |
| 36 | * taking into account things like images, tables, and other potentially | |
| 37 | * long vertical presentation items. | |
| 38 | * </p> | |
| 39 | */ | |
| 40 | public final class ScrollEventHandler implements EventHandler<Event> { | |
| 41 | ||
| 42 | private final class MouseHandler implements EventHandler<MouseEvent> { | |
| 43 | private final EventHandler<? super MouseEvent> mOldHandler; | |
| 44 | ||
| 45 | /** | |
| 46 | * Constructs a new handler for mouse scrolling events. | |
| 47 | * | |
| 48 | * @param oldHandler Receives the event after scrolling takes place. | |
| 49 | */ | |
| 50 | private MouseHandler( final EventHandler<? super MouseEvent> oldHandler ) { | |
| 51 | mOldHandler = oldHandler; | |
| 52 | } | |
| 53 | ||
| 54 | @Override | |
| 55 | public void handle( final MouseEvent event ) { | |
| 56 | ScrollEventHandler.this.handle( event ); | |
| 57 | mOldHandler.handle( event ); | |
| 58 | } | |
| 59 | } | |
| 60 | ||
| 61 | private final class ScrollHandler implements EventHandler<ScrollEvent> { | |
| 62 | @Override | |
| 63 | public void handle( final ScrollEvent event ) { | |
| 64 | ScrollEventHandler.this.handle( event ); | |
| 65 | } | |
| 66 | } | |
| 67 | ||
| 68 | private final VirtualizedScrollPane<StyleClassedTextArea> mEditorScrollPane; | |
| 69 | private final JScrollBar mPreviewScrollBar; | |
| 70 | private final BooleanProperty mEnabled = new SimpleBooleanProperty(); | |
| 71 | ||
| 72 | private boolean mLocked; | |
| 73 | ||
| 74 | /** | |
| 75 | * @param editorScrollPane Scroll event source (human movement). | |
| 76 | * @param previewScrollBar Scroll event destination (corresponding movement). | |
| 77 | */ | |
| 78 | public ScrollEventHandler( | |
| 79 | final VirtualizedScrollPane<StyleClassedTextArea> editorScrollPane, | |
| 80 | final JScrollBar previewScrollBar ) { | |
| 81 | mEditorScrollPane = editorScrollPane; | |
| 82 | mPreviewScrollBar = previewScrollBar; | |
| 83 | ||
| 84 | mEditorScrollPane.addEventFilter( ScrollEvent.ANY, new ScrollHandler() ); | |
| 85 | ||
| 86 | initVerticalScrollBarThumb( | |
| 87 | mEditorScrollPane, | |
| 88 | thumb -> { | |
| 89 | final var handler = new MouseHandler( thumb.getOnMouseDragged() ); | |
| 90 | thumb.setOnMouseDragged( handler ); | |
| 91 | } | |
| 92 | ); | |
| 93 | ||
| 94 | register( this ); | |
| 95 | } | |
| 96 | ||
| 97 | /** | |
| 98 | * Gets a property intended to be bound to selected property of the tab being | |
| 99 | * scrolled. This is required because there's only one preview pane but | |
| 100 | * multiple editor panes. Each editor pane maintains its own scroll position. | |
| 101 | * | |
| 102 | * @return A {@link BooleanProperty} representing whether the scroll | |
| 103 | * events for this tab are to be executed. | |
| 104 | */ | |
| 105 | public BooleanProperty enabledProperty() { | |
| 106 | return mEnabled; | |
| 107 | } | |
| 108 | ||
| 109 | /** | |
| 110 | * Scrolls the preview scrollbar relative to the edit scrollbar. Algorithm | |
| 111 | * is based on Karl Tauber's ratio calculation. | |
| 112 | * | |
| 113 | * @param event Unused; either {@link MouseEvent} or {@link ScrollEvent} | |
| 114 | */ | |
| 115 | @Override | |
| 116 | public void handle( final Event event ) { | |
| 117 | invokeLater( () -> { | |
| 118 | if( isEnabled() ) { | |
| 119 | // e is for editor pane | |
| 120 | final var eScrollPane = getEditorScrollPane(); | |
| 121 | final var eScrollY = | |
| 122 | eScrollPane.estimatedScrollYProperty().getValue().intValue(); | |
| 123 | final var eHeight = (int) | |
| 124 | (eScrollPane.totalHeightEstimateProperty().getValue().intValue() | |
| 125 | - eScrollPane.getHeight()); | |
| 126 | final var eRatio = eHeight > 0 | |
| 127 | ? min( max( eScrollY / (float) eHeight, 0 ), 1 ) : 0; | |
| 128 | ||
| 129 | // p is for preview pane | |
| 130 | final var pScrollBar = getPreviewScrollBar(); | |
| 131 | final var pHeight = pScrollBar.getMaximum() - pScrollBar.getHeight(); | |
| 132 | final var pScrollY = (int) (pHeight * eRatio); | |
| 133 | ||
| 134 | pScrollBar.setValue( pScrollY ); | |
| 135 | pScrollBar.getParent().repaint(); | |
| 136 | } | |
| 137 | } ); | |
| 138 | } | |
| 139 | ||
| 140 | @Subscribe | |
| 141 | public void handle( final ScrollLockEvent event ) { | |
| 142 | mLocked = event.isLocked(); | |
| 143 | } | |
| 144 | ||
| 145 | private void initVerticalScrollBarThumb( | |
| 146 | final VirtualizedScrollPane<StyleClassedTextArea> pane, | |
| 147 | final Consumer<StackPane> consumer ) { | |
| 148 | // When the skin property is set, the stack pane is available (not null). | |
| 149 | getVerticalScrollBar( pane ).skinProperty().addListener( ( c, o, n ) -> { | |
| 150 | for( final var node : ((ScrollBarSkin) n).getChildren() ) { | |
| 151 | // Brittle, but what can you do? | |
| 152 | if( node.getStyleClass().contains( "thumb" ) ) { | |
| 153 | consumer.accept( (StackPane) node ); | |
| 154 | } | |
| 155 | } | |
| 156 | } ); | |
| 157 | } | |
| 158 | ||
| 159 | /** | |
| 160 | * Returns the vertical {@link ScrollBar} instance associated with the | |
| 161 | * given scroll pane. This is {@code null}-safe because the scroll pane | |
| 162 | * initializes its vertical {@link ScrollBar} upon construction. | |
| 163 | * | |
| 164 | * @param pane The scroll pane that contains a vertical {@link ScrollBar}. | |
| 165 | * @return The vertical {@link ScrollBar} associated with the scroll pane. | |
| 166 | * @throws IllegalStateException Could not obtain the vertical scroll bar. | |
| 167 | */ | |
| 168 | private ScrollBar getVerticalScrollBar( | |
| 169 | final VirtualizedScrollPane<StyleClassedTextArea> pane ) { | |
| 170 | ||
| 171 | for( final var node : pane.getChildrenUnmodifiable() ) { | |
| 172 | if( node instanceof final ScrollBar scrollBar && | |
| 173 | scrollBar.getOrientation() == VERTICAL ) { | |
| 174 | return scrollBar; | |
| 175 | } | |
| 176 | } | |
| 177 | ||
| 178 | throw new IllegalStateException( "No vertical scroll bar found." ); | |
| 179 | } | |
| 180 | ||
| 181 | private boolean isEnabled() { | |
| 182 | // TODO: As a minor optimization, when this is set to false, it could remove | |
| 183 | // the MouseHandler and ScrollHandler so that events only dispatch to one | |
| 184 | // object (instead of one per editor tab). | |
| 185 | return mEnabled.get() && !mLocked; | |
| 186 | } | |
| 187 | ||
| 188 | private VirtualizedScrollPane<StyleClassedTextArea> getEditorScrollPane() { | |
| 189 | return mEditorScrollPane; | |
| 190 | } | |
| 191 | ||
| 192 | private JScrollBar getPreviewScrollBar() { | |
| 193 | return mPreviewScrollBar; | |
| 194 | } | |
| 195 | } | |
| 1 | 196 |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.editors.common; | |
| 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 |
| 79 | 79 | * null} if there was no match found. |
| 80 | 80 | */ |
| 81 | @SuppressWarnings( "AssignmentUsedAsCondition" ) | |
| 81 | 82 | public DefinitionTreeItem<T> findLeaf( |
| 82 | 83 | final String text, |
| ... | ||
| 168 | 169 | */ |
| 169 | 170 | public String toPath() { |
| 170 | return new TreeItemMapper().toPath( getParent() ); | |
| 171 | return TreeItemMapper.toPath( getParent() ); | |
| 171 | 172 | } |
| 172 | 173 | |
| 2 | 2 | package com.keenwrite.editors.markdown; |
| 3 | 3 | |
| 4 | import com.keenwrite.Caret; | |
| 5 | import com.keenwrite.constants.Constants; | |
| 6 | import com.keenwrite.editors.TextEditor; | |
| 7 | import com.keenwrite.events.TextEditorFocusEvent; | |
| 8 | import com.keenwrite.io.MediaType; | |
| 9 | import com.keenwrite.preferences.LocaleProperty; | |
| 10 | import com.keenwrite.preferences.Workspace; | |
| 11 | import com.keenwrite.spelling.impl.TextEditorSpeller; | |
| 12 | import javafx.beans.binding.Bindings; | |
| 13 | import javafx.beans.property.*; | |
| 14 | import javafx.beans.value.ChangeListener; | |
| 15 | import javafx.event.Event; | |
| 16 | import javafx.scene.Node; | |
| 17 | import javafx.scene.control.ContextMenu; | |
| 18 | import javafx.scene.control.IndexRange; | |
| 19 | import javafx.scene.control.MenuItem; | |
| 20 | import javafx.scene.input.KeyEvent; | |
| 21 | import javafx.scene.layout.BorderPane; | |
| 22 | import org.fxmisc.flowless.VirtualizedScrollPane; | |
| 23 | import org.fxmisc.richtext.StyleClassedTextArea; | |
| 24 | import org.fxmisc.richtext.model.StyleSpans; | |
| 25 | import org.fxmisc.undo.UndoManager; | |
| 26 | import org.fxmisc.wellbehaved.event.EventPattern; | |
| 27 | import org.fxmisc.wellbehaved.event.Nodes; | |
| 28 | ||
| 29 | import java.io.File; | |
| 30 | import java.nio.charset.Charset; | |
| 31 | import java.text.BreakIterator; | |
| 32 | import java.text.MessageFormat; | |
| 33 | import java.util.*; | |
| 34 | import java.util.function.Consumer; | |
| 35 | import java.util.function.Supplier; | |
| 36 | import java.util.regex.Pattern; | |
| 37 | ||
| 38 | import static com.keenwrite.MainApp.keyDown; | |
| 39 | import static com.keenwrite.constants.Constants.*; | |
| 40 | import static com.keenwrite.events.StatusEvent.clue; | |
| 41 | import static com.keenwrite.io.MediaType.TEXT_MARKDOWN; | |
| 42 | import static com.keenwrite.io.MediaType.TEXT_R_MARKDOWN; | |
| 43 | import static com.keenwrite.preferences.AppKeys.*; | |
| 44 | import static java.lang.Character.isWhitespace; | |
| 45 | import static java.lang.String.format; | |
| 46 | import static java.util.Collections.singletonList; | |
| 47 | import static javafx.application.Platform.runLater; | |
| 48 | import static javafx.scene.control.ScrollPane.ScrollBarPolicy.ALWAYS; | |
| 49 | import static javafx.scene.input.KeyCode.*; | |
| 50 | import static javafx.scene.input.KeyCombination.*; | |
| 51 | import static org.apache.commons.lang3.StringUtils.stripEnd; | |
| 52 | import static org.apache.commons.lang3.StringUtils.stripStart; | |
| 53 | import static org.fxmisc.richtext.model.StyleSpans.singleton; | |
| 54 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 55 | import static org.fxmisc.wellbehaved.event.InputMap.consume; | |
| 56 | ||
| 57 | /** | |
| 58 | * Responsible for editing Markdown documents. | |
| 59 | */ | |
| 60 | public final class MarkdownEditor extends BorderPane implements TextEditor { | |
| 61 | /** | |
| 62 | * Regular expression that matches the type of markup block. This is used | |
| 63 | * when Enter is pressed to continue the block environment. | |
| 64 | */ | |
| 65 | private static final Pattern PATTERN_AUTO_INDENT = Pattern.compile( | |
| 66 | "(\\s*[*+-]\\s+|\\s*[0-9]+\\.\\s+|\\s+)(.*)" ); | |
| 67 | ||
| 68 | private final Workspace mWorkspace; | |
| 69 | ||
| 70 | /** | |
| 71 | * The text editor. | |
| 72 | */ | |
| 73 | private final StyleClassedTextArea mTextArea = | |
| 74 | new StyleClassedTextArea( false ); | |
| 75 | ||
| 76 | /** | |
| 77 | * Wraps the text editor in scrollbars. | |
| 78 | */ | |
| 79 | private final VirtualizedScrollPane<StyleClassedTextArea> mScrollPane = | |
| 80 | new VirtualizedScrollPane<>( mTextArea ); | |
| 81 | ||
| 82 | /** | |
| 83 | * Tracks where the caret is located in this document. This offers observable | |
| 84 | * properties for caret position changes. | |
| 85 | */ | |
| 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 | ||
| 93 | /** | |
| 94 | * File being edited by this editor instance. | |
| 95 | */ | |
| 96 | private File mFile; | |
| 97 | ||
| 98 | /** | |
| 99 | * Set to {@code true} upon text or caret position changes. Value is {@code | |
| 100 | * false} by default. | |
| 101 | */ | |
| 102 | private final BooleanProperty mDirty = new SimpleBooleanProperty(); | |
| 103 | ||
| 104 | /** | |
| 105 | * Opened file's character encoding, or {@link Constants#DEFAULT_CHARSET} if | |
| 106 | * either no encoding could be determined or this is a new (empty) file. | |
| 107 | */ | |
| 108 | private final Charset mEncoding; | |
| 109 | ||
| 110 | /** | |
| 111 | * Tracks whether the in-memory definitions have changed with respect to the | |
| 112 | * persisted definitions. | |
| 113 | */ | |
| 114 | private final BooleanProperty mModified = new SimpleBooleanProperty(); | |
| 115 | ||
| 116 | public MarkdownEditor( final Workspace workspace ) { | |
| 117 | this( DOCUMENT_DEFAULT, workspace ); | |
| 118 | } | |
| 119 | ||
| 120 | public MarkdownEditor( final File file, final Workspace workspace ) { | |
| 121 | mEncoding = open( mFile = file ); | |
| 122 | mWorkspace = workspace; | |
| 123 | ||
| 124 | initTextArea( mTextArea ); | |
| 125 | initStyle( mTextArea ); | |
| 126 | initScrollPane( mScrollPane ); | |
| 127 | initSpellchecker( mTextArea ); | |
| 128 | initHotKeys(); | |
| 129 | initUndoManager(); | |
| 130 | } | |
| 131 | ||
| 132 | private void initTextArea( final StyleClassedTextArea textArea ) { | |
| 133 | textArea.setWrapText( true ); | |
| 134 | textArea.requestFollowCaret(); | |
| 135 | textArea.moveTo( 0 ); | |
| 136 | ||
| 137 | textArea.textProperty().addListener( ( c, o, n ) -> { | |
| 138 | // Fire, regardless of whether the caret position has changed. | |
| 139 | mDirty.set( false ); | |
| 140 | ||
| 141 | // Prevent the subsequent caret position change from raising dirty bits. | |
| 142 | mDirty.set( true ); | |
| 143 | } ); | |
| 144 | ||
| 145 | textArea.caretPositionProperty().addListener( ( c, o, n ) -> { | |
| 146 | // Fire when the caret position has changed and the text has not. | |
| 147 | mDirty.set( true ); | |
| 148 | mDirty.set( false ); | |
| 149 | } ); | |
| 150 | ||
| 151 | textArea.focusedProperty().addListener( ( c, o, n ) -> { | |
| 152 | if( n != null && n ) { | |
| 153 | TextEditorFocusEvent.fire( this ); | |
| 154 | } | |
| 155 | } ); | |
| 156 | } | |
| 157 | ||
| 158 | private void initStyle( final StyleClassedTextArea textArea ) { | |
| 159 | textArea.getStyleClass().add( "markdown" ); | |
| 160 | ||
| 161 | final var stylesheets = textArea.getStylesheets(); | |
| 162 | stylesheets.add( getStylesheetPath( getLocale() ) ); | |
| 163 | ||
| 164 | localeProperty().addListener( ( c, o, n ) -> { | |
| 165 | if( n != null ) { | |
| 166 | stylesheets.clear(); | |
| 167 | stylesheets.add( getStylesheetPath( getLocale() ) ); | |
| 168 | } | |
| 169 | } ); | |
| 170 | ||
| 171 | fontNameProperty().addListener( | |
| 172 | ( c, o, n ) -> | |
| 173 | setFont( mTextArea, getFontName(), getFontSize() ) | |
| 174 | ); | |
| 175 | ||
| 176 | fontSizeProperty().addListener( | |
| 177 | ( c, o, n ) -> | |
| 178 | setFont( mTextArea, getFontName(), getFontSize() ) | |
| 179 | ); | |
| 180 | ||
| 181 | setFont( mTextArea, getFontName(), getFontSize() ); | |
| 182 | } | |
| 183 | ||
| 184 | private void initScrollPane( | |
| 185 | final VirtualizedScrollPane<StyleClassedTextArea> scrollpane ) { | |
| 186 | scrollpane.setVbarPolicy( ALWAYS ); | |
| 187 | setCenter( scrollpane ); | |
| 188 | } | |
| 189 | ||
| 190 | private void initSpellchecker( final StyleClassedTextArea textarea ) { | |
| 191 | mSpeller.checkDocument( textarea ); | |
| 192 | mSpeller.checkParagraphs( textarea ); | |
| 193 | } | |
| 194 | ||
| 195 | private void initHotKeys() { | |
| 196 | addEventListener( keyPressed( ENTER ), this::onEnterPressed ); | |
| 197 | addEventListener( keyPressed( X, CONTROL_DOWN ), this::cut ); | |
| 198 | addEventListener( keyPressed( TAB ), this::tab ); | |
| 199 | addEventListener( keyPressed( TAB, SHIFT_DOWN ), this::untab ); | |
| 200 | addEventListener( keyPressed( ENTER, ALT_DOWN ), this::autofix ); | |
| 201 | } | |
| 202 | ||
| 203 | private void initUndoManager() { | |
| 204 | final var undoManager = getUndoManager(); | |
| 205 | final var markedPosition = undoManager.atMarkedPositionProperty(); | |
| 206 | ||
| 207 | undoManager.forgetHistory(); | |
| 208 | undoManager.mark(); | |
| 209 | mModified.bind( Bindings.not( markedPosition ) ); | |
| 210 | } | |
| 211 | ||
| 212 | @Override | |
| 213 | public void moveTo( final int offset ) { | |
| 214 | assert 0 <= offset && offset <= mTextArea.getLength(); | |
| 215 | ||
| 216 | mTextArea.moveTo( offset ); | |
| 217 | mTextArea.requestFollowCaret(); | |
| 218 | } | |
| 219 | ||
| 220 | /** | |
| 221 | * Delegate the focus request to the text area itself. | |
| 222 | */ | |
| 223 | @Override | |
| 224 | public void requestFocus() { | |
| 225 | mTextArea.requestFocus(); | |
| 226 | } | |
| 227 | ||
| 228 | @Override | |
| 229 | public void setText( final String text ) { | |
| 230 | mTextArea.clear(); | |
| 231 | mTextArea.appendText( text ); | |
| 232 | mTextArea.getUndoManager().mark(); | |
| 233 | } | |
| 234 | ||
| 235 | @Override | |
| 236 | public String getText() { | |
| 237 | return mTextArea.getText(); | |
| 238 | } | |
| 239 | ||
| 240 | @Override | |
| 241 | public Charset getEncoding() { | |
| 242 | return mEncoding; | |
| 243 | } | |
| 244 | ||
| 245 | @Override | |
| 246 | public File getFile() { | |
| 247 | return mFile; | |
| 248 | } | |
| 249 | ||
| 250 | @Override | |
| 251 | public void rename( final File file ) { | |
| 252 | mFile = file; | |
| 253 | } | |
| 254 | ||
| 255 | @Override | |
| 256 | public void undo() { | |
| 257 | final var manager = getUndoManager(); | |
| 258 | xxdo( manager::isUndoAvailable, manager::undo, "Main.status.error.undo" ); | |
| 259 | } | |
| 260 | ||
| 261 | @Override | |
| 262 | public void redo() { | |
| 263 | final var manager = getUndoManager(); | |
| 264 | xxdo( manager::isRedoAvailable, manager::redo, "Main.status.error.redo" ); | |
| 265 | } | |
| 266 | ||
| 267 | /** | |
| 268 | * Performs an undo or redo action, if possible, otherwise displays an error | |
| 269 | * message to the user. | |
| 270 | * | |
| 271 | * @param ready Answers whether the action can be executed. | |
| 272 | * @param action The action to execute. | |
| 273 | * @param key The informational message key having a value to display if | |
| 274 | * the {@link Supplier} is not ready. | |
| 275 | */ | |
| 276 | private void xxdo( | |
| 277 | final Supplier<Boolean> ready, final Runnable action, final String key ) { | |
| 278 | if( ready.get() ) { | |
| 279 | action.run(); | |
| 280 | } | |
| 281 | else { | |
| 282 | clue( key ); | |
| 283 | } | |
| 284 | } | |
| 285 | ||
| 286 | @Override | |
| 287 | public void cut() { | |
| 288 | final var selected = mTextArea.getSelectedText(); | |
| 289 | ||
| 290 | // Emulate selecting the current line by firing Home then Shift+Down Arrow. | |
| 291 | if( selected == null || selected.isEmpty() ) { | |
| 292 | // Note: mTextArea.selectLine() does not select empty lines. | |
| 293 | mTextArea.fireEvent( keyDown( HOME, false ) ); | |
| 294 | mTextArea.fireEvent( keyDown( DOWN, true ) ); | |
| 295 | } | |
| 296 | ||
| 297 | mTextArea.cut(); | |
| 298 | } | |
| 299 | ||
| 300 | @Override | |
| 301 | public void copy() { | |
| 302 | mTextArea.copy(); | |
| 303 | } | |
| 304 | ||
| 305 | @Override | |
| 306 | public void paste() { | |
| 307 | mTextArea.paste(); | |
| 308 | } | |
| 309 | ||
| 310 | @Override | |
| 311 | public void selectAll() { | |
| 312 | mTextArea.selectAll(); | |
| 313 | } | |
| 314 | ||
| 315 | @Override | |
| 316 | public void bold() { | |
| 317 | enwrap( "**" ); | |
| 318 | } | |
| 319 | ||
| 320 | @Override | |
| 321 | public void italic() { | |
| 322 | enwrap( "*" ); | |
| 323 | } | |
| 324 | ||
| 325 | @Override | |
| 326 | public void monospace() { | |
| 327 | enwrap( "`" ); | |
| 328 | } | |
| 329 | ||
| 330 | @Override | |
| 331 | public void superscript() { | |
| 332 | enwrap( "^" ); | |
| 333 | } | |
| 334 | ||
| 335 | @Override | |
| 336 | public void subscript() { | |
| 337 | enwrap( "~" ); | |
| 338 | } | |
| 339 | ||
| 340 | @Override | |
| 341 | public void strikethrough() { | |
| 342 | enwrap( "~~" ); | |
| 343 | } | |
| 344 | ||
| 345 | @Override | |
| 346 | public void blockquote() { | |
| 347 | block( "> " ); | |
| 348 | } | |
| 349 | ||
| 350 | @Override | |
| 351 | public void code() { | |
| 352 | enwrap( "`" ); | |
| 353 | } | |
| 354 | ||
| 355 | @Override | |
| 356 | public void fencedCodeBlock() { | |
| 357 | enwrap( "\n\n```\n", "\n```\n\n" ); | |
| 358 | } | |
| 359 | ||
| 360 | @Override | |
| 361 | public void heading( final int level ) { | |
| 362 | final var hashes = new String( new char[ level ] ).replace( "\0", "#" ); | |
| 363 | block( format( "%s ", hashes ) ); | |
| 364 | } | |
| 365 | ||
| 366 | @Override | |
| 367 | public void unorderedList() { | |
| 368 | block( "* " ); | |
| 369 | } | |
| 370 | ||
| 371 | @Override | |
| 372 | public void orderedList() { | |
| 373 | block( "1. " ); | |
| 374 | } | |
| 375 | ||
| 376 | @Override | |
| 377 | public void horizontalRule() { | |
| 378 | block( format( "---%n%n" ) ); | |
| 379 | } | |
| 380 | ||
| 381 | @Override | |
| 382 | public Node getNode() { | |
| 383 | return this; | |
| 384 | } | |
| 385 | ||
| 386 | @Override | |
| 387 | public ReadOnlyBooleanProperty modifiedProperty() { | |
| 388 | return mModified; | |
| 389 | } | |
| 390 | ||
| 391 | @Override | |
| 392 | public void clearModifiedProperty() { | |
| 393 | getUndoManager().mark(); | |
| 394 | } | |
| 395 | ||
| 396 | @Override | |
| 397 | public VirtualizedScrollPane<StyleClassedTextArea> getScrollPane() { | |
| 398 | return mScrollPane; | |
| 399 | } | |
| 400 | ||
| 401 | @Override | |
| 402 | public StyleClassedTextArea getTextArea() { | |
| 403 | return mTextArea; | |
| 404 | } | |
| 405 | ||
| 406 | private final Map<String, IndexRange> mStyles = new HashMap<>(); | |
| 407 | ||
| 408 | @Override | |
| 409 | public void stylize( final IndexRange range, final String style ) { | |
| 410 | final var began = range.getStart(); | |
| 411 | final var ended = range.getEnd() + 1; | |
| 412 | ||
| 413 | assert 0 <= began && began <= ended; | |
| 414 | assert style != null; | |
| 415 | ||
| 416 | // TODO: Ensure spell check and find highlights can coexist. | |
| 417 | // final var spans = mTextArea.getStyleSpans( range ); | |
| 418 | // System.out.println( "SPANS: " + spans ); | |
| 419 | ||
| 420 | // final var spans = mTextArea.getStyleSpans( range ); | |
| 421 | // mTextArea.setStyleSpans( began, merge( spans, range.getLength(), style | |
| 422 | // ) ); | |
| 423 | ||
| 424 | // final var builder = new StyleSpansBuilder<Collection<String>>(); | |
| 425 | // builder.add( singleton( style ), range.getLength() + 1 ); | |
| 426 | // mTextArea.setStyleSpans( began, builder.create() ); | |
| 427 | ||
| 428 | // final var s = mTextArea.getStyleSpans( began, ended ); | |
| 429 | // System.out.println( "STYLES: " +s ); | |
| 430 | ||
| 431 | mStyles.put( style, range ); | |
| 432 | mTextArea.setStyleClass( began, ended, style ); | |
| 433 | ||
| 434 | // Ensure that whenever the user interacts with the text that the found | |
| 435 | // word will have its highlighting removed. The handler removes itself. | |
| 436 | // This won't remove the highlighting if the caret position moves by mouse. | |
| 437 | final var handler = mTextArea.getOnKeyPressed(); | |
| 438 | mTextArea.setOnKeyPressed( ( event ) -> { | |
| 439 | mTextArea.setOnKeyPressed( handler ); | |
| 440 | unstylize( style ); | |
| 441 | } ); | |
| 442 | ||
| 443 | //mTextArea.setStyleSpans(began, ended, s); | |
| 444 | } | |
| 445 | ||
| 446 | private static StyleSpans<Collection<String>> merge( | |
| 447 | StyleSpans<Collection<String>> spans, int len, String style ) { | |
| 448 | spans = spans.overlay( | |
| 449 | singleton( singletonList( style ), len ), | |
| 450 | ( bottomSpan, list ) -> { | |
| 451 | final List<String> l = | |
| 452 | new ArrayList<>( bottomSpan.size() + list.size() ); | |
| 453 | l.addAll( bottomSpan ); | |
| 454 | l.addAll( list ); | |
| 455 | return l; | |
| 456 | } ); | |
| 457 | ||
| 458 | return spans; | |
| 459 | } | |
| 460 | ||
| 461 | @Override | |
| 462 | public void unstylize( final String style ) { | |
| 463 | final var indexes = mStyles.remove( style ); | |
| 464 | if( indexes != null ) { | |
| 465 | mTextArea.clearStyle( indexes.getStart(), indexes.getEnd() + 1 ); | |
| 466 | } | |
| 467 | } | |
| 468 | ||
| 469 | @Override | |
| 470 | public Caret getCaret() { | |
| 471 | return mCaret; | |
| 472 | } | |
| 473 | ||
| 474 | private Caret createCaret( final StyleClassedTextArea editor ) { | |
| 475 | return Caret | |
| 476 | .builder() | |
| 477 | .with( Caret.Mutator::setEditor, editor ) | |
| 4 | import com.keenwrite.constants.Constants; | |
| 5 | import com.keenwrite.editors.TextEditor; | |
| 6 | import com.keenwrite.editors.common.Caret; | |
| 7 | import com.keenwrite.events.TextEditorFocusEvent; | |
| 8 | import com.keenwrite.io.MediaType; | |
| 9 | import com.keenwrite.preferences.LocaleProperty; | |
| 10 | import com.keenwrite.preferences.Workspace; | |
| 11 | import com.keenwrite.processors.markdown.extensions.CaretExtension; | |
| 12 | import com.keenwrite.spelling.impl.TextEditorSpeller; | |
| 13 | import javafx.beans.binding.Bindings; | |
| 14 | import javafx.beans.property.*; | |
| 15 | import javafx.beans.value.ChangeListener; | |
| 16 | import javafx.event.Event; | |
| 17 | import javafx.scene.Node; | |
| 18 | import javafx.scene.control.ContextMenu; | |
| 19 | import javafx.scene.control.IndexRange; | |
| 20 | import javafx.scene.control.MenuItem; | |
| 21 | import javafx.scene.input.KeyEvent; | |
| 22 | import javafx.scene.layout.BorderPane; | |
| 23 | import org.fxmisc.flowless.VirtualizedScrollPane; | |
| 24 | import org.fxmisc.richtext.StyleClassedTextArea; | |
| 25 | import org.fxmisc.richtext.model.StyleSpans; | |
| 26 | import org.fxmisc.undo.UndoManager; | |
| 27 | import org.fxmisc.wellbehaved.event.EventPattern; | |
| 28 | import org.fxmisc.wellbehaved.event.Nodes; | |
| 29 | ||
| 30 | import java.io.File; | |
| 31 | import java.nio.charset.Charset; | |
| 32 | import java.text.BreakIterator; | |
| 33 | import java.text.MessageFormat; | |
| 34 | import java.util.*; | |
| 35 | import java.util.function.Consumer; | |
| 36 | import java.util.function.Supplier; | |
| 37 | import java.util.regex.Pattern; | |
| 38 | ||
| 39 | import static com.keenwrite.MainApp.keyDown; | |
| 40 | import static com.keenwrite.constants.Constants.*; | |
| 41 | import static com.keenwrite.events.StatusEvent.clue; | |
| 42 | import static com.keenwrite.io.MediaType.TEXT_MARKDOWN; | |
| 43 | import static com.keenwrite.io.MediaType.TEXT_R_MARKDOWN; | |
| 44 | import static com.keenwrite.preferences.AppKeys.*; | |
| 45 | import static java.lang.Character.isWhitespace; | |
| 46 | import static java.lang.String.format; | |
| 47 | import static java.util.Collections.singletonList; | |
| 48 | import static javafx.application.Platform.runLater; | |
| 49 | import static javafx.scene.control.ScrollPane.ScrollBarPolicy.ALWAYS; | |
| 50 | import static javafx.scene.input.KeyCode.*; | |
| 51 | import static javafx.scene.input.KeyCombination.*; | |
| 52 | import static org.apache.commons.lang3.StringUtils.stripEnd; | |
| 53 | import static org.apache.commons.lang3.StringUtils.stripStart; | |
| 54 | import static org.fxmisc.richtext.model.StyleSpans.singleton; | |
| 55 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 56 | import static org.fxmisc.wellbehaved.event.InputMap.consume; | |
| 57 | ||
| 58 | /** | |
| 59 | * Responsible for editing Markdown documents. | |
| 60 | */ | |
| 61 | public final class MarkdownEditor extends BorderPane implements TextEditor { | |
| 62 | /** | |
| 63 | * Regular expression that matches the type of markup block. This is used | |
| 64 | * when Enter is pressed to continue the block environment. | |
| 65 | */ | |
| 66 | private static final Pattern PATTERN_AUTO_INDENT = Pattern.compile( | |
| 67 | "(\\s*[*+-]\\s+|\\s*[0-9]+\\.\\s+|\\s+)(.*)" ); | |
| 68 | ||
| 69 | private final Workspace mWorkspace; | |
| 70 | ||
| 71 | /** | |
| 72 | * The text editor. | |
| 73 | */ | |
| 74 | private final StyleClassedTextArea mTextArea = | |
| 75 | new StyleClassedTextArea( false ); | |
| 76 | ||
| 77 | /** | |
| 78 | * Wraps the text editor in scrollbars. | |
| 79 | */ | |
| 80 | private final VirtualizedScrollPane<StyleClassedTextArea> mScrollPane = | |
| 81 | new VirtualizedScrollPane<>( mTextArea ); | |
| 82 | ||
| 83 | /** | |
| 84 | * Tracks where the caret is located in this document. This offers observable | |
| 85 | * properties for caret position changes. | |
| 86 | */ | |
| 87 | private final Caret mCaret = createCaret( mTextArea ); | |
| 88 | ||
| 89 | /** | |
| 90 | * For spell checking the document upon load and whenever it changes. | |
| 91 | */ | |
| 92 | private final TextEditorSpeller mSpeller = new TextEditorSpeller(); | |
| 93 | ||
| 94 | /** | |
| 95 | * File being edited by this editor instance. | |
| 96 | */ | |
| 97 | private File mFile; | |
| 98 | ||
| 99 | /** | |
| 100 | * Set to {@code true} upon text or caret position changes. Value is {@code | |
| 101 | * false} by default. | |
| 102 | */ | |
| 103 | private final BooleanProperty mDirty = new SimpleBooleanProperty(); | |
| 104 | ||
| 105 | /** | |
| 106 | * Opened file's character encoding, or {@link Constants#DEFAULT_CHARSET} if | |
| 107 | * either no encoding could be determined or this is a new (empty) file. | |
| 108 | */ | |
| 109 | private final Charset mEncoding; | |
| 110 | ||
| 111 | /** | |
| 112 | * Tracks whether the in-memory definitions have changed with respect to the | |
| 113 | * persisted definitions. | |
| 114 | */ | |
| 115 | private final BooleanProperty mModified = new SimpleBooleanProperty(); | |
| 116 | ||
| 117 | public MarkdownEditor( final Workspace workspace ) { | |
| 118 | this( DOCUMENT_DEFAULT, workspace ); | |
| 119 | } | |
| 120 | ||
| 121 | public MarkdownEditor( final File file, final Workspace workspace ) { | |
| 122 | mEncoding = open( mFile = file ); | |
| 123 | mWorkspace = workspace; | |
| 124 | ||
| 125 | initTextArea( mTextArea ); | |
| 126 | initStyle( mTextArea ); | |
| 127 | initScrollPane( mScrollPane ); | |
| 128 | initSpellchecker( mTextArea ); | |
| 129 | initHotKeys(); | |
| 130 | initUndoManager(); | |
| 131 | } | |
| 132 | ||
| 133 | private void initTextArea( final StyleClassedTextArea textArea ) { | |
| 134 | textArea.setWrapText( true ); | |
| 135 | textArea.requestFollowCaret(); | |
| 136 | textArea.moveTo( 0 ); | |
| 137 | ||
| 138 | textArea.textProperty().addListener( ( c, o, n ) -> { | |
| 139 | // Fire, regardless of whether the caret position has changed. | |
| 140 | mDirty.set( false ); | |
| 141 | ||
| 142 | // Prevent the subsequent caret position change from raising dirty bits. | |
| 143 | mDirty.set( true ); | |
| 144 | } ); | |
| 145 | ||
| 146 | textArea.caretPositionProperty().addListener( ( c, o, n ) -> { | |
| 147 | // Fire when the caret position has changed and the text has not. | |
| 148 | mDirty.set( true ); | |
| 149 | mDirty.set( false ); | |
| 150 | } ); | |
| 151 | ||
| 152 | textArea.focusedProperty().addListener( ( c, o, n ) -> { | |
| 153 | if( n != null && n ) { | |
| 154 | TextEditorFocusEvent.fire( this ); | |
| 155 | } | |
| 156 | } ); | |
| 157 | } | |
| 158 | ||
| 159 | private void initStyle( final StyleClassedTextArea textArea ) { | |
| 160 | textArea.getStyleClass().add( "markdown" ); | |
| 161 | ||
| 162 | final var stylesheets = textArea.getStylesheets(); | |
| 163 | stylesheets.add( getStylesheetPath( getLocale() ) ); | |
| 164 | ||
| 165 | localeProperty().addListener( ( c, o, n ) -> { | |
| 166 | if( n != null ) { | |
| 167 | stylesheets.clear(); | |
| 168 | stylesheets.add( getStylesheetPath( getLocale() ) ); | |
| 169 | } | |
| 170 | } ); | |
| 171 | ||
| 172 | fontNameProperty().addListener( | |
| 173 | ( c, o, n ) -> | |
| 174 | setFont( mTextArea, getFontName(), getFontSize() ) | |
| 175 | ); | |
| 176 | ||
| 177 | fontSizeProperty().addListener( | |
| 178 | ( c, o, n ) -> | |
| 179 | setFont( mTextArea, getFontName(), getFontSize() ) | |
| 180 | ); | |
| 181 | ||
| 182 | setFont( mTextArea, getFontName(), getFontSize() ); | |
| 183 | } | |
| 184 | ||
| 185 | private void initScrollPane( | |
| 186 | final VirtualizedScrollPane<StyleClassedTextArea> scrollpane ) { | |
| 187 | scrollpane.setVbarPolicy( ALWAYS ); | |
| 188 | setCenter( scrollpane ); | |
| 189 | } | |
| 190 | ||
| 191 | private void initSpellchecker( final StyleClassedTextArea textarea ) { | |
| 192 | mSpeller.checkDocument( textarea ); | |
| 193 | mSpeller.checkParagraphs( textarea ); | |
| 194 | } | |
| 195 | ||
| 196 | private void initHotKeys() { | |
| 197 | addEventListener( keyPressed( ENTER ), this::onEnterPressed ); | |
| 198 | addEventListener( keyPressed( X, CONTROL_DOWN ), this::cut ); | |
| 199 | addEventListener( keyPressed( TAB ), this::tab ); | |
| 200 | addEventListener( keyPressed( TAB, SHIFT_DOWN ), this::untab ); | |
| 201 | addEventListener( keyPressed( ENTER, ALT_DOWN ), this::autofix ); | |
| 202 | } | |
| 203 | ||
| 204 | private void initUndoManager() { | |
| 205 | final var undoManager = getUndoManager(); | |
| 206 | final var markedPosition = undoManager.atMarkedPositionProperty(); | |
| 207 | ||
| 208 | undoManager.forgetHistory(); | |
| 209 | undoManager.mark(); | |
| 210 | mModified.bind( Bindings.not( markedPosition ) ); | |
| 211 | } | |
| 212 | ||
| 213 | @Override | |
| 214 | public void moveTo( final int offset ) { | |
| 215 | assert 0 <= offset && offset <= mTextArea.getLength(); | |
| 216 | ||
| 217 | mTextArea.moveTo( offset ); | |
| 218 | mTextArea.requestFollowCaret(); | |
| 219 | } | |
| 220 | ||
| 221 | /** | |
| 222 | * Delegate the focus request to the text area itself. | |
| 223 | */ | |
| 224 | @Override | |
| 225 | public void requestFocus() { | |
| 226 | mTextArea.requestFocus(); | |
| 227 | } | |
| 228 | ||
| 229 | @Override | |
| 230 | public void setText( final String text ) { | |
| 231 | mTextArea.clear(); | |
| 232 | mTextArea.appendText( text ); | |
| 233 | mTextArea.getUndoManager().mark(); | |
| 234 | } | |
| 235 | ||
| 236 | @Override | |
| 237 | public String getText() { | |
| 238 | return mTextArea.getText(); | |
| 239 | } | |
| 240 | ||
| 241 | @Override | |
| 242 | public Charset getEncoding() { | |
| 243 | return mEncoding; | |
| 244 | } | |
| 245 | ||
| 246 | @Override | |
| 247 | public File getFile() { | |
| 248 | return mFile; | |
| 249 | } | |
| 250 | ||
| 251 | @Override | |
| 252 | public void rename( final File file ) { | |
| 253 | mFile = file; | |
| 254 | } | |
| 255 | ||
| 256 | @Override | |
| 257 | public void undo() { | |
| 258 | final var manager = getUndoManager(); | |
| 259 | xxdo( manager::isUndoAvailable, manager::undo, "Main.status.error.undo" ); | |
| 260 | } | |
| 261 | ||
| 262 | @Override | |
| 263 | public void redo() { | |
| 264 | final var manager = getUndoManager(); | |
| 265 | xxdo( manager::isRedoAvailable, manager::redo, "Main.status.error.redo" ); | |
| 266 | } | |
| 267 | ||
| 268 | /** | |
| 269 | * Performs an undo or redo action, if possible, otherwise displays an error | |
| 270 | * message to the user. | |
| 271 | * | |
| 272 | * @param ready Answers whether the action can be executed. | |
| 273 | * @param action The action to execute. | |
| 274 | * @param key The informational message key having a value to display if | |
| 275 | * the {@link Supplier} is not ready. | |
| 276 | */ | |
| 277 | private void xxdo( | |
| 278 | final Supplier<Boolean> ready, final Runnable action, final String key ) { | |
| 279 | if( ready.get() ) { | |
| 280 | action.run(); | |
| 281 | } | |
| 282 | else { | |
| 283 | clue( key ); | |
| 284 | } | |
| 285 | } | |
| 286 | ||
| 287 | @Override | |
| 288 | public void cut() { | |
| 289 | final var selected = mTextArea.getSelectedText(); | |
| 290 | ||
| 291 | // Emulate selecting the current line by firing Home then Shift+Down Arrow. | |
| 292 | if( selected == null || selected.isEmpty() ) { | |
| 293 | // Note: mTextArea.selectLine() does not select empty lines. | |
| 294 | mTextArea.fireEvent( keyDown( HOME, false ) ); | |
| 295 | mTextArea.fireEvent( keyDown( DOWN, true ) ); | |
| 296 | } | |
| 297 | ||
| 298 | mTextArea.cut(); | |
| 299 | } | |
| 300 | ||
| 301 | @Override | |
| 302 | public void copy() { | |
| 303 | mTextArea.copy(); | |
| 304 | } | |
| 305 | ||
| 306 | @Override | |
| 307 | public void paste() { | |
| 308 | mTextArea.paste(); | |
| 309 | } | |
| 310 | ||
| 311 | @Override | |
| 312 | public void selectAll() { | |
| 313 | mTextArea.selectAll(); | |
| 314 | } | |
| 315 | ||
| 316 | @Override | |
| 317 | public void bold() { | |
| 318 | enwrap( "**" ); | |
| 319 | } | |
| 320 | ||
| 321 | @Override | |
| 322 | public void italic() { | |
| 323 | enwrap( "*" ); | |
| 324 | } | |
| 325 | ||
| 326 | @Override | |
| 327 | public void monospace() { | |
| 328 | enwrap( "`" ); | |
| 329 | } | |
| 330 | ||
| 331 | @Override | |
| 332 | public void superscript() { | |
| 333 | enwrap( "^" ); | |
| 334 | } | |
| 335 | ||
| 336 | @Override | |
| 337 | public void subscript() { | |
| 338 | enwrap( "~" ); | |
| 339 | } | |
| 340 | ||
| 341 | @Override | |
| 342 | public void strikethrough() { | |
| 343 | enwrap( "~~" ); | |
| 344 | } | |
| 345 | ||
| 346 | @Override | |
| 347 | public void blockquote() { | |
| 348 | block( "> " ); | |
| 349 | } | |
| 350 | ||
| 351 | @Override | |
| 352 | public void code() { | |
| 353 | enwrap( "`" ); | |
| 354 | } | |
| 355 | ||
| 356 | @Override | |
| 357 | public void fencedCodeBlock() { | |
| 358 | enwrap( "\n\n```\n", "\n```\n\n" ); | |
| 359 | } | |
| 360 | ||
| 361 | @Override | |
| 362 | public void heading( final int level ) { | |
| 363 | final var hashes = new String( new char[ level ] ).replace( "\0", "#" ); | |
| 364 | block( format( "%s ", hashes ) ); | |
| 365 | } | |
| 366 | ||
| 367 | @Override | |
| 368 | public void unorderedList() { | |
| 369 | block( "* " ); | |
| 370 | } | |
| 371 | ||
| 372 | @Override | |
| 373 | public void orderedList() { | |
| 374 | block( "1. " ); | |
| 375 | } | |
| 376 | ||
| 377 | @Override | |
| 378 | public void horizontalRule() { | |
| 379 | block( format( "---%n%n" ) ); | |
| 380 | } | |
| 381 | ||
| 382 | @Override | |
| 383 | public Node getNode() { | |
| 384 | return this; | |
| 385 | } | |
| 386 | ||
| 387 | @Override | |
| 388 | public ReadOnlyBooleanProperty modifiedProperty() { | |
| 389 | return mModified; | |
| 390 | } | |
| 391 | ||
| 392 | @Override | |
| 393 | public void clearModifiedProperty() { | |
| 394 | getUndoManager().mark(); | |
| 395 | } | |
| 396 | ||
| 397 | @Override | |
| 398 | public VirtualizedScrollPane<StyleClassedTextArea> getScrollPane() { | |
| 399 | return mScrollPane; | |
| 400 | } | |
| 401 | ||
| 402 | @Override | |
| 403 | public StyleClassedTextArea getTextArea() { | |
| 404 | return mTextArea; | |
| 405 | } | |
| 406 | ||
| 407 | private final Map<String, IndexRange> mStyles = new HashMap<>(); | |
| 408 | ||
| 409 | @Override | |
| 410 | public void stylize( final IndexRange range, final String style ) { | |
| 411 | final var began = range.getStart(); | |
| 412 | final var ended = range.getEnd() + 1; | |
| 413 | ||
| 414 | assert 0 <= began && began <= ended; | |
| 415 | assert style != null; | |
| 416 | ||
| 417 | // TODO: Ensure spell check and find highlights can coexist. | |
| 418 | // final var spans = mTextArea.getStyleSpans( range ); | |
| 419 | // System.out.println( "SPANS: " + spans ); | |
| 420 | ||
| 421 | // final var spans = mTextArea.getStyleSpans( range ); | |
| 422 | // mTextArea.setStyleSpans( began, merge( spans, range.getLength(), style | |
| 423 | // ) ); | |
| 424 | ||
| 425 | // final var builder = new StyleSpansBuilder<Collection<String>>(); | |
| 426 | // builder.add( singleton( style ), range.getLength() + 1 ); | |
| 427 | // mTextArea.setStyleSpans( began, builder.create() ); | |
| 428 | ||
| 429 | // final var s = mTextArea.getStyleSpans( began, ended ); | |
| 430 | // System.out.println( "STYLES: " +s ); | |
| 431 | ||
| 432 | mStyles.put( style, range ); | |
| 433 | mTextArea.setStyleClass( began, ended, style ); | |
| 434 | ||
| 435 | // Ensure that whenever the user interacts with the text that the found | |
| 436 | // word will have its highlighting removed. The handler removes itself. | |
| 437 | // This won't remove the highlighting if the caret position moves by mouse. | |
| 438 | final var handler = mTextArea.getOnKeyPressed(); | |
| 439 | mTextArea.setOnKeyPressed( ( event ) -> { | |
| 440 | mTextArea.setOnKeyPressed( handler ); | |
| 441 | unstylize( style ); | |
| 442 | } ); | |
| 443 | ||
| 444 | //mTextArea.setStyleSpans(began, ended, s); | |
| 445 | } | |
| 446 | ||
| 447 | private static StyleSpans<Collection<String>> merge( | |
| 448 | StyleSpans<Collection<String>> spans, int len, String style ) { | |
| 449 | spans = spans.overlay( | |
| 450 | singleton( singletonList( style ), len ), | |
| 451 | ( bottomSpan, list ) -> { | |
| 452 | final List<String> l = | |
| 453 | new ArrayList<>( bottomSpan.size() + list.size() ); | |
| 454 | l.addAll( bottomSpan ); | |
| 455 | l.addAll( list ); | |
| 456 | return l; | |
| 457 | } ); | |
| 458 | ||
| 459 | return spans; | |
| 460 | } | |
| 461 | ||
| 462 | @Override | |
| 463 | public void unstylize( final String style ) { | |
| 464 | final var indexes = mStyles.remove( style ); | |
| 465 | if( indexes != null ) { | |
| 466 | mTextArea.clearStyle( indexes.getStart(), indexes.getEnd() + 1 ); | |
| 467 | } | |
| 468 | } | |
| 469 | ||
| 470 | @Override | |
| 471 | public Caret getCaret() { | |
| 472 | return mCaret; | |
| 473 | } | |
| 474 | ||
| 475 | /** | |
| 476 | * A {@link Caret} instance is not directly coupled ot the GUI because | |
| 477 | * document processing does not always require interactive status bar | |
| 478 | * updates. This can happen when processing from the command-line. However, | |
| 479 | * the processors need the {@link Caret} instance to inject the caret | |
| 480 | * position into the document. Making the {@link CaretExtension} optional | |
| 481 | * would require more effort than using a {@link Caret} model that is | |
| 482 | * decoupled from GUI widgets. | |
| 483 | * | |
| 484 | * @param editor The text editor containing caret position information. | |
| 485 | * @return An instance of {@link Caret} that tracks the GUI caret position. | |
| 486 | */ | |
| 487 | private Caret createCaret( final StyleClassedTextArea editor ) { | |
| 488 | return Caret | |
| 489 | .builder() | |
| 490 | .with( Caret.Mutator::setParagraph, | |
| 491 | () -> editor.currentParagraphProperty().getValue() ) | |
| 492 | .with( Caret.Mutator::setParagraphs, | |
| 493 | () -> editor.getParagraphs().size() ) | |
| 494 | .with( Caret.Mutator::setParaOffset, | |
| 495 | () -> editor.caretColumnProperty().getValue() ) | |
| 496 | .with( Caret.Mutator::setTextOffset, | |
| 497 | () -> editor.caretPositionProperty().getValue() ) | |
| 498 | .with( Caret.Mutator::setTextLength, | |
| 499 | () -> editor.lengthProperty().getValue() ) | |
| 478 | 500 | .build(); |
| 479 | 501 | } |
| 1 | /* Copyright 2022 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.events; | |
| 3 | ||
| 4 | import com.keenwrite.editors.common.Caret; | |
| 5 | ||
| 6 | /** | |
| 7 | * Responsible for notifying when the caret has moved, which includes giving | |
| 8 | * focus to a different editor. | |
| 9 | */ | |
| 10 | public class CaretMovedEvent implements AppEvent { | |
| 11 | private final Caret mCaret; | |
| 12 | ||
| 13 | private CaretMovedEvent( final Caret caret ) { | |
| 14 | assert caret != null; | |
| 15 | mCaret = caret; | |
| 16 | } | |
| 17 | ||
| 18 | public static void fire( final Caret caret ) { | |
| 19 | new CaretMovedEvent( caret ).publish(); | |
| 20 | } | |
| 21 | ||
| 22 | public Caret getCaret() { | |
| 23 | return mCaret; | |
| 24 | } | |
| 25 | } | |
| 1 | 26 |
| 6 | 6 | /** |
| 7 | 7 | * Collates information about a caret event, which is typically triggered when |
| 8 | * the user double-clicks in the {@link DocumentOutline}. | |
| 8 | * the user double-clicks in the {@link DocumentOutline}. This is an imperative | |
| 9 | * event, meaning that the position of the caret will be changed after this | |
| 10 | * event is handled. As opposed to a {@link CaretMovedEvent}, which provides | |
| 11 | * information about the caret after it has been moved. | |
| 9 | 12 | */ |
| 10 | 13 | public class CaretNavigationEvent implements AppEvent { |
| 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 | } | |
| 56 | 1 |
| 3 | 3 | |
| 4 | 4 | import com.dlsc.preferencesfx.formsfx.view.controls.SimpleControl; |
| 5 | import com.keenwrite.ui.table.AltTableCell; | |
| 5 | import com.keenwrite.ui.cells.AltTableCell; | |
| 6 | 6 | import javafx.beans.property.SimpleObjectProperty; |
| 7 | 7 | import javafx.event.ActionEvent; |
| 2 | 2 | package com.keenwrite.preferences; |
| 3 | 3 | |
| 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 ) ); | |
| 4 | import javafx.application.Platform; | |
| 5 | import javafx.beans.property.*; | |
| 6 | import javafx.collections.ObservableList; | |
| 7 | ||
| 8 | import java.io.File; | |
| 9 | import java.nio.file.Path; | |
| 10 | import java.util.*; | |
| 11 | import java.util.Map.Entry; | |
| 12 | import java.util.function.BooleanSupplier; | |
| 13 | import java.util.function.Function; | |
| 14 | ||
| 15 | import static com.keenwrite.Bootstrap.APP_TITLE_LOWERCASE; | |
| 16 | import static com.keenwrite.Launcher.getVersion; | |
| 17 | import static com.keenwrite.constants.Constants.*; | |
| 18 | import static com.keenwrite.events.StatusEvent.clue; | |
| 19 | import static com.keenwrite.preferences.AppKeys.*; | |
| 20 | import static java.util.Map.entry; | |
| 21 | import static javafx.application.Platform.runLater; | |
| 22 | import static javafx.collections.FXCollections.observableArrayList; | |
| 23 | import static javafx.collections.FXCollections.observableSet; | |
| 24 | ||
| 25 | /** | |
| 26 | * Responsible for defining behaviours for separate projects. A workspace has | |
| 27 | * the ability to save and restore a session, including the window dimensions, | |
| 28 | * tab setup, files, and user preferences. | |
| 29 | * <p> | |
| 30 | * The configuration must support hierarchical (nested) configuration nodes | |
| 31 | * to persist the user interface state. Although possible with a flat | |
| 32 | * configuration file, it's not nearly as simple or elegant. | |
| 33 | * </p> | |
| 34 | * <p> | |
| 35 | * Neither JSON nor HOCON support schema validation and versioning, which makes | |
| 36 | * XML the more suitable configuration file format. Schema validation and | |
| 37 | * versioning provide future-proofing and ease of reading and upgrading previous | |
| 38 | * versions of the configuration file. | |
| 39 | * </p> | |
| 40 | * <p> | |
| 41 | * Persistent preferences may be set directly by the user or indirectly by | |
| 42 | * the act of using the application. | |
| 43 | * </p> | |
| 44 | * <p> | |
| 45 | * Note the following definitions: | |
| 46 | * </p> | |
| 47 | * <dl> | |
| 48 | * <dt>File</dt> | |
| 49 | * <dd>References a file name (no path), path, or directory.</dd> | |
| 50 | * <dt>Path</dt> | |
| 51 | * <dd>Fully qualified file name, which includes all parent directories.</dd> | |
| 52 | * <dt>Dir</dt> | |
| 53 | * <dd>Directory without file name ({@link File#isDirectory()} is true).</dd> | |
| 54 | * </dl> | |
| 55 | */ | |
| 56 | public final class Workspace { | |
| 57 | ||
| 58 | /** | |
| 59 | * Main configuration values, single text strings. | |
| 60 | */ | |
| 61 | private final Map<Key, Property<?>> mValues = Map.ofEntries( | |
| 62 | entry( KEY_META_VERSION, asStringProperty( getVersion() ) ), | |
| 63 | entry( KEY_META_NAME, asStringProperty( "default" ) ), | |
| 64 | ||
| 65 | entry( KEY_EDITOR_AUTOSAVE, asIntegerProperty( 30 ) ), | |
| 66 | ||
| 67 | entry( KEY_R_SCRIPT, asStringProperty( "" ) ), | |
| 68 | entry( KEY_R_DIR, asFileProperty( USER_DIRECTORY ) ), | |
| 69 | entry( KEY_R_DELIM_BEGAN, asStringProperty( R_DELIM_BEGAN_DEFAULT ) ), | |
| 70 | entry( KEY_R_DELIM_ENDED, asStringProperty( R_DELIM_ENDED_DEFAULT ) ), | |
| 71 | ||
| 72 | entry( KEY_IMAGES_DIR, asFileProperty( USER_DIRECTORY ) ), | |
| 73 | entry( KEY_IMAGES_ORDER, asStringProperty( PERSIST_IMAGES_DEFAULT ) ), | |
| 74 | entry( KEY_IMAGES_RESIZE, asBooleanProperty( true ) ), | |
| 75 | entry( KEY_IMAGES_SERVER, asStringProperty( DIAGRAM_SERVER_NAME ) ), | |
| 76 | ||
| 77 | entry( KEY_DEF_PATH, asFileProperty( DEFINITION_DEFAULT ) ), | |
| 78 | entry( KEY_DEF_DELIM_BEGAN, asStringProperty( DEF_DELIM_BEGAN_DEFAULT ) ), | |
| 79 | entry( KEY_DEF_DELIM_ENDED, asStringProperty( DEF_DELIM_ENDED_DEFAULT ) ), | |
| 80 | ||
| 81 | entry( KEY_UI_RECENT_DIR, asFileProperty( USER_DIRECTORY ) ), | |
| 82 | entry( KEY_UI_RECENT_DOCUMENT, asFileProperty( DOCUMENT_DEFAULT ) ), | |
| 83 | entry( KEY_UI_RECENT_DEFINITION, asFileProperty( DEFINITION_DEFAULT ) ), | |
| 84 | entry( KEY_UI_RECENT_EXPORT, asFileProperty( PDF_DEFAULT ) ), | |
| 85 | ||
| 86 | //@formatter:off | |
| 87 | entry( KEY_UI_FONT_EDITOR_NAME, asStringProperty( FONT_NAME_EDITOR_DEFAULT ) ), | |
| 88 | entry( KEY_UI_FONT_EDITOR_SIZE, asDoubleProperty( FONT_SIZE_EDITOR_DEFAULT ) ), | |
| 89 | entry( KEY_UI_FONT_PREVIEW_NAME, asStringProperty( FONT_NAME_PREVIEW_DEFAULT ) ), | |
| 90 | entry( KEY_UI_FONT_PREVIEW_SIZE, asDoubleProperty( FONT_SIZE_PREVIEW_DEFAULT ) ), | |
| 91 | entry( KEY_UI_FONT_PREVIEW_MONO_NAME, asStringProperty( FONT_NAME_PREVIEW_MONO_NAME_DEFAULT ) ), | |
| 92 | entry( KEY_UI_FONT_PREVIEW_MONO_SIZE, asDoubleProperty( FONT_SIZE_PREVIEW_MONO_SIZE_DEFAULT ) ), | |
| 93 | ||
| 94 | entry( KEY_UI_WINDOW_X, asDoubleProperty( WINDOW_X_DEFAULT ) ), | |
| 95 | entry( KEY_UI_WINDOW_Y, asDoubleProperty( WINDOW_Y_DEFAULT ) ), | |
| 96 | entry( KEY_UI_WINDOW_W, asDoubleProperty( WINDOW_W_DEFAULT ) ), | |
| 97 | entry( KEY_UI_WINDOW_H, asDoubleProperty( WINDOW_H_DEFAULT ) ), | |
| 98 | entry( KEY_UI_WINDOW_MAX, asBooleanProperty() ), | |
| 99 | entry( KEY_UI_WINDOW_FULL, asBooleanProperty() ), | |
| 100 | ||
| 101 | entry( KEY_UI_SKIN_SELECTION, asSkinProperty( SKIN_DEFAULT ) ), | |
| 102 | entry( KEY_UI_SKIN_CUSTOM, asFileProperty( SKIN_CUSTOM_DEFAULT ) ), | |
| 103 | ||
| 104 | entry( KEY_UI_PREVIEW_STYLESHEET, asFileProperty( PREVIEW_CUSTOM_DEFAULT ) ), | |
| 105 | ||
| 106 | entry( KEY_LANGUAGE_LOCALE, asLocaleProperty( LOCALE_DEFAULT ) ), | |
| 107 | ||
| 108 | entry( KEY_TYPESET_CONTEXT_CLEAN, asBooleanProperty( true ) ), | |
| 109 | entry( KEY_TYPESET_CONTEXT_THEMES_PATH, asFileProperty( USER_DIRECTORY ) ), | |
| 110 | entry( KEY_TYPESET_CONTEXT_THEME_SELECTION, asStringProperty( "boschet" ) ), | |
| 111 | entry( KEY_TYPESET_TYPOGRAPHY_QUOTES, asBooleanProperty( true ) ) | |
| 112 | //@formatter:on | |
| 113 | ); | |
| 114 | ||
| 115 | /** | |
| 116 | * Sets of configuration values, all the same type (e.g., file names), | |
| 117 | * where the key name doesn't change per set. | |
| 118 | */ | |
| 119 | private final Map<Key, SetProperty<?>> mSets = Map.ofEntries( | |
| 120 | entry( | |
| 121 | KEY_UI_RECENT_OPEN_PATH, | |
| 122 | createSetProperty( new HashSet<String>() ) | |
| 123 | ) | |
| 124 | ); | |
| 125 | ||
| 126 | /** | |
| 127 | * Lists of configuration values, such as key-value pairs where both the | |
| 128 | * key name and the value must be preserved per list. | |
| 129 | */ | |
| 130 | private final Map<Key, ListProperty<?>> mLists = Map.ofEntries( | |
| 131 | entry( | |
| 132 | KEY_DOC_META, | |
| 133 | createListProperty( new LinkedList<Entry<String, String>>() ) | |
| 134 | ) | |
| 135 | ); | |
| 136 | ||
| 137 | /** | |
| 138 | * Helps instantiate {@link Property} instances for XML configuration items. | |
| 139 | */ | |
| 140 | private static final Map<Class<?>, Function<String, Object>> UNMARSHALL = | |
| 141 | Map.of( | |
| 142 | LocaleProperty.class, LocaleProperty::parseLocale, | |
| 143 | SimpleBooleanProperty.class, Boolean::parseBoolean, | |
| 144 | SimpleIntegerProperty.class, Integer::parseInt, | |
| 145 | SimpleDoubleProperty.class, Double::parseDouble, | |
| 146 | SimpleFloatProperty.class, Float::parseFloat, | |
| 147 | SimpleStringProperty.class, String::new, | |
| 148 | SimpleObjectProperty.class, String::new, | |
| 149 | SkinProperty.class, String::new, | |
| 150 | FileProperty.class, File::new | |
| 151 | ); | |
| 152 | ||
| 153 | /** | |
| 154 | * The asymmetry with respect to {@link #UNMARSHALL} is because most objects | |
| 155 | * can simply call {@link Object#toString()} to convert the value to a string. | |
| 156 | */ | |
| 157 | private static final Map<Class<?>, Function<String, Object>> MARSHALL = | |
| 158 | Map.of( | |
| 159 | LocaleProperty.class, LocaleProperty::toLanguageTag | |
| 160 | ); | |
| 161 | ||
| 162 | /** | |
| 163 | * Converts the given {@link Property} value to a string. | |
| 164 | * | |
| 165 | * @param property The {@link Property} to convert. | |
| 166 | * @return A string representation of the given property, or the empty | |
| 167 | * string if no conversion was possible. | |
| 168 | */ | |
| 169 | private static String marshall( final Property<?> property ) { | |
| 170 | final var v = property.getValue(); | |
| 171 | ||
| 172 | return v == null | |
| 173 | ? "" | |
| 174 | : MARSHALL | |
| 175 | .getOrDefault( property.getClass(), __ -> property.getValue() ) | |
| 176 | .apply( v.toString() ) | |
| 177 | .toString(); | |
| 178 | } | |
| 179 | ||
| 180 | private static Object unmarshall( | |
| 181 | final Property<?> property, final Object configValue ) { | |
| 182 | final var v = configValue.toString(); | |
| 183 | ||
| 184 | return UNMARSHALL | |
| 185 | .getOrDefault( property.getClass(), value -> property.getValue() ) | |
| 186 | .apply( v ); | |
| 187 | } | |
| 188 | ||
| 189 | /** | |
| 190 | * Creates an instance of {@link ObservableList} that is based on a | |
| 191 | * modifiable observable array list for the given items. | |
| 192 | * | |
| 193 | * @param items The items to wrap in an observable list. | |
| 194 | * @param <E> The type of items to add to the list. | |
| 195 | * @return An observable property that can have its contents modified. | |
| 196 | */ | |
| 197 | public static <E> ObservableList<E> listProperty( final Set<E> items ) { | |
| 198 | return new SimpleListProperty<>( observableArrayList( items ) ); | |
| 199 | } | |
| 200 | ||
| 201 | private static <E> SetProperty<E> createSetProperty( final Set<E> set ) { | |
| 202 | return new SimpleSetProperty<>( observableSet( set ) ); | |
| 203 | } | |
| 204 | ||
| 205 | private static <E> ListProperty<E> createListProperty( final List<E> list ) { | |
| 206 | return new SimpleListProperty<>( observableArrayList( list ) ); | |
| 207 | } | |
| 208 | ||
| 209 | private static StringProperty asStringProperty( final String value ) { | |
| 210 | return new SimpleStringProperty( value ); | |
| 211 | } | |
| 212 | ||
| 213 | private static BooleanProperty asBooleanProperty() { | |
| 214 | return new SimpleBooleanProperty(); | |
| 215 | } | |
| 216 | ||
| 217 | /** | |
| 218 | * @param value Default value. | |
| 219 | */ | |
| 220 | @SuppressWarnings( "SameParameterValue" ) | |
| 221 | private static BooleanProperty asBooleanProperty( final boolean value ) { | |
| 222 | return new SimpleBooleanProperty( value ); | |
| 223 | } | |
| 224 | ||
| 225 | /** | |
| 226 | * @param value Default value. | |
| 227 | */ | |
| 228 | @SuppressWarnings( "SameParameterValue" ) | |
| 229 | private static IntegerProperty asIntegerProperty( final int value ) { | |
| 230 | return new SimpleIntegerProperty( value ); | |
| 231 | } | |
| 232 | ||
| 233 | /** | |
| 234 | * @param value Default value. | |
| 235 | */ | |
| 236 | private static DoubleProperty asDoubleProperty( final double value ) { | |
| 237 | return new SimpleDoubleProperty( value ); | |
| 238 | } | |
| 239 | ||
| 240 | /** | |
| 241 | * @param value Default value. | |
| 242 | */ | |
| 243 | private static FileProperty asFileProperty( final File value ) { | |
| 244 | return new FileProperty( value ); | |
| 245 | } | |
| 246 | ||
| 247 | /** | |
| 248 | * @param value Default value. | |
| 249 | */ | |
| 250 | @SuppressWarnings( "SameParameterValue" ) | |
| 251 | private static LocaleProperty asLocaleProperty( final Locale value ) { | |
| 252 | return new LocaleProperty( value ); | |
| 253 | } | |
| 254 | ||
| 255 | /** | |
| 256 | * @param value Default value. | |
| 257 | */ | |
| 258 | @SuppressWarnings( "SameParameterValue" ) | |
| 259 | private static SkinProperty asSkinProperty( final String value ) { | |
| 260 | return new SkinProperty( value ); | |
| 261 | } | |
| 262 | ||
| 263 | /** | |
| 264 | * Creates a new {@link Workspace} that will attempt to load the users' | |
| 265 | * preferences. If the configuration file cannot be loaded, the workspace | |
| 266 | * settings returns default values. | |
| 267 | */ | |
| 268 | public Workspace() { | |
| 269 | load(); | |
| 270 | } | |
| 271 | ||
| 272 | /** | |
| 273 | * Attempts to load the app's configuration file. | |
| 274 | */ | |
| 275 | private void load() { | |
| 276 | final var store = createXmlStore(); | |
| 277 | store.load( FILE_PREFERENCES ); | |
| 278 | ||
| 279 | mValues.keySet().forEach( key -> { | |
| 280 | try { | |
| 281 | final var storeValue = store.getValue( key ); | |
| 282 | final var property = valuesProperty( key ); | |
| 283 | ||
| 284 | property.setValue( unmarshall( property, storeValue ) ); | |
| 285 | } catch( final NoSuchElementException ignored ) { | |
| 286 | // When no configuration (item), use the default value. | |
| 287 | } | |
| 288 | } ); | |
| 289 | ||
| 290 | mSets.keySet().forEach( key -> { | |
| 291 | final var set = store.getSet( key ); | |
| 292 | final SetProperty<String> property = setsProperty( key ); | |
| 293 | ||
| 294 | property.setValue( observableSet( set ) ); | |
| 295 | } ); | |
| 296 | ||
| 297 | mLists.keySet().forEach( key -> { | |
| 298 | final var map = store.getMap( key ); | |
| 299 | final ListProperty<Entry<String, String>> property = listsProperty( key ); | |
| 300 | final var list = map | |
| 301 | .entrySet() | |
| 302 | .stream() | |
| 303 | .toList(); | |
| 304 | ||
| 305 | property.setValue( observableArrayList( list ) ); | |
| 306 | } ); | |
| 307 | } | |
| 308 | ||
| 309 | /** | |
| 310 | * Saves the current workspace. | |
| 311 | */ | |
| 312 | public void save() { | |
| 313 | final var store = createXmlStore(); | |
| 314 | ||
| 315 | try { | |
| 316 | // Update the string values to include the application version. | |
| 317 | valuesProperty( KEY_META_VERSION ).setValue( getVersion() ); | |
| 318 | ||
| 319 | mValues.forEach( ( k, v ) -> store.setValue( k, marshall( v ) ) ); | |
| 320 | mSets.forEach( store::setSet ); | |
| 321 | mLists.forEach( store::setMap ); | |
| 322 | ||
| 323 | store.save( FILE_PREFERENCES ); | |
| 324 | } catch( final Exception ex ) { | |
| 325 | clue( ex ); | |
| 326 | } | |
| 327 | } | |
| 328 | ||
| 329 | /** | |
| 330 | * Returns a value that represents a setting in the application that the user | |
| 331 | * may configure, either directly or indirectly. | |
| 332 | * | |
| 333 | * @param key The reference to the users' preference stored in deference | |
| 334 | * of app reëntrance. | |
| 335 | * @return An observable property to be persisted. | |
| 336 | */ | |
| 337 | @SuppressWarnings( "unchecked" ) | |
| 338 | public <T, U extends Property<T>> U valuesProperty( final Key key ) { | |
| 339 | assert key != null; | |
| 340 | return (U) mValues.get( key ); | |
| 341 | } | |
| 342 | ||
| 343 | /** | |
| 344 | * Returns a set of values that represent a setting in the application that | |
| 345 | * the user may configure, either directly or indirectly. The property | |
| 346 | * returned is backed by a {@link Set}. | |
| 347 | * | |
| 348 | * @param key The {@link Key} associated with a preference value. | |
| 349 | * @return An observable property to be persisted. | |
| 350 | */ | |
| 351 | @SuppressWarnings( "unchecked" ) | |
| 352 | public <T> SetProperty<T> setsProperty( final Key key ) { | |
| 353 | assert key != null; | |
| 354 | return (SetProperty<T>) mSets.get( key ); | |
| 355 | } | |
| 356 | ||
| 357 | /** | |
| 358 | * Returns a list of values that represent a setting in the application that | |
| 359 | * the user may configure, either directly or indirectly. The property | |
| 360 | * returned is backed by a mutable {@link List}. | |
| 361 | * | |
| 362 | * @param key The {@link Key} associated with a preference value. | |
| 363 | * @return An observable property to be persisted. | |
| 364 | */ | |
| 365 | @SuppressWarnings( "unchecked" ) | |
| 366 | public <K, V> ListProperty<Entry<K, V>> listsProperty( final Key key ) { | |
| 367 | assert key != null; | |
| 368 | return (ListProperty<Entry<K, V>>) mLists.get( key ); | |
| 369 | } | |
| 370 | ||
| 371 | /** | |
| 372 | * Returns the {@link String} {@link Property} associated with the given | |
| 373 | * {@link Key} from the internal list of preference values. The caller | |
| 374 | * must be sure that the given {@link Key} is associated with a {@link File} | |
| 375 | * {@link Property}. | |
| 376 | * | |
| 377 | * @param key The {@link Key} associated with a preference value. | |
| 378 | * @return The value associated with the given {@link Key}. | |
| 379 | */ | |
| 380 | public StringProperty stringProperty( final Key key ) { | |
| 381 | assert key != null; | |
| 382 | return valuesProperty( key ); | |
| 383 | } | |
| 384 | ||
| 385 | /** | |
| 386 | * Returns the {@link Boolean} {@link Property} associated with the given | |
| 387 | * {@link Key} from the internal list of preference values. The caller | |
| 388 | * must be sure that the given {@link Key} is associated with a {@link File} | |
| 389 | * {@link Property}. | |
| 390 | * | |
| 391 | * @param key The {@link Key} associated with a preference value. | |
| 392 | * @return The value associated with the given {@link Key}. | |
| 393 | */ | |
| 394 | public BooleanProperty booleanProperty( final Key key ) { | |
| 395 | assert key != null; | |
| 396 | return valuesProperty( key ); | |
| 397 | } | |
| 398 | ||
| 399 | /** | |
| 400 | * Returns the {@link Integer} {@link Property} associated with the given | |
| 401 | * {@link Key} from the internal list of preference values. The caller | |
| 402 | * must be sure that the given {@link Key} is associated with a {@link File} | |
| 403 | * {@link Property}. | |
| 404 | * | |
| 405 | * @param key The {@link Key} associated with a preference value. | |
| 406 | * @return The value associated with the given {@link Key}. | |
| 407 | */ | |
| 408 | public IntegerProperty integerProperty( final Key key ) { | |
| 409 | assert key != null; | |
| 410 | return valuesProperty( key ); | |
| 411 | } | |
| 412 | ||
| 413 | /** | |
| 414 | * Returns the {@link Double} {@link Property} associated with the given | |
| 415 | * {@link Key} from the internal list of preference values. The caller | |
| 416 | * must be sure that the given {@link Key} is associated with a {@link File} | |
| 417 | * {@link Property}. | |
| 418 | * | |
| 419 | * @param key The {@link Key} associated with a preference value. | |
| 420 | * @return The value associated with the given {@link Key}. | |
| 421 | */ | |
| 422 | public DoubleProperty doubleProperty( final Key key ) { | |
| 423 | assert key != null; | |
| 424 | return valuesProperty( key ); | |
| 425 | } | |
| 426 | ||
| 427 | /** | |
| 428 | * Returns the {@link File} {@link Property} associated with the given | |
| 429 | * {@link Key} from the internal list of preference values. The caller | |
| 430 | * must be sure that the given {@link Key} is associated with a {@link File} | |
| 431 | * {@link Property}. | |
| 432 | * | |
| 433 | * @param key The {@link Key} associated with a preference value. | |
| 434 | * @return The value associated with the given {@link Key}. | |
| 435 | */ | |
| 436 | public ObjectProperty<File> fileProperty( final Key key ) { | |
| 437 | assert key != null; | |
| 438 | return valuesProperty( key ); | |
| 439 | } | |
| 440 | ||
| 441 | /** | |
| 442 | * Returns the {@link Locale} {@link Property} associated with the given | |
| 443 | * {@link Key} from the internal list of preference values. The caller | |
| 444 | * must be sure that the given {@link Key} is associated with a {@link File} | |
| 445 | * {@link Property}. | |
| 446 | * | |
| 447 | * @param key The {@link Key} associated with a preference value. | |
| 448 | * @return The value associated with the given {@link Key}. | |
| 449 | */ | |
| 450 | public LocaleProperty localeProperty( final Key key ) { | |
| 451 | assert key != null; | |
| 452 | return valuesProperty( key ); | |
| 453 | } | |
| 454 | ||
| 455 | public ObjectProperty<String> skinProperty( final Key key ) { | |
| 456 | assert key != null; | |
| 457 | return valuesProperty( key ); | |
| 458 | } | |
| 459 | ||
| 460 | public String getString( final Key key ) { | |
| 461 | assert key != null; | |
| 462 | return stringProperty( key ).get(); | |
| 463 | } | |
| 464 | ||
| 465 | /** | |
| 466 | * Returns the {@link Boolean} preference value associated with the given | |
| 467 | * {@link Key}. The caller must be sure that the given {@link Key} is | |
| 468 | * associated with a value that matches the return type. | |
| 469 | * | |
| 470 | * @param key The {@link Key} associated with a preference value. | |
| 471 | * @return The value associated with the given {@link Key}. | |
| 472 | */ | |
| 473 | public boolean getBoolean( final Key key ) { | |
| 474 | assert key != null; | |
| 475 | return booleanProperty( key ).get(); | |
| 476 | } | |
| 477 | ||
| 478 | /** | |
| 479 | * Returns the {@link Integer} preference value associated with the given | |
| 480 | * {@link Key}. The caller must be sure that the given {@link Key} is | |
| 481 | * associated with a value that matches the return type. | |
| 482 | * | |
| 483 | * @param key The {@link Key} associated with a preference value. | |
| 484 | * @return The value associated with the given {@link Key}. | |
| 485 | */ | |
| 486 | public int getInteger( final Key key ) { | |
| 487 | assert key != null; | |
| 488 | return integerProperty( key ).get(); | |
| 489 | } | |
| 490 | ||
| 491 | /** | |
| 492 | * Returns the {@link Double} preference value associated with the given | |
| 493 | * {@link Key}. The caller must be sure that the given {@link Key} is | |
| 494 | * associated with a value that matches the return type. | |
| 495 | * | |
| 496 | * @param key The {@link Key} associated with a preference value. | |
| 497 | * @return The value associated with the given {@link Key}. | |
| 498 | */ | |
| 499 | public double getDouble( final Key key ) { | |
| 500 | assert key != null; | |
| 501 | return doubleProperty( key ).get(); | |
| 502 | } | |
| 503 | ||
| 504 | /** | |
| 505 | * Returns the {@link File} preference value associated with the given | |
| 506 | * {@link Key}. The caller must be sure that the given {@link Key} is | |
| 507 | * associated with a value that matches the return type. | |
| 508 | * | |
| 509 | * @param key The {@link Key} associated with a preference value. | |
| 510 | * @return The value associated with the given {@link Key}. | |
| 511 | */ | |
| 512 | public File getFile( final Key key ) { | |
| 513 | assert key != null; | |
| 514 | return fileProperty( key ).get(); | |
| 515 | } | |
| 516 | ||
| 517 | /** | |
| 518 | * Returns the language locale setting for the | |
| 519 | * {@link AppKeys#KEY_LANGUAGE_LOCALE} key. | |
| 520 | * | |
| 521 | * @return The user's current locale setting. | |
| 522 | */ | |
| 523 | public Locale getLocale() { | |
| 524 | return localeProperty( KEY_LANGUAGE_LOCALE ).toLocale(); | |
| 525 | } | |
| 526 | ||
| 527 | @SuppressWarnings( "unchecked" ) | |
| 528 | public <K, V> Map<K, V> getMetadata() { | |
| 529 | final var metadata = listsProperty( KEY_DOC_META ); | |
| 530 | final var map = new HashMap<K, V>( metadata.size() ); | |
| 531 | ||
| 532 | metadata.forEach( | |
| 533 | entry -> map.put( (K) entry.getKey(), (V) entry.getValue() ) | |
| 534 | ); | |
| 535 | ||
| 536 | return map; | |
| 537 | } | |
| 538 | ||
| 539 | public Path getThemePath() { | |
| 540 | final var dir = getFile( KEY_TYPESET_CONTEXT_THEMES_PATH ); | |
| 541 | final var name = getString( KEY_TYPESET_CONTEXT_THEME_SELECTION ); | |
| 542 | ||
| 543 | return Path.of( dir.toString(), name ); | |
| 544 | } | |
| 545 | ||
| 546 | /** | |
| 547 | * Delegates to {@link #listen(Key, ReadOnlyProperty, BooleanSupplier)}, | |
| 548 | * providing a value of {@code true} for the {@link BooleanSupplier} to | |
| 549 | * indicate the property changes always take effect. | |
| 550 | * | |
| 551 | * @param key The value to bind to the internal key property. | |
| 552 | * @param property The external property value that sets the internal value. | |
| 553 | */ | |
| 554 | public <T> void listen( final Key key, final ReadOnlyProperty<T> property ) { | |
| 555 | assert key != null; | |
| 556 | assert property != null; | |
| 557 | ||
| 558 | listen( key, property, () -> true ); | |
| 559 | } | |
| 560 | ||
| 561 | /** | |
| 562 | * Binds a read-only property to a value in the preferences. This allows | |
| 563 | * user interface properties to change and the preferences will be | |
| 564 | * synchronized automatically. | |
| 565 | * <p> | |
| 566 | * This calls {@link Platform#runLater(Runnable)} to ensure that all pending | |
| 567 | * application window states are finished before assessing whether property | |
| 568 | * changes should be applied. Without this, exiting the application while the | |
| 569 | * window is maximized would persist the window's maximum dimensions, | |
| 570 | * preventing restoration to its prior, non-maximum size. | |
| 571 | * | |
| 572 | * @param key The value to bind to the internal key property. | |
| 573 | * @param property The external property value that sets the internal value. | |
| 574 | * @param enabled Indicates whether property changes should be applied. | |
| 575 | */ | |
| 576 | public <T> void listen( | |
| 577 | final Key key, | |
| 578 | final ReadOnlyProperty<T> property, | |
| 579 | final BooleanSupplier enabled ) { | |
| 580 | assert key != null; | |
| 581 | assert property != null; | |
| 582 | assert enabled != null; | |
| 583 | ||
| 584 | property.addListener( | |
| 585 | ( c, o, n ) -> runLater( () -> { | |
| 586 | if( enabled.getAsBoolean() ) { | |
| 587 | valuesProperty( key ).setValue( n ); | |
| 588 | } | |
| 589 | } ) | |
| 590 | ); | |
| 614 | 591 | } |
| 615 | 592 |
| 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 | 11 | import static com.keenwrite.typesetting.Typesetter.Mutator; |
| 13 | 12 | import static java.nio.file.Files.deleteIfExists; |
| ... | ||
| 37 | 36 | try { |
| 38 | 37 | clue( "Main.status.typeset.create" ); |
| 39 | final var workspace = mContext.getWorkspace(); | |
| 38 | final var context = mContext; | |
| 40 | 39 | final var document = TEXT_XML.createTemporaryFile( APP_TITLE_LOWERCASE ); |
| 41 | 40 | final var typesetter = Typesetter |
| 42 | 41 | .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 ) ) | |
| 42 | .with( Mutator::setInputPath, writeString( document, xhtml ) ) | |
| 43 | .with( Mutator::setOutputPath, context.getOutputPath() ) | |
| 44 | .with( Mutator::setThemePath, context.getThemePath() ) | |
| 45 | .with( Mutator::setAutoClean, context.getAutoClean() ) | |
| 53 | 46 | .build(); |
| 54 | 47 | |
| 2 | 2 | package com.keenwrite.processors; |
| 3 | 3 | |
| 4 | import com.keenwrite.Caret; | |
| 5 | 4 | import com.keenwrite.ExportFormat; |
| 5 | import com.keenwrite.collections.InterpolatingMap; | |
| 6 | 6 | import com.keenwrite.constants.Constants; |
| 7 | import com.keenwrite.editors.common.Caret; | |
| 7 | 8 | import com.keenwrite.io.FileType; |
| 8 | import com.keenwrite.preferences.Workspace; | |
| 9 | import com.keenwrite.sigils.PropertyKeyOperator; | |
| 9 | 10 | import com.keenwrite.sigils.SigilKeyOperator; |
| 10 | 11 | import com.keenwrite.util.GenericBuilder; |
| 11 | import com.keenwrite.collections.InterpolatingMap; | |
| 12 | import org.renjin.repackaged.guava.base.Splitter; | |
| 12 | 13 | |
| 13 | 14 | import java.io.File; |
| 14 | 15 | import java.nio.file.Path; |
| 16 | import java.util.HashMap; | |
| 17 | import java.util.Locale; | |
| 15 | 18 | import java.util.Map; |
| 16 | 19 | import java.util.concurrent.Callable; |
| 17 | 20 | import java.util.function.Supplier; |
| 18 | 21 | |
| 19 | import static com.keenwrite.AbstractFileFactory.lookup; | |
| 20 | import static com.keenwrite.constants.Constants.DEFAULT_DIRECTORY; | |
| 22 | import static com.keenwrite.constants.Constants.*; | |
| 23 | import static com.keenwrite.io.FileType.UNKNOWN; | |
| 24 | import static com.keenwrite.io.MediaType.TEXT_PROPERTIES; | |
| 25 | import static com.keenwrite.io.MediaType.valueFrom; | |
| 26 | import static com.keenwrite.predicates.PredicateFactory.createFileTypePredicate; | |
| 21 | 27 | |
| 22 | 28 | /** |
| 23 | 29 | * Provides a context for configuring a chain of {@link Processor} instances. |
| 24 | 30 | */ |
| 25 | 31 | public final class ProcessorContext { |
| 26 | 32 | |
| 27 | 33 | private final Mutator mMutator; |
| 34 | ||
| 35 | /** | |
| 36 | * Determines the file type from the path extension. This should only be | |
| 37 | * called when it is known that the file type won't be a definition file | |
| 38 | * (e.g., YAML or other definition source), but rather an editable file | |
| 39 | * (e.g., Markdown, R Markdown, etc.). | |
| 40 | * | |
| 41 | * @param path The path with a file name extension. | |
| 42 | * @return The FileType for the given path. | |
| 43 | */ | |
| 44 | private static FileType lookup( final Path path ) { | |
| 45 | assert path != null; | |
| 46 | ||
| 47 | final var prefix = GLOB_PREFIX_FILE; | |
| 48 | final var keys = sSettings.getKeys( prefix ); | |
| 49 | ||
| 50 | var found = false; | |
| 51 | var fileType = UNKNOWN; | |
| 52 | ||
| 53 | while( keys.hasNext() && !found ) { | |
| 54 | final var key = keys.next(); | |
| 55 | final var patterns = sSettings.getStringSettingList( key ); | |
| 56 | final var predicate = createFileTypePredicate( patterns ); | |
| 57 | ||
| 58 | if( predicate.test( path.toFile() ) ) { | |
| 59 | // Remove the EXTENSIONS_PREFIX to get the file name extension mapped | |
| 60 | // to a standard name (as defined in the settings.properties file). | |
| 61 | final String suffix = key.replace( prefix + '.', "" ); | |
| 62 | fileType = FileType.from( suffix ); | |
| 63 | found = true; | |
| 64 | } | |
| 65 | } | |
| 66 | ||
| 67 | return fileType; | |
| 68 | } | |
| 28 | 69 | |
| 29 | 70 | /** |
| 30 | 71 | * Responsible for populating the instance variables required by the |
| 31 | 72 | * context. |
| 32 | 73 | */ |
| 33 | 74 | public static class Mutator { |
| 34 | 75 | private Path mInputPath; |
| 35 | 76 | private Path mOutputPath; |
| 36 | 77 | private ExportFormat mExportFormat; |
| 37 | private Supplier<Map<String, String>> mDefinitions; | |
| 38 | private Supplier<Caret> mCaret; | |
| 39 | private Workspace mWorkspace; | |
| 78 | private boolean mConcatenate; | |
| 79 | ||
| 80 | private Supplier<Path> mThemePath; | |
| 81 | private Supplier<Locale> mLocale = () -> Locale.ENGLISH; | |
| 82 | ||
| 83 | private Supplier<Map<String, String>> mDefinitions = HashMap::new; | |
| 84 | private Supplier<Map<String, String>> mMetadata = HashMap::new; | |
| 85 | private Supplier<Caret> mCaret = () -> Caret.builder().build(); | |
| 86 | ||
| 87 | private Supplier<Path> mImageDir; | |
| 88 | private Supplier<String> mImageServer = () -> DIAGRAM_SERVER_NAME; | |
| 89 | private Supplier<String> mImageOrder = () -> PERSIST_IMAGES_DEFAULT; | |
| 90 | ||
| 91 | private Supplier<String> mSigilBegan = () -> DEF_DELIM_BEGAN_DEFAULT; | |
| 92 | private Supplier<String> mSigilEnded = () -> DEF_DELIM_ENDED_DEFAULT; | |
| 93 | ||
| 94 | private Supplier<Path> mRWorkingDir; | |
| 95 | private Supplier<String> mRScript = () -> ""; | |
| 96 | ||
| 97 | private Supplier<Boolean> mCurlQuotes = () -> true; | |
| 98 | private Supplier<Boolean> mAutoClean = () -> true; | |
| 40 | 99 | |
| 41 | 100 | public void setInputPath( final Path inputPath ) { |
| 101 | assert inputPath != null; | |
| 42 | 102 | mInputPath = inputPath; |
| 43 | } | |
| 44 | ||
| 45 | public void setInputPath( final File inputPath ) { | |
| 46 | setInputPath( inputPath.toPath() ); | |
| 47 | 103 | } |
| 48 | 104 | |
| 49 | 105 | public void setOutputPath( final Path outputPath ) { |
| 106 | assert outputPath != null; | |
| 50 | 107 | mOutputPath = outputPath; |
| 51 | 108 | } |
| 52 | 109 | |
| 53 | 110 | public void setOutputPath( final File outputPath ) { |
| 111 | assert outputPath != null; | |
| 54 | 112 | setOutputPath( outputPath.toPath() ); |
| 113 | } | |
| 114 | ||
| 115 | public void setExportFormat( final ExportFormat exportFormat ) { | |
| 116 | assert exportFormat != null; | |
| 117 | mExportFormat = exportFormat; | |
| 118 | } | |
| 119 | ||
| 120 | public void setConcatenate( final boolean concatenate ) { | |
| 121 | mConcatenate = concatenate; | |
| 122 | } | |
| 123 | ||
| 124 | public void setLocale( final Supplier<Locale> locale ) { | |
| 125 | assert locale != null; | |
| 126 | mLocale = locale; | |
| 127 | } | |
| 128 | ||
| 129 | public void setThemePath( final Supplier<Path> themePath ) { | |
| 130 | assert themePath != null; | |
| 131 | mThemePath = themePath; | |
| 55 | 132 | } |
| 56 | 133 | |
| 57 | 134 | /** |
| 58 | 135 | * Sets the list of fully interpolated key-value pairs to use when |
| 59 | 136 | * substituting variable names back into the document as variable values. |
| 60 | 137 | * This uses a {@link Callable} reference so that GUI and command-line |
| 61 | 138 | * usage can insert their respective behaviours. That is, this method |
| 62 | 139 | * prevents coupling the GUI to the CLI. |
| 63 | 140 | * |
| 64 | * @param definitions Defines how to retrieve the definitions. | |
| 141 | * @param supplier Defines how to retrieve the definitions. | |
| 65 | 142 | */ |
| 66 | public void setDefinitions( | |
| 67 | final Supplier<Map<String, String>> definitions ) { | |
| 68 | mDefinitions = definitions; | |
| 143 | public void setDefinitions( final Supplier<Map<String, String>> supplier ) { | |
| 144 | assert supplier != null; | |
| 145 | mDefinitions = supplier; | |
| 146 | } | |
| 147 | ||
| 148 | public void setMetadata( final Supplier<Map<String, String>> metadata ) { | |
| 149 | assert metadata != null; | |
| 150 | mMetadata = metadata.get() == null ? HashMap::new : metadata; | |
| 69 | 151 | } |
| 70 | 152 | |
| 71 | 153 | /** |
| 72 | 154 | * Sets the source for deriving the {@link Caret}. Typically, this is |
| 73 | 155 | * the text editor that has focus. |
| 74 | 156 | * |
| 75 | 157 | * @param caret The source for the currently active caret. |
| 76 | 158 | */ |
| 77 | 159 | public void setCaret( final Supplier<Caret> caret ) { |
| 160 | assert caret != null; | |
| 78 | 161 | mCaret = caret; |
| 79 | 162 | } |
| 80 | 163 | |
| 81 | public void setExportFormat( final ExportFormat exportFormat ) { | |
| 82 | mExportFormat = exportFormat; | |
| 164 | public void setImageDir( final Supplier<File> imageDir ) { | |
| 165 | assert imageDir != null; | |
| 166 | mImageDir = () -> imageDir.get().toPath(); | |
| 83 | 167 | } |
| 84 | 168 | |
| 85 | public void setWorkspace( final Workspace workspace ) { | |
| 86 | mWorkspace = workspace; | |
| 169 | public void setImageOrder( final Supplier<String> imageOrder ) { | |
| 170 | assert imageOrder != null; | |
| 171 | mImageOrder = imageOrder; | |
| 172 | } | |
| 173 | ||
| 174 | public void setImageServer( final Supplier<String> imageServer ) { | |
| 175 | assert imageServer != null; | |
| 176 | mImageServer = imageServer; | |
| 177 | } | |
| 178 | ||
| 179 | public void setSigilBegan( final Supplier<String> sigilBegan ) { | |
| 180 | assert sigilBegan != null; | |
| 181 | mSigilBegan = sigilBegan; | |
| 182 | } | |
| 183 | ||
| 184 | public void setSigilEnded( final Supplier<String> sigilEnded ) { | |
| 185 | assert sigilEnded != null; | |
| 186 | mSigilEnded = sigilEnded; | |
| 187 | } | |
| 188 | ||
| 189 | public void setRWorkingDir( final Supplier<Path> rWorkingDir ) { | |
| 190 | assert rWorkingDir != null; | |
| 191 | ||
| 192 | mRWorkingDir = rWorkingDir; | |
| 193 | } | |
| 194 | ||
| 195 | public void setRScript( final Supplier<String> rScript ) { | |
| 196 | assert rScript != null; | |
| 197 | mRScript = rScript; | |
| 198 | } | |
| 199 | ||
| 200 | public void setCurlQuotes( final Supplier<Boolean> curlQuotes ) { | |
| 201 | assert curlQuotes != null; | |
| 202 | mCurlQuotes = curlQuotes; | |
| 203 | } | |
| 204 | ||
| 205 | public void setAutoClean( final Supplier<Boolean> autoClean ) { | |
| 206 | assert autoClean != null; | |
| 207 | mAutoClean = autoClean; | |
| 87 | 208 | } |
| 88 | 209 | } |
| 89 | 210 | |
| 90 | 211 | public static GenericBuilder<Mutator, ProcessorContext> builder() { |
| 91 | 212 | return GenericBuilder.of( Mutator::new, ProcessorContext::new ); |
| 92 | } | |
| 93 | ||
| 94 | /** | |
| 95 | * @param inputPath Path to the document to process. | |
| 96 | * @param format Indicate configuration options for export format. | |
| 97 | * @return A context that may be used for processing documents. | |
| 98 | */ | |
| 99 | public static ProcessorContext create( | |
| 100 | final Path inputPath, | |
| 101 | final ExportFormat format ) { | |
| 102 | return builder() | |
| 103 | .with( Mutator::setInputPath, inputPath ) | |
| 104 | .with( Mutator::setExportFormat, format ) | |
| 105 | .build(); | |
| 106 | 213 | } |
| 107 | 214 | |
| ... | ||
| 116 | 223 | |
| 117 | 224 | mMutator = mutator; |
| 225 | } | |
| 226 | ||
| 227 | public Path getInputPath() { | |
| 228 | return mMutator.mInputPath; | |
| 229 | } | |
| 230 | ||
| 231 | /** | |
| 232 | * Fully qualified file name to use when exporting (e.g., document.pdf). | |
| 233 | * | |
| 234 | * @return Full path to a file name. | |
| 235 | */ | |
| 236 | public Path getOutputPath() { | |
| 237 | return mMutator.mOutputPath; | |
| 238 | } | |
| 239 | ||
| 240 | public ExportFormat getExportFormat() { | |
| 241 | return mMutator.mExportFormat; | |
| 242 | } | |
| 243 | ||
| 244 | public Locale getLocale() { | |
| 245 | return mMutator.mLocale.get(); | |
| 118 | 246 | } |
| 119 | 247 | |
| ... | ||
| 133 | 261 | */ |
| 134 | 262 | public InterpolatingMap getInterpolatedDefinitions() { |
| 135 | final var map = new InterpolatingMap( | |
| 136 | createDefinitionSigilOperator(), getDefinitions() | |
| 137 | ); | |
| 138 | ||
| 139 | map.interpolate(); | |
| 140 | ||
| 141 | return map; | |
| 142 | } | |
| 143 | ||
| 144 | /** | |
| 145 | * Fully qualified file name to use when exporting (e.g., document.pdf). | |
| 146 | * | |
| 147 | * @return Full path to a file name. | |
| 148 | */ | |
| 149 | public Path getOutputPath() { | |
| 150 | return mMutator.mOutputPath; | |
| 263 | return new InterpolatingMap( | |
| 264 | createDefinitionKeyOperator(), getDefinitions() | |
| 265 | ).interpolate(); | |
| 151 | 266 | } |
| 152 | 267 | |
| 153 | public ExportFormat getExportFormat() { | |
| 154 | return mMutator.mExportFormat; | |
| 268 | public Map<String, String> getMetadata() { | |
| 269 | return mMutator.mMetadata.get(); | |
| 155 | 270 | } |
| 156 | 271 | |
| ... | ||
| 182 | 297 | } |
| 183 | 298 | |
| 184 | public Path getInputPath() { | |
| 185 | return mMutator.mInputPath; | |
| 299 | FileType getInputFileType() { | |
| 300 | return lookup( getInputPath() ); | |
| 186 | 301 | } |
| 187 | 302 | |
| 188 | FileType getFileType() { | |
| 189 | return lookup( getInputPath() ); | |
| 303 | public Path getImageDir() { | |
| 304 | return mMutator.mImageDir.get(); | |
| 190 | 305 | } |
| 191 | 306 | |
| 192 | public Workspace getWorkspace() { | |
| 193 | return mMutator.mWorkspace; | |
| 307 | public Iterable<String> getImageOrder() { | |
| 308 | assert mMutator.mImageOrder != null; | |
| 309 | ||
| 310 | final var order = mMutator.mImageOrder.get(); | |
| 311 | final var token = order.contains( "," ) ? ',' : ' '; | |
| 312 | ||
| 313 | return Splitter.on( token ).split( token + order ); | |
| 194 | 314 | } |
| 195 | 315 | |
| 196 | public SigilKeyOperator createSigilOperator() { | |
| 197 | return getWorkspace().createSigilOperator( getInputPath() ); | |
| 316 | public String getImageServer() { | |
| 317 | return mMutator.mImageServer.get(); | |
| 198 | 318 | } |
| 199 | 319 | |
| 200 | public SigilKeyOperator createDefinitionSigilOperator() { | |
| 201 | return getWorkspace().createDefinitionKeyOperator(); | |
| 320 | public Path getThemePath() { | |
| 321 | return mMutator.mThemePath.get(); | |
| 322 | } | |
| 323 | ||
| 324 | public Path getRWorkingDir() { | |
| 325 | return mMutator.mRWorkingDir.get(); | |
| 326 | } | |
| 327 | ||
| 328 | public String getRScript() { | |
| 329 | return mMutator.mRScript.get(); | |
| 330 | } | |
| 331 | ||
| 332 | public boolean getCurlQuotes() { | |
| 333 | return mMutator.mCurlQuotes.get(); | |
| 334 | } | |
| 335 | ||
| 336 | public boolean getAutoClean() { | |
| 337 | return mMutator.mAutoClean.get(); | |
| 338 | } | |
| 339 | ||
| 340 | /** | |
| 341 | * Answers whether to process a single text file or all text files in | |
| 342 | * the same directory as a single text file. See {@link #getInputPath()} | |
| 343 | * for the file to process (or all files in its directory). | |
| 344 | * | |
| 345 | * @return {@code true} means to process all text files, {@code false} | |
| 346 | * means to process a single file. | |
| 347 | */ | |
| 348 | public boolean getConcatenate() { | |
| 349 | return mMutator.mConcatenate; | |
| 350 | } | |
| 351 | ||
| 352 | public SigilKeyOperator createKeyOperator() { | |
| 353 | return createKeyOperator( getInputPath() ); | |
| 354 | } | |
| 355 | ||
| 356 | /** | |
| 357 | * Returns the sigil operator for the given {@link Path}. | |
| 358 | * | |
| 359 | * @param path The type of file being edited, from its extension. | |
| 360 | */ | |
| 361 | private SigilKeyOperator createKeyOperator( final Path path ) { | |
| 362 | assert path != null; | |
| 363 | ||
| 364 | return valueFrom( path ) == TEXT_PROPERTIES | |
| 365 | ? createPropertyKeyOperator() | |
| 366 | : createDefinitionKeyOperator(); | |
| 367 | } | |
| 368 | ||
| 369 | private SigilKeyOperator createPropertyKeyOperator() { | |
| 370 | return new PropertyKeyOperator(); | |
| 371 | } | |
| 372 | ||
| 373 | private SigilKeyOperator createDefinitionKeyOperator() { | |
| 374 | final var began = mMutator.mSigilBegan.get(); | |
| 375 | final var ended = mMutator.mSigilEnded.get(); | |
| 376 | ||
| 377 | return new SigilKeyOperator( began, ended ); | |
| 202 | 378 | } |
| 203 | 379 | } |
| 2 | 2 | package com.keenwrite.processors; |
| 3 | 3 | |
| 4 | import com.keenwrite.AbstractFileFactory; | |
| 5 | 4 | import com.keenwrite.processors.markdown.MarkdownProcessor; |
| 5 | import com.keenwrite.processors.r.InlineRProcessor; | |
| 6 | import com.keenwrite.processors.r.RVariableProcessor; | |
| 6 | 7 | |
| 8 | import static com.keenwrite.ExportFormat.MARKDOWN_PLAIN; | |
| 9 | import static com.keenwrite.io.FileType.RMARKDOWN; | |
| 10 | import static com.keenwrite.io.FileType.SOURCE; | |
| 7 | 11 | import static com.keenwrite.processors.IdentityProcessor.IDENTITY; |
| 8 | 12 | |
| 9 | 13 | /** |
| 10 | 14 | * Responsible for creating processors capable of parsing, transforming, |
| 11 | 15 | * interpolating, and rendering known file types. |
| 12 | 16 | */ |
| 13 | public final class ProcessorFactory extends AbstractFileFactory { | |
| 17 | public final class ProcessorFactory { | |
| 14 | 18 | |
| 15 | 19 | private ProcessorFactory() { |
| ... | ||
| 30 | 34 | public static Processor<String> createProcessors( |
| 31 | 35 | final ProcessorContext context, final Processor<String> preview ) { |
| 32 | return ProcessorFactory.createProcessor( context, preview ); | |
| 36 | return createProcessor( context, preview ); | |
| 33 | 37 | } |
| 34 | 38 | |
| ... | ||
| 52 | 56 | // to SVG. Without conversion would require client-side rendering of |
| 53 | 57 | // math (such as using the JavaScript-based KaTeX engine). |
| 54 | final var successor = switch( context.getExportFormat() ) { | |
| 58 | final var outputType = context.getExportFormat(); | |
| 59 | ||
| 60 | final var successor = switch( outputType ) { | |
| 55 | 61 | case NONE -> preview; |
| 56 | 62 | case XHTML_TEX -> createXhtmlProcessor( context ); |
| 57 | 63 | case APPLICATION_PDF -> createPdfProcessor( context ); |
| 58 | 64 | default -> createIdentityProcessor( context ); |
| 59 | 65 | }; |
| 60 | 66 | |
| 61 | final var processor = switch( context.getFileType() ) { | |
| 62 | case SOURCE, RMARKDOWN -> createMarkdownProcessor( successor, context ); | |
| 63 | default -> createPreformattedProcessor( successor ); | |
| 64 | }; | |
| 67 | final var inputType = context.getInputFileType(); | |
| 68 | final Processor<String> processor; | |
| 69 | ||
| 70 | if( preview == null && outputType == MARKDOWN_PLAIN ) { | |
| 71 | processor = inputType == RMARKDOWN | |
| 72 | ? createInlineRProcessor( successor, context ) | |
| 73 | : createMarkdownProcessor( successor, context ); | |
| 74 | } | |
| 75 | else { | |
| 76 | processor = inputType == SOURCE || inputType == RMARKDOWN | |
| 77 | ? createMarkdownProcessor( successor, context ) | |
| 78 | : createPreformattedProcessor( successor ); | |
| 79 | } | |
| 65 | 80 | |
| 66 | 81 | return new ExecutorProcessor<>( processor ); |
| ... | ||
| 78 | 93 | return IDENTITY; |
| 79 | 94 | } |
| 95 | ||
| 80 | 96 | /** |
| 81 | 97 | * Instantiates a {@link Processor} responsible for parsing Markdown and |
| ... | ||
| 88 | 104 | final Processor<String> successor, |
| 89 | 105 | final ProcessorContext context ) { |
| 90 | final var dp = createDefinitionProcessor( successor, context ); | |
| 106 | final var dp = createVariableProcessor( successor, context ); | |
| 91 | 107 | return MarkdownProcessor.create( dp, context ); |
| 92 | 108 | } |
| 93 | 109 | |
| 94 | private static Processor<String> createDefinitionProcessor( | |
| 110 | private static Processor<String> createVariableProcessor( | |
| 95 | 111 | final Processor<String> successor, |
| 96 | 112 | final ProcessorContext context ) { |
| 97 | 113 | return new VariableProcessor( successor, context ); |
| 114 | } | |
| 115 | ||
| 116 | /** | |
| 117 | * Instantiates a processor capable of executing R statements (along with | |
| 118 | * R variable references) and embedding the result into the document. This | |
| 119 | * is useful for converting R Markdown documents into plain Markdown. | |
| 120 | * | |
| 121 | * @param successor {@link Processor} invoked after {@link InlineRProcessor}. | |
| 122 | * @param context {@link Processor} configuration settings. | |
| 123 | * @return An instance of {@link Processor} that performs variable | |
| 124 | * interpolation, replacement, and execution of R statements. | |
| 125 | */ | |
| 126 | private static Processor<String> createInlineRProcessor( | |
| 127 | final Processor<String> successor, final ProcessorContext context ) { | |
| 128 | final var irp = new InlineRProcessor( successor, context ); | |
| 129 | final var rvp = new RVariableProcessor( irp, context ); | |
| 130 | return createVariableProcessor( rvp, context ); | |
| 98 | 131 | } |
| 99 | 132 | |
| 31 | 31 | super( successor ); |
| 32 | 32 | |
| 33 | mSigilOperator = createKeyOperator( context ); | |
| 34 | 33 | mContext = context; |
| 34 | mSigilOperator = createKeyOperator( context ); | |
| 35 | 35 | } |
| 36 | 36 | |
| ... | ||
| 44 | 44 | protected UnaryOperator<String> createKeyOperator( |
| 45 | 45 | final ProcessorContext context ) { |
| 46 | return context.createSigilOperator(); | |
| 46 | return context.createKeyOperator(); | |
| 47 | 47 | } |
| 48 | 48 | |
| 3 | 3 | |
| 4 | 4 | import com.keenwrite.dom.DocumentParser; |
| 5 | import com.keenwrite.preferences.Workspace; | |
| 6 | 5 | import com.keenwrite.ui.heuristics.WordCounter; |
| 7 | 6 | import com.whitemagicsoftware.keenquotes.Contractions; |
| 8 | 7 | import com.whitemagicsoftware.keenquotes.Converter; |
| 9 | import javafx.beans.property.ListProperty; | |
| 10 | 8 | import org.w3c.dom.Document; |
| 11 | 9 | |
| 12 | 10 | import java.io.FileNotFoundException; |
| 13 | 11 | import java.nio.file.Path; |
| 14 | 12 | import java.util.LinkedHashMap; |
| 15 | 13 | import java.util.List; |
| 16 | 14 | import java.util.Locale; |
| 17 | 15 | import java.util.Map; |
| 18 | import java.util.Map.Entry; | |
| 19 | import java.util.regex.Pattern; | |
| 20 | 16 | |
| 21 | 17 | import static com.keenwrite.Bootstrap.APP_TITLE_LOWERCASE; |
| 22 | 18 | import static com.keenwrite.dom.DocumentParser.createMeta; |
| 23 | 19 | import static com.keenwrite.dom.DocumentParser.visit; |
| 24 | 20 | import static com.keenwrite.events.StatusEvent.clue; |
| 25 | 21 | import static com.keenwrite.io.HttpFacade.httpGet; |
| 26 | import static com.keenwrite.preferences.AppKeys.*; | |
| 27 | 22 | import static com.keenwrite.util.ProtocolScheme.getProtocol; |
| 28 | 23 | import static com.whitemagicsoftware.keenquotes.Converter.CHARS; |
| 29 | 24 | import static com.whitemagicsoftware.keenquotes.ParserFactory.ParserType.PARSER_XML; |
| 30 | 25 | import static java.lang.String.format; |
| 31 | 26 | import static java.lang.String.valueOf; |
| 32 | 27 | import static java.nio.file.Files.copy; |
| 33 | 28 | import static java.nio.file.StandardCopyOption.REPLACE_EXISTING; |
| 34 | import static java.util.regex.Pattern.UNICODE_CHARACTER_CLASS; | |
| 35 | import static java.util.regex.Pattern.compile; | |
| 36 | 29 | |
| 37 | 30 | /** |
| 38 | 31 | * Responsible for making an XHTML document complete by wrapping it with html |
| 39 | 32 | * and body elements. This doesn't have to be super-efficient because it's |
| 40 | 33 | * not run in real-time. |
| 41 | 34 | */ |
| 42 | 35 | public final class XhtmlProcessor extends ExecutorProcessor<String> { |
| 43 | private final static Pattern BLANK = | |
| 44 | compile( "\\p{Blank}", UNICODE_CHARACTER_CLASS ); | |
| 45 | ||
| 46 | 36 | private final static Converter sTypographer = new Converter( |
| 47 | 37 | lex -> clue( lex.toString() ), contractions(), CHARS, PARSER_XML ); |
| ... | ||
| 105 | 95 | |
| 106 | 96 | final var document = DocumentParser.toString( doc ); |
| 97 | final var curl = mContext.getCurlQuotes(); | |
| 107 | 98 | |
| 108 | return curl() ? sTypographer.apply( document ) : document; | |
| 99 | return curl ? sTypographer.apply( document ) : document; | |
| 109 | 100 | } catch( final Exception ex ) { |
| 110 | 101 | clue( ex ); |
| ... | ||
| 139 | 130 | */ |
| 140 | 131 | private Map<String, String> createMetaDataMap( final Document doc ) { |
| 141 | final Map<String, String> result = new LinkedHashMap<>(); | |
| 142 | final var metadata = getMetaData(); | |
| 132 | final var result = new LinkedHashMap<String, String>(); | |
| 133 | final var metadata = getMetadata(); | |
| 143 | 134 | final var map = mContext.getInterpolatedDefinitions(); |
| 144 | 135 | |
| 145 | metadata.forEach( entry -> result.put( | |
| 146 | entry.getKey(), map.interpolate( entry.getValue() ) ) | |
| 136 | metadata.forEach( | |
| 137 | ( key, value ) -> result.put( key, map.interpolate( value ) ) | |
| 147 | 138 | ); |
| 148 | 139 | result.put( "count", wordCount( doc ) ); |
| ... | ||
| 158 | 149 | * @return The document metadata. |
| 159 | 150 | */ |
| 160 | private ListProperty<Entry<String, String>> getMetaData() { | |
| 161 | return getWorkspace().listsProperty( KEY_DOC_META ); | |
| 151 | private Map<String, String> getMetadata() { | |
| 152 | return mContext.getMetadata(); | |
| 162 | 153 | } |
| 163 | 154 | |
| ... | ||
| 193 | 184 | } |
| 194 | 185 | else { |
| 195 | final var extensions = " " + getImageOrder().trim(); | |
| 186 | final var extensions = getImageOrder(); | |
| 196 | 187 | var imagePath = getImagePath(); |
| 197 | 188 | var found = false; |
| 198 | 189 | |
| 199 | // By including " " in the extensions, the first element returned | |
| 200 | // will be the empty string. Thus the first extension to try is the | |
| 201 | // file's default extension. Subsequent iterations will try to find | |
| 202 | // a file that has a name matching one of the preferred extensions. | |
| 203 | for( final var extension : BLANK.split( extensions ) ) { | |
| 190 | for( final var extension : extensions ) { | |
| 204 | 191 | final var filename = format( |
| 205 | 192 | "%s%s%s", src, extension.isBlank() ? "" : ".", extension ); |
| ... | ||
| 226 | 213 | |
| 227 | 214 | private String getImagePath() { |
| 228 | return getWorkspace().getFile( KEY_IMAGES_DIR ).toString(); | |
| 215 | return mContext.getImageDir().toString(); | |
| 229 | 216 | } |
| 230 | 217 | |
| 231 | private String getImageOrder() { | |
| 232 | return getWorkspace().getString( KEY_IMAGES_ORDER ); | |
| 218 | /** | |
| 219 | * By including an "empty" extension, the first element returned | |
| 220 | * will be the empty string. Thus, the first extension to try is the | |
| 221 | * file's default extension. Subsequent iterations will try to find | |
| 222 | * a file that has a name matching one of the preferred extensions. | |
| 223 | * | |
| 224 | * @return A list of extensions, including an empty string at the start. | |
| 225 | */ | |
| 226 | private Iterable<String> getImageOrder() { | |
| 227 | return mContext.getImageOrder(); | |
| 233 | 228 | } |
| 234 | 229 | |
| ... | ||
| 243 | 238 | } |
| 244 | 239 | |
| 245 | private Workspace getWorkspace() { | |
| 246 | return mContext.getWorkspace(); | |
| 240 | private Locale getLocale() { | |
| 241 | return mContext.getLocale(); | |
| 247 | 242 | } |
| 248 | ||
| 249 | private Locale locale() {return getWorkspace().getLocale();} | |
| 250 | 243 | |
| 251 | 244 | private String wordCount( final Document doc ) { |
| 252 | 245 | final var sb = new StringBuilder( 65536 * 10 ); |
| 253 | 246 | |
| 254 | 247 | visit( |
| 255 | 248 | doc, |
| 256 | 249 | "//*[normalize-space( text() ) != '']", |
| 257 | 250 | node -> sb.append( node.getTextContent() ) |
| 258 | 251 | ); |
| 259 | ||
| 260 | return valueOf( WordCounter.create( locale() ).count( sb.toString() ) ); | |
| 261 | } | |
| 262 | 252 | |
| 263 | /** | |
| 264 | * Answers whether straight quotation marks should be curled. | |
| 265 | * | |
| 266 | * @return {@code false} to prevent curling straight quotes. | |
| 267 | */ | |
| 268 | private boolean curl() { | |
| 269 | return getWorkspace().getBoolean( KEY_TYPESET_TYPOGRAPHY_QUOTES ); | |
| 253 | return valueOf( WordCounter.create( getLocale() ).count( sb.toString() ) ); | |
| 270 | 254 | } |
| 271 | 255 | |
| 53 | 53 | @Override |
| 54 | 54 | List<Extension> createExtensions( final ProcessorContext context ) { |
| 55 | final var editorFile = context.getInputPath(); | |
| 56 | final var mediaType = MediaType.valueFrom( editorFile ); | |
| 55 | final var inputPath = context.getInputPath(); | |
| 56 | final var mediaType = MediaType.valueFrom( inputPath ); | |
| 57 | 57 | final Processor<String> processor; |
| 58 | 58 | final List<Extension> extensions = new ArrayList<>(); |
| 2 | 2 | package com.keenwrite.processors.markdown.extensions; |
| 3 | 3 | |
| 4 | import com.keenwrite.Caret; | |
| 4 | import com.keenwrite.editors.common.Caret; | |
| 5 | 5 | import com.keenwrite.constants.Constants; |
| 6 | 6 | import com.keenwrite.processors.ProcessorContext; |
| 3 | 3 | |
| 4 | 4 | import com.keenwrite.ExportFormat; |
| 5 | import com.keenwrite.preferences.Workspace; | |
| 6 | 5 | import com.keenwrite.processors.ProcessorContext; |
| 7 | 6 | import com.vladsch.flexmark.ast.Image; |
| ... | ||
| 18 | 17 | import static com.keenwrite.ExportFormat.NONE; |
| 19 | 18 | import static com.keenwrite.events.StatusEvent.clue; |
| 20 | import static com.keenwrite.preferences.AppKeys.KEY_IMAGES_DIR; | |
| 21 | import static com.keenwrite.preferences.AppKeys.KEY_IMAGES_ORDER; | |
| 22 | 19 | import static com.keenwrite.util.ProtocolScheme.getProtocol; |
| 23 | 20 | import static com.vladsch.flexmark.html.HtmlRenderer.Builder; |
| 24 | 21 | import static com.vladsch.flexmark.html.renderer.LinkStatus.VALID; |
| 25 | import static org.renjin.repackaged.guava.base.Splitter.on; | |
| 26 | 22 | |
| 27 | 23 | /** |
| 28 | 24 | * Responsible for ensuring that images can be rendered relative to a path. |
| 29 | 25 | * This allows images to be located virtually anywhere. |
| 30 | 26 | */ |
| 31 | 27 | public class ImageLinkExtension extends HtmlRendererAdapter { |
| 32 | 28 | |
| 33 | private final Path mBaseDir; | |
| 34 | private final Workspace mWorkspace; | |
| 35 | private final ExportFormat mExportFormat; | |
| 29 | private final ProcessorContext mContext; | |
| 36 | 30 | |
| 37 | private ImageLinkExtension( | |
| 38 | @NotNull final ProcessorContext context ) { | |
| 39 | mBaseDir = context.getBaseDir(); | |
| 40 | mWorkspace = context.getWorkspace(); | |
| 41 | mExportFormat = context.getExportFormat(); | |
| 31 | private ImageLinkExtension( @NotNull final ProcessorContext context ) { | |
| 32 | mContext = context; | |
| 42 | 33 | } |
| 43 | 34 | |
| ... | ||
| 111 | 102 | } |
| 112 | 103 | |
| 113 | if( mExportFormat != NONE ) { | |
| 104 | if( mContext.getExportFormat() != NONE ) { | |
| 114 | 105 | return valid( link, uri ); |
| 115 | 106 | } |
| 116 | 107 | |
| 117 | 108 | try { |
| 118 | 109 | // Compute the path to the image file. The base directory should |
| 119 | 110 | // be an absolute path to the file being edited, without an extension. |
| 120 | final var imagesDir = getUserImagesDir(); | |
| 111 | final var imagesDir = getImageDir(); | |
| 121 | 112 | final var relativeDir = imagesDir.toString().isEmpty() |
| 122 | 113 | ? imagesDir : baseDir.relativize( imagesDir ); |
| 123 | 114 | final var imageFile = Path.of( |
| 124 | 115 | baseDir.toString(), relativeDir.toString(), uri ); |
| 125 | 116 | |
| 126 | for( final var ext : getImageExtensions() ) { | |
| 117 | for( final var ext : getImageOrder() ) { | |
| 127 | 118 | var file = new File( imageFile.toString() + '.' + ext ); |
| 128 | 119 | |
| ... | ||
| 147 | 138 | } |
| 148 | 139 | |
| 149 | private Path getUserImagesDir() { | |
| 150 | return mWorkspace.getFile( KEY_IMAGES_DIR ).toPath(); | |
| 140 | private Path getImageDir() { | |
| 141 | return mContext.getImageDir(); | |
| 151 | 142 | } |
| 152 | 143 | |
| 153 | private Iterable<String> getImageExtensions() { | |
| 154 | return on( ' ' ).split( mWorkspace.getString( KEY_IMAGES_ORDER ) ); | |
| 144 | private Iterable<String> getImageOrder() { | |
| 145 | return mContext.getImageOrder(); | |
| 155 | 146 | } |
| 156 | 147 | |
| 157 | 148 | private Path getBaseDir() { |
| 158 | return mBaseDir; | |
| 149 | return mContext.getBaseDir(); | |
| 159 | 150 | } |
| 160 | 151 | } |
| 2 | 2 | package com.keenwrite.processors.markdown.extensions.fences; |
| 3 | 3 | |
| 4 | import com.keenwrite.preferences.Workspace; | |
| 5 | 4 | import com.keenwrite.preview.DiagramUrlGenerator; |
| 6 | import com.keenwrite.processors.VariableProcessor; | |
| 7 | 5 | import com.keenwrite.processors.Processor; |
| 8 | 6 | import com.keenwrite.processors.ProcessorContext; |
| 7 | import com.keenwrite.processors.VariableProcessor; | |
| 9 | 8 | import com.keenwrite.processors.markdown.MarkdownProcessor; |
| 10 | 9 | import com.keenwrite.processors.markdown.extensions.HtmlRendererAdapter; |
| ... | ||
| 23 | 22 | import java.util.Set; |
| 24 | 23 | |
| 25 | import static com.keenwrite.preferences.AppKeys.KEY_IMAGES_SERVER; | |
| 26 | 24 | import static com.vladsch.flexmark.html.HtmlRenderer.Builder; |
| 27 | 25 | import static com.vladsch.flexmark.html.renderer.CoreNodeRenderer.CODE_CONTENT; |
| ... | ||
| 37 | 35 | |
| 38 | 36 | private final Processor<String> mProcessor; |
| 39 | private final Workspace mWorkspace; | |
| 37 | private final ProcessorContext mContext; | |
| 40 | 38 | |
| 41 | 39 | public FencedBlockExtension( |
| 42 | final Processor<String> processor, final Workspace workspace ) { | |
| 40 | final Processor<String> processor, final ProcessorContext context ) { | |
| 43 | 41 | assert processor != null; |
| 44 | assert workspace != null; | |
| 42 | assert context != null; | |
| 45 | 43 | mProcessor = processor; |
| 46 | mWorkspace = workspace; | |
| 44 | mContext = context; | |
| 47 | 45 | } |
| 48 | 46 | |
| ... | ||
| 69 | 67 | assert processor != null; |
| 70 | 68 | assert context != null; |
| 71 | return new FencedBlockExtension( processor, context.getWorkspace() ); | |
| 69 | return new FencedBlockExtension( processor, context ); | |
| 72 | 70 | } |
| 73 | 71 | |
| ... | ||
| 107 | 105 | final var content = node.getContentChars().normalizeEOL(); |
| 108 | 106 | final var text = mProcessor.apply( content ); |
| 109 | final var server = mWorkspace.getString( KEY_IMAGES_SERVER ); | |
| 107 | final var server = mContext.getImageServer(); | |
| 110 | 108 | final var source = DiagramUrlGenerator.toUrl( server, type, text ); |
| 111 | 109 | final var link = context.resolveLink( LINK, source, false ); |
| 2 | 2 | package com.keenwrite.processors.r; |
| 3 | 3 | |
| 4 | import com.keenwrite.preferences.Workspace; | |
| 5 | 4 | import com.keenwrite.processors.Processor; |
| 6 | 5 | import com.keenwrite.processors.ProcessorContext; |
| 7 | 6 | import com.keenwrite.processors.VariableProcessor; |
| 8 | 7 | import com.keenwrite.sigils.RKeyOperator; |
| 9 | import javafx.beans.property.Property; | |
| 10 | 8 | import org.jetbrains.annotations.NotNull; |
| 11 | 9 | |
| 12 | import java.io.File; | |
| 13 | import java.nio.file.Path; | |
| 14 | 10 | import java.util.HashMap; |
| 15 | 11 | import java.util.concurrent.atomic.AtomicBoolean; |
| 16 | 12 | |
| 17 | 13 | import static com.keenwrite.constants.Constants.STATUS_PARSE_ERROR; |
| 18 | 14 | import static com.keenwrite.events.StatusEvent.clue; |
| 19 | import static com.keenwrite.preferences.AppKeys.KEY_R_DIR; | |
| 20 | import static com.keenwrite.preferences.AppKeys.KEY_R_SCRIPT; | |
| 21 | 15 | import static com.keenwrite.processors.r.RVariableProcessor.escape; |
| 22 | 16 | import static com.keenwrite.processors.text.TextReplacementFactory.replace; |
| ... | ||
| 60 | 54 | */ |
| 61 | 55 | public void init() { |
| 62 | final var bootstrap = getBootstrapScript(); | |
| 56 | final var context = getContext(); | |
| 57 | final var bootstrap = context.getRScript(); | |
| 63 | 58 | |
| 64 | 59 | if( !bootstrap.isBlank() ) { |
| 65 | final var wd = getWorkingDirectory(); | |
| 60 | final var wd = context.getRWorkingDir(); | |
| 66 | 61 | final var dir = wd.toString().replace( '\\', '/' ); |
| 67 | 62 | final var definitions = getContext().getDefinitions(); |
| ... | ||
| 79 | 74 | Engine.eval( replace( bootstrap, map ) ); |
| 80 | 75 | mReady.set( true ); |
| 81 | } catch( final Exception ignored ) { | |
| 76 | } catch( final Exception ex ) { | |
| 77 | clue( ex ); | |
| 82 | 78 | // A problem with the bootstrap script is likely caused by variables |
| 83 | 79 | // not being loaded. This implies that the R processor is being invoked |
| ... | ||
| 154 | 150 | // Copy from the previous index to the end of the string. |
| 155 | 151 | return sb.append( text.substring( min( prevIndex, length ) ) ).toString(); |
| 156 | } | |
| 157 | ||
| 158 | /** | |
| 159 | * Return the given path if not {@code null}, otherwise return the path to | |
| 160 | * the user's directory. | |
| 161 | * | |
| 162 | * @return A non-null path. | |
| 163 | */ | |
| 164 | private Path getWorkingDirectory() { | |
| 165 | return workingDirectoryProperty().getValue().toPath(); | |
| 166 | } | |
| 167 | ||
| 168 | private Property<File> workingDirectoryProperty() { | |
| 169 | return getWorkspace().fileProperty( KEY_R_DIR ); | |
| 170 | } | |
| 171 | ||
| 172 | /** | |
| 173 | * Loads the R init script from the application's persisted preferences. | |
| 174 | * | |
| 175 | * @return A non-null string, possibly empty. | |
| 176 | */ | |
| 177 | private String getBootstrapScript() { | |
| 178 | return bootstrapScriptProperty().getValue(); | |
| 179 | } | |
| 180 | ||
| 181 | private Property<String> bootstrapScriptProperty() { | |
| 182 | return getWorkspace().valuesProperty( KEY_R_SCRIPT ); | |
| 183 | } | |
| 184 | ||
| 185 | private Workspace getWorkspace() { | |
| 186 | return getContext().getWorkspace(); | |
| 187 | 152 | } |
| 188 | 153 | } |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.security; | |
| 3 | ||
| 4 | import javax.net.ssl.*; | |
| 5 | import java.security.SecureRandom; | |
| 6 | import java.security.cert.X509Certificate; | |
| 7 | ||
| 8 | import static javax.net.ssl.HttpsURLConnection.setDefaultHostnameVerifier; | |
| 9 | import static javax.net.ssl.HttpsURLConnection.setDefaultSSLSocketFactory; | |
| 10 | ||
| 11 | /** | |
| 12 | * Responsible for trusting all certificate chains. The purpose of this class | |
| 13 | * is to work-around certificate issues caused by software that blocks | |
| 14 | * HTTP requests. For example, zscaler may block HTTP requests to kroki.io | |
| 15 | * when generating diagrams. | |
| 16 | */ | |
| 17 | public final class PermissiveCertificate { | |
| 18 | /** | |
| 19 | * Create a trust manager that does not validate certificate chains. | |
| 20 | */ | |
| 21 | private final static TrustManager[] TRUST_ALL_CERTS = new TrustManager[]{ | |
| 22 | new X509TrustManager() { | |
| 23 | @Override | |
| 24 | public X509Certificate[] getAcceptedIssuers() { | |
| 25 | return new X509Certificate[ 0 ]; | |
| 26 | } | |
| 27 | ||
| 28 | @Override | |
| 29 | public void checkClientTrusted( | |
| 30 | X509Certificate[] certs, String authType ) { | |
| 31 | } | |
| 32 | ||
| 33 | @Override | |
| 34 | public void checkServerTrusted( | |
| 35 | X509Certificate[] certs, String authType ) { | |
| 36 | } | |
| 37 | } | |
| 38 | }; | |
| 39 | ||
| 40 | /** | |
| 41 | * Responsible for permitting all hostnames for making HTTP requests. | |
| 42 | */ | |
| 43 | private static class PermissiveHostNameVerifier implements HostnameVerifier { | |
| 44 | @Override | |
| 45 | public boolean verify( final String hostname, final SSLSession session ) { | |
| 46 | return true; | |
| 47 | } | |
| 48 | } | |
| 49 | ||
| 50 | /** | |
| 51 | * Install the all-trusting trust manager. If this fails it means that in | |
| 52 | * certain situations the HTML preview may fail to render diagrams. A way | |
| 53 | * to work around the issue is to install a local server for generating | |
| 54 | * diagrams. | |
| 55 | */ | |
| 56 | public static boolean installTrustManager() { | |
| 57 | try { | |
| 58 | final var context = SSLContext.getInstance( "SSL" ); | |
| 59 | context.init( null, TRUST_ALL_CERTS, new SecureRandom() ); | |
| 60 | setDefaultSSLSocketFactory( context.getSocketFactory() ); | |
| 61 | setDefaultHostnameVerifier( new PermissiveHostNameVerifier() ); | |
| 62 | return true; | |
| 63 | } catch( final Exception ex ) { | |
| 64 | return false; | |
| 65 | } | |
| 66 | } | |
| 67 | ||
| 68 | /** | |
| 69 | * Use {@link #installTrustManager()}. | |
| 70 | */ | |
| 71 | private PermissiveCertificate() { | |
| 72 | } | |
| 73 | } | |
| 1 | 74 |
| 2 | 2 | package com.keenwrite.typesetting; |
| 3 | 3 | |
| 4 | import com.keenwrite.io.SysFile; | |
| 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; | |
| 4 | import com.keenwrite.collections.BoundedCache; | |
| 5 | import com.keenwrite.io.SysFile; | |
| 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 boolean mAutoClean; | |
| 47 | ||
| 48 | /** | |
| 49 | * @param inputPath The input document to typeset. | |
| 50 | */ | |
| 51 | public void setInputPath( final Path inputPath ) { | |
| 52 | mInputPath = inputPath; | |
| 53 | } | |
| 54 | ||
| 55 | /** | |
| 56 | * @param outputPath Path to the finished typeset document to create. | |
| 57 | */ | |
| 58 | public void setOutputPath( final Path outputPath ) { | |
| 59 | mOutputPath = outputPath; | |
| 60 | } | |
| 61 | ||
| 62 | /** | |
| 63 | * @param themePath Fully qualified path to the theme directory, which | |
| 64 | * ends with the selected theme name. | |
| 65 | */ | |
| 66 | public void setThemePath( final Path themePath ) { | |
| 67 | mThemePath = themePath; | |
| 68 | } | |
| 69 | ||
| 70 | /** | |
| 71 | * @see #setThemePath(Path) | |
| 72 | */ | |
| 73 | public void setThemePath( final File themePath ) { | |
| 74 | setThemePath( themePath.toPath() ); | |
| 75 | } | |
| 76 | ||
| 77 | /** | |
| 78 | * @param autoClean {@code true} to remove all temporary files after | |
| 79 | * typesetter produces a PDF file. | |
| 80 | */ | |
| 81 | public void setAutoClean( final boolean autoClean ) { | |
| 82 | mAutoClean = autoClean; | |
| 83 | } | |
| 84 | } | |
| 85 | ||
| 86 | public static boolean canRun() { | |
| 87 | return TYPESETTER.canRun(); | |
| 88 | } | |
| 89 | ||
| 90 | /** | |
| 91 | * Calculates the time that has elapsed from the current time to the | |
| 92 | * given moment in time. | |
| 93 | * | |
| 94 | * @param start The starting time, which should be before the current time. | |
| 95 | * @return A human-readable formatted time. | |
| 96 | * @see #asElapsed(long) | |
| 97 | */ | |
| 98 | private static String since( final long start ) { | |
| 99 | return asElapsed( currentTimeMillis() - start ); | |
| 100 | } | |
| 101 | ||
| 102 | /** | |
| 103 | * Converts an elapsed time to a human-readable format (hours, minutes, | |
| 104 | * seconds, and milliseconds). | |
| 105 | * | |
| 106 | * @param elapsed An elapsed time, in milliseconds. | |
| 107 | * @return Human-readable elapsed time. | |
| 108 | */ | |
| 109 | private static String asElapsed( final long elapsed ) { | |
| 110 | final var hours = MILLISECONDS.toHours( elapsed ); | |
| 111 | final var eHours = elapsed - HOURS.toMillis( hours ); | |
| 112 | final var minutes = MILLISECONDS.toMinutes( eHours ); | |
| 113 | final var eMinutes = eHours - MINUTES.toMillis( minutes ); | |
| 114 | final var seconds = MILLISECONDS.toSeconds( eMinutes ); | |
| 115 | final var eSeconds = eMinutes - SECONDS.toMillis( seconds ); | |
| 116 | final var milliseconds = MILLISECONDS.toMillis( eSeconds ); | |
| 117 | ||
| 118 | return format( "%02d:%02d:%02d.%03d", | |
| 119 | hours, minutes, seconds, milliseconds ); | |
| 120 | } | |
| 121 | ||
| 122 | /** | |
| 123 | * Launches a task to typeset a document. | |
| 124 | */ | |
| 125 | private class TypesetTask implements Callable<Boolean> { | |
| 126 | private final List<String> mArgs = new ArrayList<>(); | |
| 127 | ||
| 128 | /** | |
| 129 | * Working directory must be set because ConTeXt cannot write the | |
| 130 | * result to an arbitrary location. | |
| 131 | */ | |
| 132 | private final Path mDirectory; | |
| 133 | ||
| 134 | private TypesetTask() { | |
| 135 | final var parentDir = getOutputPath().getParent(); | |
| 136 | mDirectory = parentDir == null ? DEFAULT_DIRECTORY : parentDir; | |
| 137 | } | |
| 138 | ||
| 139 | /** | |
| 140 | * Initializes ConTeXt, which means creating the cache directory if it | |
| 141 | * doesn't already exist. The theme entry point must be named 'main.tex'. | |
| 142 | * | |
| 143 | * @return {@code true} if the cache directory exists. | |
| 144 | */ | |
| 145 | private boolean reinitialize() { | |
| 146 | final var filename = getOutputPath().getFileName(); | |
| 147 | final var theme = getThemePath(); | |
| 148 | final var cacheExists = !isEmpty( getCacheDir().toPath() ); | |
| 149 | ||
| 150 | // Ensure invoking multiple times will load the correct arguments. | |
| 151 | mArgs.clear(); | |
| 152 | mArgs.add( TYPESETTER.getName() ); | |
| 153 | ||
| 154 | if( cacheExists ) { | |
| 155 | mArgs.add( "--autogenerate" ); | |
| 156 | mArgs.add( "--script" ); | |
| 157 | mArgs.add( "mtx-context" ); | |
| 158 | mArgs.add( "--batchmode" ); | |
| 159 | mArgs.add( "--nonstopmode" ); | |
| 160 | mArgs.add( "--purgeall" ); | |
| 161 | mArgs.add( "--path='" + theme + "'" ); | |
| 162 | mArgs.add( "--environment='main'" ); | |
| 163 | mArgs.add( "--result='" + filename + "'" ); | |
| 164 | mArgs.add( getInputPath().toString() ); | |
| 165 | ||
| 166 | final var sb = new StringBuilder( 128 ); | |
| 167 | mArgs.forEach( arg -> sb.append( arg ).append( " " ) ); | |
| 168 | clue( sb.toString() ); | |
| 169 | } | |
| 170 | else { | |
| 171 | mArgs.add( "--generate" ); | |
| 172 | } | |
| 173 | ||
| 174 | return cacheExists; | |
| 175 | } | |
| 176 | ||
| 177 | /** | |
| 178 | * Setting {@code TEXMFCACHE} when run on a fresh system fails on the first | |
| 179 | * try. If the cache directory doesn't exist, attempt to create it, then | |
| 180 | * call ConTeXt to generate the PDF. This is brittle because if the | |
| 181 | * directory is empty, or not populated with cached data, a false positive | |
| 182 | * will be returned, resulting in no PDF being created. | |
| 183 | * | |
| 184 | * @return {@code true} if the document was typeset successfully. | |
| 185 | * @throws IOException If the process could not be started. | |
| 186 | * @throws InterruptedException If the process was killed. | |
| 187 | */ | |
| 188 | private boolean typeset() throws IOException, InterruptedException { | |
| 189 | return reinitialize() ? call() : call() && reinitialize() && call(); | |
| 190 | } | |
| 191 | ||
| 192 | @Override | |
| 193 | public Boolean call() throws IOException, InterruptedException { | |
| 194 | final var stdout = new BoundedCache<String, String>( 150 ); | |
| 195 | final var builder = new ProcessBuilder( mArgs ); | |
| 196 | builder.directory( mDirectory.toFile() ); | |
| 197 | builder.environment().put( "TEXMFCACHE", getCacheDir().toString() ); | |
| 198 | ||
| 199 | // Without redirecting (or draining) stderr, the command may not | |
| 200 | // terminate successfully. | |
| 201 | builder.redirectError( DISCARD ); | |
| 202 | ||
| 203 | final var process = builder.start(); | |
| 204 | final var stream = process.getInputStream(); | |
| 205 | ||
| 206 | // Reading from stdout allows slurping page numbers while generating. | |
| 207 | final var listener = new PaginationListener( stream, stdout ); | |
| 208 | listener.start(); | |
| 209 | ||
| 210 | // Even though the process has completed, there may be incomplete I/O. | |
| 211 | process.waitFor(); | |
| 212 | ||
| 213 | // Allow time for any incomplete I/O to take place. | |
| 214 | process.waitFor( 1, SECONDS ); | |
| 215 | ||
| 216 | final var exit = process.exitValue(); | |
| 217 | process.destroy(); | |
| 218 | ||
| 219 | // If there was an error, the typesetter will leave behind log, pdf, and | |
| 220 | // error files. | |
| 221 | if( exit > 0 ) { | |
| 222 | final var xmlName = getInputPath().getFileName().toString(); | |
| 223 | final var srcName = getOutputPath().getFileName().toString(); | |
| 224 | final var logName = newExtension( xmlName, ".log" ); | |
| 225 | final var errName = newExtension( xmlName, "-error.log" ); | |
| 226 | final var pdfName = newExtension( xmlName, ".pdf" ); | |
| 227 | final var tuaName = newExtension( xmlName, ".tua" ); | |
| 228 | final var badName = newExtension( srcName, ".log" ); | |
| 229 | ||
| 230 | log( badName ); | |
| 231 | log( logName ); | |
| 232 | log( errName ); | |
| 233 | log( stdout.keySet().stream().toList() ); | |
| 234 | ||
| 235 | // Users may opt to keep these files around for debugging purposes. | |
| 236 | if( autoclean() ) { | |
| 237 | deleteIfExists( logName ); | |
| 238 | deleteIfExists( errName ); | |
| 239 | deleteIfExists( pdfName ); | |
| 240 | deleteIfExists( badName ); | |
| 241 | deleteIfExists( tuaName ); | |
| 242 | } | |
| 243 | } | |
| 244 | ||
| 245 | // Exit value for a successful invocation of the typesetter. This value | |
| 246 | // value is returned when creating the cache on the first run as well as | |
| 247 | // creating PDFs on subsequent runs (after the cache has been created). | |
| 248 | // Users don't care about exit codes, only whether the PDF was generated. | |
| 249 | return exit == 0; | |
| 250 | } | |
| 251 | ||
| 252 | private Path newExtension( final String baseName, final String ext ) { | |
| 253 | return getOutputPath().resolveSibling( removeExtension( baseName ) + ext ); | |
| 254 | } | |
| 255 | ||
| 256 | /** | |
| 257 | * Fires a status message for each line in the given file. The file format | |
| 258 | * is somewhat machine-readable, but no effort beyond line splitting is | |
| 259 | * made to parse the text. | |
| 260 | * | |
| 261 | * @param path Path to the file containing error messages. | |
| 262 | */ | |
| 263 | private void log( final Path path ) throws IOException { | |
| 264 | if( exists( path ) ) { | |
| 265 | log( readAllLines( path ) ); | |
| 266 | } | |
| 267 | } | |
| 268 | ||
| 269 | private void log( final List<String> lines ) { | |
| 270 | final var splits = new ArrayList<String>( lines.size() * 2 ); | |
| 271 | ||
| 272 | for( final var line : lines ) { | |
| 273 | splits.addAll( asList( line.split( "\\\\n" ) ) ); | |
| 274 | } | |
| 275 | ||
| 276 | clue( splits ); | |
| 277 | } | |
| 278 | ||
| 279 | /** | |
| 280 | * Returns the location of the cache directory. | |
| 281 | * | |
| 282 | * @return A fully qualified path to the location to store temporary | |
| 283 | * files between typesetting runs. | |
| 284 | */ | |
| 285 | private java.io.File getCacheDir() { | |
| 286 | final var temp = getProperty( "java.io.tmpdir" ); | |
| 287 | final var cache = Path.of( temp, "luatex-cache" ); | |
| 288 | return cache.toFile(); | |
| 289 | } | |
| 290 | ||
| 291 | /** | |
| 292 | * Answers whether the given directory is empty. The typesetting software | |
| 293 | * creates a non-empty directory by default. The return value from this | |
| 294 | * method is a proxy to answering whether the typesetter has been run for | |
| 295 | * the first time or not. | |
| 296 | * | |
| 297 | * @param path The directory to check for emptiness. | |
| 298 | * @return {@code true} if the directory is empty. | |
| 299 | */ | |
| 300 | private boolean isEmpty( final Path path ) { | |
| 301 | try( final var stream = newDirectoryStream( path ) ) { | |
| 302 | return !stream.iterator().hasNext(); | |
| 303 | } catch( final NoSuchFileException | FileNotFoundException ex ) { | |
| 304 | // A missing directory means it doesn't exist, ergo is empty. | |
| 305 | return true; | |
| 306 | } catch( final IOException ex ) { | |
| 307 | throw new RuntimeException( ex ); | |
| 308 | } | |
| 309 | } | |
| 310 | } | |
| 311 | ||
| 312 | /** | |
| 313 | * Responsible for parsing the output from the typesetting engine and | |
| 314 | * updating the status bar to provide assurance that typesetting is | |
| 315 | * executing. | |
| 316 | * | |
| 317 | * <p> | |
| 318 | * Example lines written to standard output: | |
| 319 | * </p> | |
| 320 | * <pre>{@code | |
| 321 | * pages > flushing realpage 15, userpage 15, subpage 15 | |
| 322 | * pages > flushing realpage 16, userpage 16, subpage 16 | |
| 323 | * pages > flushing realpage 1, userpage 1, subpage 1 | |
| 324 | * pages > flushing realpage 2, userpage 2, subpage 2 | |
| 325 | * }</pre> | |
| 326 | * <p> | |
| 327 | * The lines are parsed; the first number is displayed in a status bar | |
| 328 | * message. | |
| 329 | * </p> | |
| 330 | */ | |
| 331 | private static class PaginationListener extends Thread { | |
| 332 | private static final Pattern DIGITS = Pattern.compile( "[^\\d]+" ); | |
| 333 | ||
| 334 | private final InputStream mInputStream; | |
| 335 | ||
| 336 | private final Map<String, String> mCache; | |
| 337 | ||
| 338 | public PaginationListener( | |
| 339 | final InputStream in, final Map<String, String> cache ) { | |
| 340 | mInputStream = in; | |
| 341 | mCache = cache; | |
| 342 | } | |
| 343 | ||
| 344 | @Override | |
| 345 | public void run() { | |
| 346 | try( final var reader = createReader( mInputStream ) ) { | |
| 347 | int pageCount = 1; | |
| 348 | int passCount = 1; | |
| 349 | int pageTotal = 0; | |
| 350 | String line; | |
| 351 | ||
| 352 | while( (line = reader.readLine()) != null ) { | |
| 353 | mCache.put( line, "" ); | |
| 354 | ||
| 355 | if( line.startsWith( "pages" ) ) { | |
| 356 | // The bottleneck will be the typesetting engine writing to stdout, | |
| 357 | // not the parsing of stdout. | |
| 358 | final var scanner = new Scanner( line ).useDelimiter( DIGITS ); | |
| 359 | final var digits = scanner.next(); | |
| 360 | final var page = Integer.parseInt( digits ); | |
| 361 | ||
| 362 | // If the page number is less than the previous page count, it | |
| 363 | // means that the typesetting engine has started another pass. | |
| 364 | if( page < pageCount ) { | |
| 365 | passCount++; | |
| 366 | pageTotal = pageCount; | |
| 367 | } | |
| 368 | ||
| 369 | pageCount = page; | |
| 370 | ||
| 371 | // Inform the user of pages being typeset. | |
| 372 | clue( "Main.status.typeset.page", | |
| 373 | pageCount, pageTotal < 1 ? "?" : pageTotal, passCount | |
| 374 | ); | |
| 375 | } | |
| 376 | } | |
| 377 | } catch( final IOException ex ) { | |
| 378 | clue( ex ); | |
| 379 | throw new RuntimeException( ex ); | |
| 380 | } | |
| 381 | } | |
| 382 | ||
| 383 | private BufferedReader createReader( final InputStream inputStream ) { | |
| 384 | return new BufferedReader( new InputStreamReader( inputStream ) ); | |
| 385 | } | |
| 386 | } | |
| 387 | ||
| 388 | /** | |
| 389 | * Creates a new {@link Typesetter} instance capable of configuring the | |
| 390 | * typesetter used to generate a typeset document. | |
| 391 | */ | |
| 392 | private Typesetter( final Mutator mutator ) { | |
| 393 | assert mutator != null; | |
| 394 | ||
| 395 | mMutator = mutator; | |
| 396 | } | |
| 397 | ||
| 398 | /** | |
| 399 | * This will typeset the document using a new process. The return value only | |
| 400 | * indicates whether the typesetter exists, not whether the typesetting was | |
| 401 | * successful. | |
| 402 | * | |
| 403 | * @throws IOException If the process could not be started. | |
| 404 | * @throws InterruptedException If the process was killed. | |
| 405 | * @throws TypesetterNotFoundException When no typesetter is along the PATH. | |
| 406 | */ | |
| 407 | public void typeset() | |
| 408 | throws IOException, InterruptedException, TypesetterNotFoundException { | |
| 409 | if( TYPESETTER.canRun() ) { | |
| 410 | final var outputPath = getOutputPath(); | |
| 411 | ||
| 412 | clue( "Main.status.typeset.began", outputPath ); | |
| 413 | final var task = new TypesetTask(); | |
| 414 | final var time = currentTimeMillis(); | |
| 415 | final var success = task.typeset(); | |
| 416 | ||
| 417 | clue( "Main.status.typeset.ended." + (success ? "success" : "failure"), | |
| 418 | outputPath, since( time ) | |
| 419 | ); | |
| 420 | } | |
| 421 | else { | |
| 422 | throw new TypesetterNotFoundException( TYPESETTER.toString() ); | |
| 423 | } | |
| 424 | } | |
| 425 | ||
| 426 | private Path getInputPath() { | |
| 427 | return mMutator.mInputPath; | |
| 428 | } | |
| 429 | ||
| 430 | private Path getOutputPath() { | |
| 431 | return mMutator.mOutputPath; | |
| 432 | } | |
| 433 | ||
| 434 | private Path getThemePath() { | |
| 435 | return mMutator.mThemePath; | |
| 436 | } | |
| 437 | ||
| 438 | /** | |
| 439 | * Answers whether logs and other files should be deleted upon error. The | |
| 440 | * log files are useful for debugging. | |
| 441 | * | |
| 442 | * @return {@code true} to delete generated files. | |
| 443 | */ | |
| 444 | public boolean autoclean() { | |
| 445 | return mMutator.mAutoClean; | |
| 458 | 446 | } |
| 459 | 447 | } |
| 10 | 10 | import com.keenwrite.editors.markdown.HyperlinkModel; |
| 11 | 11 | import com.keenwrite.editors.markdown.LinkVisitor; |
| 12 | import com.keenwrite.events.CaretMovedEvent; | |
| 12 | 13 | import com.keenwrite.events.ExportFailedEvent; |
| 13 | 14 | import com.keenwrite.preferences.PreferencesController; |
| ... | ||
| 109 | 110 | } ); |
| 110 | 111 | |
| 111 | // When the active text editor changes, update the haystack. | |
| 112 | // When the active text editor changes ... | |
| 112 | 113 | mMainPane.textEditorProperty().addListener( |
| 113 | ( c, o, n ) -> mSearchModel.search( getActiveTextEditor().getText() ) | |
| 114 | ( c, o, n ) -> { | |
| 115 | // ... update the haystack. | |
| 116 | mSearchModel.search( getActiveTextEditor().getText() ); | |
| 117 | ||
| 118 | // ... update the status bar with the current caret position. | |
| 119 | if( n != null ) { | |
| 120 | CaretMovedEvent.fire( n.getCaret() ); | |
| 121 | } | |
| 122 | } | |
| 114 | 123 | ); |
| 115 | 124 | } |
| 1 | package com.keenwrite.ui.cells; | |
| 2 | ||
| 3 | import javafx.scene.control.cell.TextFieldTableCell; | |
| 4 | import javafx.util.StringConverter; | |
| 5 | ||
| 6 | public class AltTableCell<S, T> extends TextFieldTableCell<S, T> { | |
| 7 | public AltTableCell( final StringConverter<T> converter ) { | |
| 8 | super( converter ); | |
| 9 | ||
| 10 | assert converter != null; | |
| 11 | ||
| 12 | new CellEditor( | |
| 13 | input -> commitEdit( getConverter().fromString( input ) ), | |
| 14 | graphicProperty() | |
| 15 | ); | |
| 16 | } | |
| 17 | } | |
| 1 | 18 |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.ui.cells; | |
| 3 | ||
| 4 | import javafx.scene.control.cell.TextFieldTreeCell; | |
| 5 | import javafx.util.StringConverter; | |
| 6 | ||
| 7 | /** | |
| 8 | * Responsible for enhancing the existing cell behaviour with fairly common | |
| 9 | * functionality, including commit on focus loss and Enter to commit. | |
| 10 | * | |
| 11 | * @param <T> The type of data stored by the tree. | |
| 12 | */ | |
| 13 | public class AltTreeCell<T> extends TextFieldTreeCell<T> { | |
| 14 | public AltTreeCell( final StringConverter<T> converter ) { | |
| 15 | super( converter ); | |
| 16 | ||
| 17 | assert converter != null; | |
| 18 | ||
| 19 | new CellEditor( | |
| 20 | input -> commitEdit( getConverter().fromString( input ) ), | |
| 21 | graphicProperty() | |
| 22 | ); | |
| 23 | } | |
| 24 | } | |
| 1 | 25 |
| 1 | package com.keenwrite.ui.cells; | |
| 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 |
| 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 | } | |
| 107 | 1 |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.ui.listeners; | |
| 3 | ||
| 4 | import com.keenwrite.Caret; | |
| 5 | import com.keenwrite.editors.TextEditor; | |
| 6 | import com.keenwrite.events.WordCountEvent; | |
| 7 | import javafx.beans.property.ReadOnlyObjectProperty; | |
| 8 | import javafx.beans.value.ChangeListener; | |
| 9 | import javafx.beans.value.ObservableValue; | |
| 10 | import javafx.scene.control.Label; | |
| 11 | import javafx.scene.layout.VBox; | |
| 12 | import org.greenrobot.eventbus.Subscribe; | |
| 13 | ||
| 14 | import static com.keenwrite.events.Bus.register; | |
| 15 | import static javafx.application.Platform.runLater; | |
| 16 | import static javafx.geometry.Pos.BASELINE_CENTER; | |
| 17 | ||
| 18 | /** | |
| 19 | * Responsible for updating the UI whenever the caret changes position. | |
| 20 | * Only one instance of {@link CaretListener} is allowed, which prevents | |
| 21 | * duplicate adds to the observable property. | |
| 22 | */ | |
| 23 | public class CaretListener extends VBox implements ChangeListener<Integer> { | |
| 24 | ||
| 25 | /** | |
| 26 | * Use an instance of {@link Label} for its built-in CSS style class. | |
| 27 | */ | |
| 28 | private final Label mLineNumberText = new Label(); | |
| 29 | private volatile Caret mCaret; | |
| 30 | ||
| 31 | /** | |
| 32 | * Approximate number of words in the document. | |
| 33 | */ | |
| 34 | private volatile int mCount; | |
| 35 | ||
| 36 | public CaretListener( final ReadOnlyObjectProperty<TextEditor> editor ) { | |
| 37 | assert editor != null; | |
| 38 | ||
| 39 | setAlignment( BASELINE_CENTER ); | |
| 40 | getChildren().add( mLineNumberText ); | |
| 41 | ||
| 42 | editor.addListener( ( c, o, n ) -> { | |
| 43 | if( n != null ) { | |
| 44 | updateListener( n.getCaret() ); | |
| 45 | } | |
| 46 | } ); | |
| 47 | ||
| 48 | updateListener( editor.get().getCaret() ); | |
| 49 | register( this ); | |
| 50 | } | |
| 51 | ||
| 52 | /** | |
| 53 | * Called whenever the caret position changes. | |
| 54 | * | |
| 55 | * @param c The caret position property. | |
| 56 | * @param o The old caret position offset. | |
| 57 | * @param n The new caret position offset. | |
| 58 | */ | |
| 59 | @Override | |
| 60 | public void changed( | |
| 61 | final ObservableValue<? extends Integer> c, | |
| 62 | final Integer o, final Integer n ) { | |
| 63 | updateLineNumber(); | |
| 64 | } | |
| 65 | ||
| 66 | @Subscribe | |
| 67 | public void handle( final WordCountEvent event ) { | |
| 68 | mCount = event.getCount(); | |
| 69 | updateLineNumber(); | |
| 70 | } | |
| 71 | ||
| 72 | private void updateListener( final Caret caret ) { | |
| 73 | assert caret != null; | |
| 74 | ||
| 75 | final var property = caret.textOffsetProperty(); | |
| 76 | ||
| 77 | property.removeListener( this ); | |
| 78 | mCaret = caret; | |
| 79 | property.addListener( this ); | |
| 80 | updateLineNumber(); | |
| 81 | } | |
| 82 | ||
| 83 | private void updateLineNumber() { | |
| 84 | runLater( | |
| 85 | () -> mLineNumberText.setText( mCaret.toString() + " | " + mCount ) | |
| 86 | ); | |
| 87 | } | |
| 88 | } | |
| 89 | 1 |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.ui.listeners; | |
| 3 | ||
| 4 | import com.keenwrite.editors.common.Caret; | |
| 5 | import com.keenwrite.events.CaretMovedEvent; | |
| 6 | import com.keenwrite.events.WordCountEvent; | |
| 7 | import javafx.scene.control.Label; | |
| 8 | import javafx.scene.layout.VBox; | |
| 9 | import org.greenrobot.eventbus.Subscribe; | |
| 10 | ||
| 11 | import static com.keenwrite.events.Bus.register; | |
| 12 | import static javafx.application.Platform.runLater; | |
| 13 | import static javafx.geometry.Pos.BASELINE_CENTER; | |
| 14 | ||
| 15 | /** | |
| 16 | * Responsible for updating the UI whenever the caret changes position. | |
| 17 | * Only one instance of {@link CaretStatus} is allowed, which prevents | |
| 18 | * duplicate adds to the observable property. | |
| 19 | */ | |
| 20 | public class CaretStatus extends VBox { | |
| 21 | ||
| 22 | /** | |
| 23 | * Use an instance of {@link Label} for its built-in CSS style class. | |
| 24 | */ | |
| 25 | private final Label mStatusText = new Label(); | |
| 26 | ||
| 27 | /** | |
| 28 | * Contains caret position information within an editor. | |
| 29 | */ | |
| 30 | private volatile Caret mCaret = Caret.builder().build(); | |
| 31 | ||
| 32 | /** | |
| 33 | * Approximate number of words in the document. | |
| 34 | */ | |
| 35 | private volatile int mCount; | |
| 36 | ||
| 37 | public CaretStatus() { | |
| 38 | setAlignment( BASELINE_CENTER ); | |
| 39 | getChildren().add( mStatusText ); | |
| 40 | register( this ); | |
| 41 | } | |
| 42 | ||
| 43 | @Subscribe | |
| 44 | public void handle( final WordCountEvent event ) { | |
| 45 | mCount = event.getCount(); | |
| 46 | updateStatus( mCaret, mCount ); | |
| 47 | } | |
| 48 | ||
| 49 | @Subscribe | |
| 50 | public void handle( final CaretMovedEvent event ) { | |
| 51 | mCaret = event.getCaret(); | |
| 52 | updateStatus( mCaret, mCount ); | |
| 53 | } | |
| 54 | ||
| 55 | private void updateStatus( final Caret caret, final int count ) { | |
| 56 | assert caret != null; | |
| 57 | runLater( () -> mStatusText.setText( caret + " | " + count ) ); | |
| 58 | } | |
| 59 | } | |
| 1 | 60 |
| 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 | } | |
| 19 | 1 |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.ui.tree; | |
| 3 | ||
| 4 | import com.keenwrite.ui.common.CellEditor; | |
| 5 | import javafx.scene.control.cell.TextFieldTreeCell; | |
| 6 | import javafx.util.StringConverter; | |
| 7 | ||
| 8 | /** | |
| 9 | * Responsible for enhancing the existing cell behaviour with fairly common | |
| 10 | * functionality, including commit on focus loss and Enter to commit. | |
| 11 | * | |
| 12 | * @param <T> The type of data stored by the tree. | |
| 13 | */ | |
| 14 | public class AltTreeCell<T> extends TextFieldTreeCell<T> { | |
| 15 | public AltTreeCell( final StringConverter<T> converter ) { | |
| 16 | super( converter ); | |
| 17 | ||
| 18 | assert converter != null; | |
| 19 | ||
| 20 | new CellEditor( | |
| 21 | input -> commitEdit( getConverter().fromString( input ) ), | |
| 22 | graphicProperty() | |
| 23 | ); | |
| 24 | } | |
| 25 | } | |
| 26 | 1 |
| 2 | 2 | package com.keenwrite.ui.tree; |
| 3 | 3 | |
| 4 | import com.keenwrite.ui.cells.AltTreeCell; | |
| 4 | 5 | import javafx.scene.control.TreeItem; |
| 5 | 6 | import javafx.scene.control.TreeView; |
| 3 | 3 | import org.junit.jupiter.api.Test; |
| 4 | 4 | |
| 5 | import static com.keenwrite.constants.Constants.DIAGRAM_SERVER_NAME; | |
| 5 | 6 | import static com.keenwrite.preview.DiagramUrlGenerator.toUrl; |
| 6 | 7 | import static org.junit.jupiter.api.Assertions.assertEquals; |
| 7 | 8 | |
| 8 | 9 | /** |
| 9 | 10 | * Responsible for testing that images sent to the diagram server will render. |
| 10 | 11 | */ |
| 11 | 12 | class DiagramUrlGeneratorTest { |
| 12 | private final static String SERVER_NAME = "kroki.io"; | |
| 13 | ||
| 14 | 13 | // @formatter:off |
| 15 | 14 | private final static String[] DIAGRAMS = new String[]{ |
| ... | ||
| 43 | 42 | final var text = DIAGRAMS[ i + 1 ]; |
| 44 | 43 | final var expected = DIAGRAMS[ i + 2 ]; |
| 45 | final var actual = toUrl( SERVER_NAME, name, text ); | |
| 44 | final var actual = toUrl( DIAGRAM_SERVER_NAME, name, text ); | |
| 46 | 45 | |
| 47 | 46 | assertEquals( expected, actual ); |
| 3 | 3 | |
| 4 | 4 | import com.keenwrite.AwaitFxExtension; |
| 5 | import com.keenwrite.Caret; | |
| 6 | import com.keenwrite.preferences.Workspace; | |
| 5 | import com.keenwrite.editors.common.Caret; | |
| 7 | 6 | import com.keenwrite.processors.Processor; |
| 8 | 7 | import com.keenwrite.processors.ProcessorContext; |
| ... | ||
| 40 | 39 | @SuppressWarnings( "SameParameterValue" ) |
| 41 | 40 | public class ImageLinkExtensionTest { |
| 42 | private static final Workspace sWorkspace = new Workspace( | |
| 43 | getResourceFile( "workspace.xml" ) ); | |
| 44 | ||
| 45 | 41 | private static final Map<String, String> IMAGES = new HashMap<>(); |
| 46 | 42 | |
| ... | ||
| 147 | 143 | .with( ProcessorContext.Mutator::setInputPath, inputPath ) |
| 148 | 144 | .with( ProcessorContext.Mutator::setExportFormat, XHTML_TEX ) |
| 149 | .with( ProcessorContext.Mutator::setWorkspace, sWorkspace ) | |
| 150 | 145 | .with( ProcessorContext.Mutator::setCaret, () -> Caret.builder().build() ) |
| 151 | 146 | .build(); |
| ... | ||
| 173 | 168 | private static String getResource( final String path ) { |
| 174 | 169 | return toUri( path ).toString(); |
| 175 | } | |
| 176 | ||
| 177 | private static File getResourceFile( final String path ) { | |
| 178 | return new File( getResource( path ) ); | |
| 179 | 170 | } |
| 180 | 171 | } |