| 84 | 84 | |
| 85 | 85 | This software is licensed under the [BSD 2-Clause License](LICENSE.md) and |
| 86 | based on [Markdown-Writer-FX](licenses/MARKDOWN-WRITER-FX.md). | |
| 86 | based on [Markdown-Writer-FX](https://github.com/JFormDesigner/markdown-writer-fx/blob/main/LICENSE). | |
| 87 | 87 | |
| 88 | 88 |
| 66 | 66 | |
| 67 | 67 | // Pure JavaFX File Chooser |
| 68 | implementation "com.io7m.jwheatsheaf:com.io7m.jwheatsheaf:${v_wheatsheaf}" | |
| 69 | implementation "com.io7m.jwheatsheaf:com.io7m.jwheatsheaf.api:${v_wheatsheaf}" | |
| 70 | implementation "com.io7m.jwheatsheaf:com.io7m.jwheatsheaf.ui:${v_wheatsheaf}" | |
| 68 | // TODO: Reinstate when file picker performance increases | |
| 69 | // implementation "com.io7m.jwheatsheaf:com.io7m.jwheatsheaf:${v_wheatsheaf}" | |
| 70 | // implementation "com.io7m.jwheatsheaf:com.io7m.jwheatsheaf.api:${v_wheatsheaf}" | |
| 71 | // implementation "com.io7m.jwheatsheaf:com.io7m.jwheatsheaf.ui:${v_wheatsheaf}" | |
| 71 | 72 | |
| 72 | 73 | // Markdown |
| ... | ||
| 123 | 124 | //noinspection GradlePackageUpdate |
| 124 | 125 | implementation 'commons-beanutils:commons-beanutils:1.9.4' |
| 126 | ||
| 127 | // Command-line parsing | |
| 128 | implementation 'info.picocli:picocli:4.6.2' | |
| 125 | 129 | |
| 126 | 130 | // Spelling, TeX, Docking, KeenQuotes |
| 14 | 14 | --add-exports=javafx.graphics/com.sun.javafx.scene.text=ALL-UNNAMED \ |
| 15 | 15 | --add-exports=javafx.graphics/com.sun.javafx.scene.traversal=ALL-UNNAMED \ |
| 16 | -jar keenwrite.jar | |
| 16 | -jar keenwrite.jar $@ | |
| 17 | 17 | |
| 18 | 18 |
| 1 | package com.keenwrite; | |
| 2 | ||
| 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 | import com.keenwrite.util.AlphanumComparator; | |
| 8 | ||
| 9 | import java.io.IOException; | |
| 10 | import java.nio.file.Path; | |
| 11 | import java.util.ArrayList; | |
| 12 | import java.util.concurrent.Callable; | |
| 13 | import java.util.concurrent.CompletableFuture; | |
| 14 | import java.util.concurrent.ExecutorService; | |
| 15 | ||
| 16 | import static com.keenwrite.ExportFormat.*; | |
| 17 | import static com.keenwrite.processors.ProcessorFactory.createProcessors; | |
| 18 | import static com.keenwrite.util.FileWalker.walk; | |
| 19 | import static java.lang.System.lineSeparator; | |
| 20 | import static java.nio.file.Files.readString; | |
| 21 | import static java.nio.file.Files.writeString; | |
| 22 | import static java.util.concurrent.Executors.newFixedThreadPool; | |
| 23 | import static org.apache.commons.io.FilenameUtils.getExtension; | |
| 24 | ||
| 25 | /** | |
| 26 | * Responsible for executing common commands. These commands are shared by | |
| 27 | * both the graphical and the command-line interfaces. | |
| 28 | */ | |
| 29 | public class AppCommands { | |
| 30 | private static final ExecutorService sExecutor = newFixedThreadPool( 1 ); | |
| 31 | ||
| 32 | /** | |
| 33 | * Sci-fi genres, which are can be longer than other genres, typically fall | |
| 34 | * below 150,000 words at 6 chars per word. This reduces re-allocations of | |
| 35 | * memory when concatenating files together when exporting novels. | |
| 36 | */ | |
| 37 | private static final int DOCUMENT_LENGTH = 150_000 * 6; | |
| 38 | ||
| 39 | private AppCommands() { | |
| 40 | } | |
| 41 | ||
| 42 | public static void run( final Arguments args ) { | |
| 43 | final var context = args.createProcessorContext(); | |
| 44 | } | |
| 45 | ||
| 46 | /** | |
| 47 | * Converts one or more files into the given file format. If {@code dir} | |
| 48 | * is set to true, this will first append all files in the same directory | |
| 49 | * as the actively edited file. | |
| 50 | * | |
| 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. | |
| 55 | */ | |
| 56 | private void file_export( | |
| 57 | final Path inputPath, | |
| 58 | final ExportFormat format, | |
| 59 | final boolean concat, | |
| 60 | final CompletableFuture<Path> future ) { | |
| 61 | final Callable<Path> callableTask = () -> { | |
| 62 | try { | |
| 63 | final var context = ProcessorContext.create( inputPath, format ); | |
| 64 | final var outputPath = format.toExportPath( inputPath ); | |
| 65 | final var chain = createProcessors( context ); | |
| 66 | final var inputDoc = read( inputPath, concat ); | |
| 67 | final var outputDoc = chain.apply( inputDoc ); | |
| 68 | ||
| 69 | // Processors can export binary files. In such cases, processors will | |
| 70 | // return null to prevent further processing. | |
| 71 | final var result = | |
| 72 | outputDoc == null ? null : writeString( outputPath, outputDoc ); | |
| 73 | ||
| 74 | future.complete( result ); | |
| 75 | return result; | |
| 76 | } catch( final Exception ex ) { | |
| 77 | future.completeExceptionally( ex ); | |
| 78 | return null; | |
| 79 | } | |
| 80 | }; | |
| 81 | ||
| 82 | // Prevent the application from blocking while the processor executes. | |
| 83 | 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 | private void file_export_pdf( final Path theme, final boolean concat ) { | |
| 91 | if( Typesetter.canRun() ) { | |
| 92 | // If the typesetter is installed, allow the user to select a theme. If | |
| 93 | // the themes aren't installed, a status message will appear. | |
| 94 | if( ThemePicker.choose( themes, theme ) ) { | |
| 95 | file_export( APPLICATION_PDF, concat ); | |
| 96 | } | |
| 97 | } | |
| 98 | else { | |
| 99 | fireExportFailedEvent(); | |
| 100 | } | |
| 101 | } | |
| 102 | ||
| 103 | public void file_export_pdf() { | |
| 104 | file_export_pdf( false ); | |
| 105 | } | |
| 106 | ||
| 107 | public void file_export_pdf_dir() { | |
| 108 | file_export_pdf( true ); | |
| 109 | } | |
| 110 | ||
| 111 | public void file_export_html_svg() { | |
| 112 | file_export( HTML_TEX_SVG ); | |
| 113 | } | |
| 114 | ||
| 115 | public void file_export_html_tex() { | |
| 116 | file_export( HTML_TEX_DELIMITED ); | |
| 117 | } | |
| 118 | ||
| 119 | public void file_export_xhtml_tex() { | |
| 120 | file_export( XHTML_TEX ); | |
| 121 | } | |
| 122 | ||
| 123 | public void file_export_markdown() { | |
| 124 | file_export( MARKDOWN_PLAIN ); | |
| 125 | } | |
| 126 | */ | |
| 127 | ||
| 128 | /** | |
| 129 | * Concatenates all the files in the same directory as the given file into | |
| 130 | * a string. The extension is determined by the given file name pattern; the | |
| 131 | * order files are concatenated is based on their numeric sort order (this | |
| 132 | * avoids lexicographic sorting). | |
| 133 | * <p> | |
| 134 | * If the parent path to the file being edited in the text editor cannot | |
| 135 | * be found then this will return the editor's text, without iterating through | |
| 136 | * the parent directory. (Should never happen, but who knows?) | |
| 137 | * </p> | |
| 138 | * <p> | |
| 139 | * New lines are automatically appended to separate each file. | |
| 140 | * </p> | |
| 141 | * | |
| 142 | * @param inputPath The path to the source file to read. | |
| 143 | * @param concat {@code true} to concatenate all files with the same | |
| 144 | * extension as the source path. | |
| 145 | * @return All files in the same directory as the file being edited | |
| 146 | * concatenated into a single string. | |
| 147 | */ | |
| 148 | private String read( final Path inputPath, final boolean concat ) | |
| 149 | throws IOException { | |
| 150 | final var parent = inputPath.getParent(); | |
| 151 | final var filename = inputPath.getFileName().toString(); | |
| 152 | final var extension = getExtension( filename ); | |
| 153 | ||
| 154 | // Short-circuit because: only one file was requested; there is no parent | |
| 155 | // directory to scan for files; or there's no extension for globbing. | |
| 156 | if( !concat || parent == null || extension.isBlank() ) { | |
| 157 | return readString( inputPath ); | |
| 158 | } | |
| 159 | ||
| 160 | final var glob = "**/*." + extension; | |
| 161 | final var files = new ArrayList<Path>(); | |
| 162 | walk( parent, glob, files::add ); | |
| 163 | files.sort( new AlphanumComparator<>() ); | |
| 164 | ||
| 165 | final var text = new StringBuilder( DOCUMENT_LENGTH ); | |
| 166 | final var eol = lineSeparator(); | |
| 167 | ||
| 168 | for( final var file : files ) { | |
| 169 | text.append( readString( file ) ); | |
| 170 | text.append( eol ); | |
| 171 | } | |
| 172 | ||
| 173 | return text.toString(); | |
| 174 | } | |
| 175 | } | |
| 1 | 176 |
| 10 | 10 | import java.util.Collection; |
| 11 | 11 | |
| 12 | import static com.keenwrite.constants.Constants.STATUS_BAR_LINE; | |
| 13 | 12 | import static com.keenwrite.Messages.get; |
| 13 | import static com.keenwrite.constants.Constants.STATUS_BAR_LINE; | |
| 14 | 14 | |
| 15 | 15 | /** |
| 5 | 5 | import java.nio.file.Path; |
| 6 | 6 | |
| 7 | import static java.lang.String.format; | |
| 7 | 8 | import static org.apache.commons.io.FilenameUtils.removeExtension; |
| 8 | 9 | |
| ... | ||
| 53 | 54 | ExportFormat( final String extension ) { |
| 54 | 55 | mExtension = extension; |
| 56 | } | |
| 57 | ||
| 58 | /** | |
| 59 | * Looks up the {@link ExportFormat} based on the given format type and | |
| 60 | * subtype combination. | |
| 61 | * | |
| 62 | * @param type The type to find. | |
| 63 | * @param subtype The subtype to find (for HTML). | |
| 64 | * @return An object that defines the export format according to the given | |
| 65 | * parameters. | |
| 66 | * @throws IllegalArgumentException Could not determine the type and | |
| 67 | * subtype combination. | |
| 68 | */ | |
| 69 | public static ExportFormat valueFrom( | |
| 70 | final String type, | |
| 71 | final String subtype ) throws IllegalArgumentException { | |
| 72 | assert type != null; | |
| 73 | assert subtype != null; | |
| 74 | ||
| 75 | return switch( type.trim().toLowerCase() ) { | |
| 76 | case "html" -> "svg".equalsIgnoreCase( subtype.trim() ) | |
| 77 | ? HTML_TEX_SVG | |
| 78 | : HTML_TEX_DELIMITED; | |
| 79 | case "md" -> MARKDOWN_PLAIN; | |
| 80 | case "pdf" -> APPLICATION_PDF; | |
| 81 | default -> throw new IllegalArgumentException( format( | |
| 82 | "Unrecognized format type and subtype: '%s' and '%s'", type, subtype | |
| 83 | ) ); | |
| 84 | }; | |
| 55 | 85 | } |
| 56 | 86 | |
| ... | ||
| 75 | 105 | public File toExportFilename( final Path path ) { |
| 76 | 106 | return toExportFilename( path.toFile() ); |
| 107 | } | |
| 108 | ||
| 109 | public Path toExportPath( final Path path ) { | |
| 110 | return toExportFilename( path ).toPath(); | |
| 77 | 111 | } |
| 78 | 112 | } |
| 2 | 2 | package com.keenwrite; |
| 3 | 3 | |
| 4 | import com.keenwrite.cmdline.Arguments; | |
| 5 | import com.keenwrite.cmdline.ColourScheme; | |
| 6 | import com.keenwrite.cmdline.HeadlessApp; | |
| 7 | import picocli.CommandLine; | |
| 8 | ||
| 4 | 9 | import java.io.IOException; |
| 5 | 10 | import java.io.InputStream; |
| 6 | 11 | import java.util.Properties; |
| 12 | import java.util.function.Consumer; | |
| 13 | import java.util.logging.LogManager; | |
| 7 | 14 | |
| 8 | 15 | import static com.keenwrite.Bootstrap.*; |
| ... | ||
| 18 | 25 | * </p> |
| 19 | 26 | */ |
| 20 | public final class Launcher { | |
| 27 | public final class Launcher implements Consumer<Arguments> { | |
| 28 | ||
| 21 | 29 | /** |
| 22 | * Delegates to the application entry point. | |
| 30 | * Needed for the GUI. | |
| 31 | */ | |
| 32 | private final String[] mArgs; | |
| 33 | ||
| 34 | /** | |
| 35 | * Delegates running the application via the command-line argument parser. | |
| 36 | * This is the main entry point for the application, regardless of whether | |
| 37 | * run from the command-line or as a GUI. | |
| 23 | 38 | * |
| 24 | 39 | * @param args Command-line arguments. |
| 25 | 40 | */ |
| 26 | 41 | public static void main( final String[] args ) { |
| 42 | installTrustManager(); | |
| 43 | parse( args ); | |
| 44 | } | |
| 45 | ||
| 46 | /** | |
| 47 | * @param args Command-line arguments (passed into the GUI). | |
| 48 | */ | |
| 49 | public Launcher( final String[] args ) { | |
| 50 | mArgs = args; | |
| 51 | } | |
| 52 | ||
| 53 | /** | |
| 54 | * Called after the arguments have been parsed. | |
| 55 | * | |
| 56 | * @param args The parsed command-line arguments. | |
| 57 | */ | |
| 58 | @Override | |
| 59 | public void accept( final Arguments args ) { | |
| 60 | assert args != null; | |
| 61 | ||
| 27 | 62 | try { |
| 28 | installTrustManager(); | |
| 29 | showAppInfo(); | |
| 30 | MainApp.main( args ); | |
| 63 | int argCount = mArgs.length; | |
| 64 | ||
| 65 | if( args.quiet() ) { | |
| 66 | argCount--; | |
| 67 | } | |
| 68 | else { | |
| 69 | showAppInfo(); | |
| 70 | } | |
| 71 | ||
| 72 | if( args.debug() ) { | |
| 73 | argCount--; | |
| 74 | } | |
| 75 | else { | |
| 76 | disableLogging(); | |
| 77 | } | |
| 78 | ||
| 79 | if( argCount <= 0 ) { | |
| 80 | // When no command-line arguments are provided, launch the GUI. | |
| 81 | MainApp.main( mArgs ); | |
| 82 | } | |
| 83 | else { | |
| 84 | // When command-line arguments are supplied, run in headless mode. | |
| 85 | HeadlessApp.main( args ); | |
| 86 | } | |
| 31 | 87 | } catch( final Throwable t ) { |
| 32 | 88 | log( t ); |
| 33 | 89 | } |
| 34 | 90 | } |
| 35 | 91 | |
| 36 | @SuppressWarnings( "RedundantStringFormatCall" ) | |
| 37 | private static void showAppInfo() { | |
| 38 | out( format( "%s version %s", APP_TITLE, APP_VERSION ) ); | |
| 39 | out( format( "Copyright 2016-%s White Magic Software, Ltd.", APP_YEAR ) ); | |
| 40 | out( format( "Portions copyright 2015-2020 Karl Tauber." ) ); | |
| 92 | private static void parse( final String[] args ) { | |
| 93 | assert args != null; | |
| 94 | ||
| 95 | final var arguments = new Arguments( new Launcher( args ) ); | |
| 96 | final var parser = new CommandLine( arguments ); | |
| 97 | ||
| 98 | parser.setColorScheme( ColourScheme.create() ); | |
| 99 | ||
| 100 | final var exitCode = parser.execute( args ); | |
| 101 | final var parseResult = parser.getParseResult(); | |
| 102 | ||
| 103 | if( parseResult.isUsageHelpRequested() ) { | |
| 104 | System.exit( exitCode ); | |
| 105 | } | |
| 41 | 106 | } |
| 42 | 107 | |
| 43 | private static void out( final String s ) { | |
| 44 | System.out.println( s ); | |
| 108 | /** | |
| 109 | * Suppress writing to standard error, suppresses writing log messages. | |
| 110 | */ | |
| 111 | private static void disableLogging() { | |
| 112 | LogManager.getLogManager().reset(); | |
| 113 | System.err.close(); | |
| 114 | } | |
| 115 | ||
| 116 | private static void showAppInfo() { | |
| 117 | out( "%n%s version %s", APP_TITLE, APP_VERSION ); | |
| 118 | out( "Copyright 2016-%s White Magic Software, Ltd.", APP_YEAR ); | |
| 119 | out( "Portions copyright 2015-2020 Karl Tauber.%n" ); | |
| 45 | 120 | } |
| 46 | 121 | |
| ... | ||
| 91 | 166 | |
| 92 | 167 | if( message != null && message.toLowerCase().contains( "javafx" ) ) { |
| 93 | message = "Re-run using a Java Runtime Environment that includes JavaFX."; | |
| 168 | message = "Run using a Java Runtime Environment that includes JavaFX."; | |
| 169 | out( "ERROR: %s", message ); | |
| 170 | } | |
| 171 | else { | |
| 172 | error.printStackTrace( System.err ); | |
| 94 | 173 | } |
| 174 | } | |
| 95 | 175 | |
| 96 | out( format( "ERROR: %s", message ) ); | |
| 176 | /** | |
| 177 | * Writes the given placeholder text to standard output with a new line | |
| 178 | * appended. | |
| 179 | * | |
| 180 | * @param message The format string specifier. | |
| 181 | * @param args The arguments to substitute into the format string. | |
| 182 | */ | |
| 183 | private static void out( final String message, final Object... args ) { | |
| 184 | System.out.printf( format( "%s%n", message ), args ); | |
| 97 | 185 | } |
| 98 | 186 | } |
| 4 | 4 | import com.keenwrite.events.HyperlinkOpenEvent; |
| 5 | 5 | import com.keenwrite.preferences.Workspace; |
| 6 | import com.keenwrite.util.ArrayScanner; | |
| 7 | 6 | import javafx.application.Application; |
| 8 | 7 | import javafx.event.Event; |
| 9 | 8 | import javafx.event.EventType; |
| 10 | 9 | import javafx.scene.input.KeyCode; |
| 11 | 10 | import javafx.scene.input.KeyEvent; |
| 12 | 11 | import javafx.stage.Stage; |
| 13 | 12 | import org.greenrobot.eventbus.Subscribe; |
| 14 | 13 | |
| 15 | 14 | import java.util.function.BooleanSupplier; |
| 16 | import java.util.logging.LogManager; | |
| 17 | 15 | |
| 18 | 16 | import static com.keenwrite.Bootstrap.APP_TITLE; |
| ... | ||
| 42 | 40 | */ |
| 43 | 41 | public static void main( final String[] args ) { |
| 44 | if( !ArrayScanner.contains( args, "--debug" ) ) { | |
| 45 | disableLogging(); | |
| 46 | } | |
| 47 | ||
| 48 | 42 | launch( args ); |
| 49 | } | |
| 50 | ||
| 51 | /** | |
| 52 | * Suppress logging to standard output and standard error. | |
| 53 | */ | |
| 54 | private static void disableLogging() { | |
| 55 | LogManager.getLogManager().reset(); | |
| 56 | System.err.close(); | |
| 57 | 43 | } |
| 58 | 44 | |
| 21 | 21 | import com.keenwrite.sigils.RSigilOperator; |
| 22 | 22 | import com.keenwrite.sigils.SigilOperator; |
| 23 | import com.keenwrite.sigils.Tokens; | |
| 24 | import com.keenwrite.sigils.YamlSigilOperator; | |
| 25 | import com.keenwrite.ui.explorer.FilePickerFactory; | |
| 26 | import com.keenwrite.ui.heuristics.DocumentStatistics; | |
| 27 | import com.keenwrite.ui.outline.DocumentOutline; | |
| 28 | import com.panemu.tiwulfx.control.dock.DetachableTab; | |
| 29 | import com.panemu.tiwulfx.control.dock.DetachableTabPane; | |
| 30 | import javafx.application.Platform; | |
| 31 | import javafx.beans.property.*; | |
| 32 | import javafx.collections.ListChangeListener; | |
| 33 | import javafx.concurrent.Task; | |
| 34 | import javafx.event.ActionEvent; | |
| 35 | import javafx.event.Event; | |
| 36 | import javafx.event.EventHandler; | |
| 37 | import javafx.scene.Node; | |
| 38 | import javafx.scene.Scene; | |
| 39 | import javafx.scene.control.*; | |
| 40 | import javafx.scene.control.TreeItem.TreeModificationEvent; | |
| 41 | import javafx.scene.input.KeyEvent; | |
| 42 | import javafx.scene.layout.FlowPane; | |
| 43 | import javafx.stage.Stage; | |
| 44 | import javafx.stage.Window; | |
| 45 | import org.greenrobot.eventbus.Subscribe; | |
| 46 | ||
| 47 | import java.io.File; | |
| 48 | import java.io.FileNotFoundException; | |
| 49 | import java.nio.file.Path; | |
| 50 | import java.util.*; | |
| 51 | import java.util.concurrent.ExecutorService; | |
| 52 | import java.util.concurrent.ScheduledExecutorService; | |
| 53 | import java.util.concurrent.ScheduledFuture; | |
| 54 | import java.util.concurrent.atomic.AtomicBoolean; | |
| 55 | import java.util.concurrent.atomic.AtomicReference; | |
| 56 | import java.util.function.Function; | |
| 57 | import java.util.stream.Collectors; | |
| 58 | ||
| 59 | import static com.keenwrite.ExportFormat.NONE; | |
| 60 | import static com.keenwrite.Messages.get; | |
| 61 | import static com.keenwrite.constants.Constants.*; | |
| 62 | import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG_NODE; | |
| 63 | import static com.keenwrite.events.Bus.register; | |
| 64 | import static com.keenwrite.events.HyperlinkOpenEvent.fireHyperlinkOpenEvent; | |
| 65 | import static com.keenwrite.events.StatusEvent.clue; | |
| 66 | import static com.keenwrite.io.MediaType.*; | |
| 67 | import static com.keenwrite.preferences.WorkspaceKeys.*; | |
| 68 | import static com.keenwrite.processors.IdentityProcessor.IDENTITY; | |
| 69 | import static com.keenwrite.processors.ProcessorFactory.createProcessors; | |
| 70 | import static java.lang.String.format; | |
| 71 | import static java.lang.System.getProperty; | |
| 72 | import static java.util.concurrent.Executors.newFixedThreadPool; | |
| 73 | import static java.util.concurrent.Executors.newScheduledThreadPool; | |
| 74 | import static java.util.concurrent.TimeUnit.SECONDS; | |
| 75 | import static java.util.stream.Collectors.groupingBy; | |
| 76 | import static javafx.application.Platform.runLater; | |
| 77 | import static javafx.scene.control.Alert.AlertType.ERROR; | |
| 78 | import static javafx.scene.control.ButtonType.*; | |
| 79 | import static javafx.scene.control.TabPane.TabClosingPolicy.ALL_TABS; | |
| 80 | import static javafx.scene.input.KeyCode.SPACE; | |
| 81 | import static javafx.scene.input.KeyCombination.CONTROL_DOWN; | |
| 82 | import static javafx.util.Duration.millis; | |
| 83 | import static javax.swing.SwingUtilities.invokeLater; | |
| 84 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 85 | ||
| 86 | /** | |
| 87 | * Responsible for wiring together the main application components for a | |
| 88 | * particular workspace (project). These include the definition views, | |
| 89 | * text editors, and preview pane along with any corresponding controllers. | |
| 90 | */ | |
| 91 | public final class MainPane extends SplitPane { | |
| 92 | private static final ExecutorService sExecutor = newFixedThreadPool( 1 ); | |
| 93 | ||
| 94 | private final ScheduledExecutorService mSaver = newScheduledThreadPool( 1 ); | |
| 95 | private final AtomicReference<ScheduledFuture<?>> mSaveTask = | |
| 96 | new AtomicReference<>(); | |
| 97 | ||
| 98 | private static final Notifier sNotifier = Services.load( Notifier.class ); | |
| 99 | ||
| 100 | /** | |
| 101 | * Used when opening files to determine how each file should be binned and | |
| 102 | * therefore what tab pane to be opened within. | |
| 103 | */ | |
| 104 | private static final Set<MediaType> PLAIN_TEXT_FORMAT = Set.of( | |
| 105 | TEXT_MARKDOWN, TEXT_R_MARKDOWN, UNDEFINED | |
| 106 | ); | |
| 107 | ||
| 108 | /** | |
| 109 | * Prevents re-instantiation of processing classes. | |
| 110 | */ | |
| 111 | private final Map<TextResource, Processor<String>> mProcessors = | |
| 112 | new HashMap<>(); | |
| 113 | ||
| 114 | private final Workspace mWorkspace; | |
| 115 | ||
| 116 | /** | |
| 117 | * Groups similar file type tabs together. | |
| 118 | */ | |
| 119 | private final List<TabPane> mTabPanes = new ArrayList<>(); | |
| 120 | ||
| 121 | /** | |
| 122 | * Stores definition names and values. | |
| 123 | */ | |
| 124 | private final Map<String, String> mResolvedMap = | |
| 125 | new HashMap<>( MAP_SIZE_DEFAULT ); | |
| 126 | ||
| 127 | /** | |
| 128 | * Renders the actively selected plain text editor tab. | |
| 129 | */ | |
| 130 | private final HtmlPreview mPreview; | |
| 131 | ||
| 132 | /** | |
| 133 | * Provides an interactive document outline. | |
| 134 | */ | |
| 135 | private final DocumentOutline mOutline = new DocumentOutline(); | |
| 136 | ||
| 137 | /** | |
| 138 | * Changing the active editor fires the value changed event. This allows | |
| 139 | * refreshes to happen when external definitions are modified and need to | |
| 140 | * trigger the processing chain. | |
| 141 | */ | |
| 142 | private final ObjectProperty<TextEditor> mActiveTextEditor = | |
| 143 | createActiveTextEditor(); | |
| 144 | ||
| 145 | /** | |
| 146 | * Changing the active definition editor fires the value changed event. This | |
| 147 | * allows refreshes to happen when external definitions are modified and need | |
| 148 | * to trigger the processing chain. | |
| 149 | */ | |
| 150 | private final ObjectProperty<TextDefinition> mActiveDefinitionEditor = | |
| 151 | createActiveDefinitionEditor( mActiveTextEditor ); | |
| 152 | ||
| 153 | /** | |
| 154 | * Tracks the number of detached tab panels opened into their own windows, | |
| 155 | * which allows unique identification of subordinate windows by their title. | |
| 156 | * It is doubtful more than 128 windows, much less 256, will be created. | |
| 157 | */ | |
| 158 | private byte mWindowCount; | |
| 159 | ||
| 160 | /** | |
| 161 | * Called when the definition data is changed. | |
| 162 | */ | |
| 163 | private final EventHandler<TreeModificationEvent<Event>> mTreeHandler = | |
| 164 | event -> { | |
| 165 | final var editor = mActiveDefinitionEditor.get(); | |
| 166 | ||
| 167 | resolve( editor ); | |
| 168 | process( getActiveTextEditor() ); | |
| 169 | save( editor ); | |
| 170 | }; | |
| 171 | ||
| 172 | private final DocumentStatistics mStatistics; | |
| 173 | ||
| 174 | /** | |
| 175 | * Adds all content panels to the main user interface. This will load the | |
| 176 | * configuration settings from the workspace to reproduce the settings from | |
| 177 | * a previous session. | |
| 178 | */ | |
| 179 | public MainPane( final Workspace workspace ) { | |
| 180 | mWorkspace = workspace; | |
| 181 | mPreview = new HtmlPreview( workspace ); | |
| 182 | mStatistics = new DocumentStatistics( workspace ); | |
| 183 | mActiveTextEditor.set( new MarkdownEditor( workspace ) ); | |
| 184 | ||
| 185 | open( bin( getRecentFiles() ) ); | |
| 186 | viewPreview(); | |
| 187 | setDividerPositions( calculateDividerPositions() ); | |
| 188 | ||
| 189 | // Once the main scene's window regains focus, update the active definition | |
| 190 | // editor to the currently selected tab. | |
| 191 | runLater( () -> getWindow().setOnCloseRequest( ( event ) -> { | |
| 192 | // Order matters here. We want to close all the tabs to ensure each | |
| 193 | // is saved, but after they are closed, the workspace should still | |
| 194 | // retain the list of files that were open. If this line came after | |
| 195 | // closing, then restarting the application would list no files. | |
| 196 | mWorkspace.save(); | |
| 197 | ||
| 198 | if( closeAll() ) { | |
| 199 | Platform.exit(); | |
| 200 | System.exit( 0 ); | |
| 201 | } | |
| 202 | else { | |
| 203 | event.consume(); | |
| 204 | } | |
| 205 | } ) ); | |
| 206 | ||
| 207 | register( this ); | |
| 208 | initAutosave( workspace ); | |
| 209 | } | |
| 210 | ||
| 211 | @Subscribe | |
| 212 | public void handle( final TextEditorFocusEvent event ) { | |
| 213 | mActiveTextEditor.set( event.get() ); | |
| 214 | } | |
| 215 | ||
| 216 | @Subscribe | |
| 217 | public void handle( final TextDefinitionFocusEvent event ) { | |
| 218 | mActiveDefinitionEditor.set( event.get() ); | |
| 219 | } | |
| 220 | ||
| 221 | /** | |
| 222 | * Typically called when a file name is clicked in the preview panel. | |
| 223 | * | |
| 224 | * @param event The event to process, must contain a valid file reference. | |
| 225 | */ | |
| 226 | @Subscribe | |
| 227 | public void handle( final FileOpenEvent event ) { | |
| 228 | final File eventFile; | |
| 229 | final var eventUri = event.getUri(); | |
| 230 | ||
| 231 | if( eventUri.isAbsolute() ) { | |
| 232 | eventFile = new File( eventUri.getPath() ); | |
| 233 | } | |
| 234 | else { | |
| 235 | final var activeFile = getActiveTextEditor().getFile(); | |
| 236 | final var parent = activeFile.getParentFile(); | |
| 237 | ||
| 238 | if( parent == null ) { | |
| 239 | clue( new FileNotFoundException( eventUri.getPath() ) ); | |
| 240 | return; | |
| 241 | } | |
| 242 | else { | |
| 243 | final var parentPath = parent.getAbsolutePath(); | |
| 244 | eventFile = Path.of( parentPath, eventUri.getPath() ).toFile(); | |
| 245 | } | |
| 246 | } | |
| 247 | ||
| 248 | runLater( () -> open( eventFile ) ); | |
| 249 | } | |
| 250 | ||
| 251 | @Subscribe | |
| 252 | public void handle( final CaretNavigationEvent event ) { | |
| 253 | runLater( () -> { | |
| 254 | final var textArea = getActiveTextEditor().getTextArea(); | |
| 255 | textArea.moveTo( event.getOffset() ); | |
| 256 | textArea.requestFollowCaret(); | |
| 257 | textArea.requestFocus(); | |
| 258 | } ); | |
| 259 | } | |
| 260 | ||
| 261 | @Subscribe | |
| 262 | @SuppressWarnings( "unused" ) | |
| 263 | public void handle( final ExportFailedEvent event ) { | |
| 264 | final var os = getProperty( "os.name" ); | |
| 265 | final var arch = getProperty( "os.arch" ).toLowerCase(); | |
| 266 | final var bits = getProperty( "sun.arch.data.model" ); | |
| 267 | ||
| 268 | final var title = Messages.get( "Alert.typesetter.missing.title" ); | |
| 269 | final var header = Messages.get( "Alert.typesetter.missing.header" ); | |
| 270 | final var version = Messages.get( | |
| 271 | "Alert.typesetter.missing.version", | |
| 272 | os, | |
| 273 | arch | |
| 274 | .replaceAll( "amd.*|i.*|x86.*", "X86" ) | |
| 275 | .replaceAll( "mips.*", "MIPS" ) | |
| 276 | .replaceAll( "armv.*", "ARM" ), | |
| 277 | bits ); | |
| 278 | final var text = Messages.get( "Alert.typesetter.missing.installer.text" ); | |
| 279 | ||
| 280 | // Download and install ConTeXt for {0} {1} {2}-bit | |
| 281 | final var content = format( "%s %s", text, version ); | |
| 282 | final var flowPane = new FlowPane(); | |
| 283 | final var link = new Hyperlink( text ); | |
| 284 | final var label = new Label( version ); | |
| 285 | flowPane.getChildren().addAll( link, label ); | |
| 286 | ||
| 287 | final var alert = new Alert( ERROR, content, OK ); | |
| 288 | alert.setTitle( title ); | |
| 289 | alert.setHeaderText( header ); | |
| 290 | alert.getDialogPane().contentProperty().set( flowPane ); | |
| 291 | alert.setGraphic( ICON_DIALOG_NODE ); | |
| 292 | ||
| 293 | link.setOnAction( ( e ) -> { | |
| 294 | alert.close(); | |
| 295 | final var url = Messages.get( "Alert.typesetter.missing.installer.url" ); | |
| 296 | runLater( () -> fireHyperlinkOpenEvent( url ) ); | |
| 297 | } ); | |
| 298 | ||
| 299 | alert.showAndWait(); | |
| 300 | } | |
| 301 | ||
| 302 | private void initAutosave( final Workspace workspace ) { | |
| 303 | final var rate = workspace.integerProperty( KEY_EDITOR_AUTOSAVE ); | |
| 304 | ||
| 305 | rate.addListener( | |
| 306 | ( c, o, n ) -> { | |
| 307 | final var taskRef = mSaveTask.get(); | |
| 308 | ||
| 309 | // Prevent multiple autosaves from running. | |
| 310 | if( taskRef != null ) { | |
| 311 | taskRef.cancel( false ); | |
| 312 | } | |
| 313 | ||
| 314 | initAutosave( rate ); | |
| 315 | } | |
| 316 | ); | |
| 317 | ||
| 318 | // Start the save listener (avoids duplicating some code). | |
| 319 | initAutosave( rate ); | |
| 320 | } | |
| 321 | ||
| 322 | private void initAutosave( final IntegerProperty rate ) { | |
| 323 | mSaveTask.set( | |
| 324 | mSaver.scheduleAtFixedRate( | |
| 325 | () -> { | |
| 326 | if( getActiveTextEditor().isModified() ) { | |
| 327 | // Ensure the modified indicator is cleared by running on EDT. | |
| 328 | runLater( this::save ); | |
| 329 | } | |
| 330 | }, 0, rate.intValue(), SECONDS | |
| 331 | ) | |
| 332 | ); | |
| 333 | } | |
| 334 | ||
| 335 | /** | |
| 336 | * TODO: Load divider positions from exported settings, see | |
| 337 | * {@link #bin(SetProperty)} comment. | |
| 338 | */ | |
| 339 | private double[] calculateDividerPositions() { | |
| 340 | final var ratio = 100f / getItems().size() / 100; | |
| 341 | final var positions = getDividerPositions(); | |
| 342 | ||
| 343 | for( int i = 0; i < positions.length; i++ ) { | |
| 344 | positions[ i ] = ratio * i; | |
| 345 | } | |
| 346 | ||
| 347 | return positions; | |
| 348 | } | |
| 349 | ||
| 350 | /** | |
| 351 | * Opens all the files into the application, provided the paths are unique. | |
| 352 | * This may only be called for any type of files that a user can edit | |
| 353 | * (i.e., update and persist), such as definitions and text files. | |
| 354 | * | |
| 355 | * @param files The list of files to open. | |
| 356 | */ | |
| 357 | public void open( final List<File> files ) { | |
| 358 | files.forEach( this::open ); | |
| 359 | } | |
| 360 | ||
| 361 | /** | |
| 362 | * This opens the given file. Since the preview pane is not a file that | |
| 363 | * can be opened, it is safe to add a listener to the detachable pane. | |
| 364 | * | |
| 365 | * @param file The file to open. | |
| 366 | */ | |
| 367 | private void open( final File file ) { | |
| 368 | final var tab = createTab( file ); | |
| 369 | final var node = tab.getContent(); | |
| 370 | final var mediaType = MediaType.valueFrom( file ); | |
| 371 | final var tabPane = obtainTabPane( mediaType ); | |
| 372 | ||
| 373 | tab.setTooltip( createTooltip( file ) ); | |
| 374 | tabPane.setFocusTraversable( false ); | |
| 375 | tabPane.setTabClosingPolicy( ALL_TABS ); | |
| 376 | tabPane.getTabs().add( tab ); | |
| 377 | ||
| 378 | // Attach the tab scene factory for new tab panes. | |
| 379 | if( !getItems().contains( tabPane ) ) { | |
| 380 | addTabPane( | |
| 381 | node instanceof TextDefinition ? 0 : getItems().size(), tabPane | |
| 382 | ); | |
| 383 | } | |
| 384 | ||
| 385 | getRecentFiles().add( file.getAbsolutePath() ); | |
| 386 | } | |
| 387 | ||
| 388 | /** | |
| 389 | * Opens a new text editor document using the default document file name. | |
| 390 | */ | |
| 391 | public void newTextEditor() { | |
| 392 | open( DOCUMENT_DEFAULT ); | |
| 393 | } | |
| 394 | ||
| 395 | /** | |
| 396 | * Opens a new definition editor document using the default definition | |
| 397 | * file name. | |
| 398 | */ | |
| 399 | public void newDefinitionEditor() { | |
| 400 | open( DEFINITION_DEFAULT ); | |
| 401 | } | |
| 402 | ||
| 403 | /** | |
| 404 | * Iterates over all tab panes to find all {@link TextEditor}s and request | |
| 405 | * that they save themselves. | |
| 406 | */ | |
| 407 | public void saveAll() { | |
| 408 | mTabPanes.forEach( | |
| 409 | ( tp ) -> tp.getTabs().forEach( ( tab ) -> { | |
| 410 | final var node = tab.getContent(); | |
| 411 | if( node instanceof final TextEditor editor ) { | |
| 412 | save( editor ); | |
| 413 | } | |
| 414 | } ) | |
| 415 | ); | |
| 416 | } | |
| 417 | ||
| 418 | /** | |
| 419 | * Requests that the active {@link TextEditor} saves itself. Don't bother | |
| 420 | * checking if modified first because if the user swaps external media from | |
| 421 | * an external source (e.g., USB thumb drive), save should not second-guess | |
| 422 | * the user: save always re-saves. Also, it's less code. | |
| 423 | */ | |
| 424 | public void save() { | |
| 425 | save( getActiveTextEditor() ); | |
| 426 | } | |
| 427 | ||
| 428 | /** | |
| 429 | * Saves the active {@link TextEditor} under a new name. | |
| 430 | * | |
| 431 | * @param files The new active editor {@link File} reference, must contain | |
| 432 | * at least one element. | |
| 433 | */ | |
| 434 | public void saveAs( final List<File> files ) { | |
| 435 | assert files != null; | |
| 436 | assert !files.isEmpty(); | |
| 437 | final var editor = getActiveTextEditor(); | |
| 438 | final var tab = getTab( editor ); | |
| 439 | final var file = files.get( 0 ); | |
| 440 | ||
| 441 | editor.rename( file ); | |
| 442 | tab.ifPresent( t -> { | |
| 443 | t.setText( editor.getFilename() ); | |
| 444 | t.setTooltip( createTooltip( file ) ); | |
| 445 | } ); | |
| 446 | ||
| 447 | save(); | |
| 448 | } | |
| 449 | ||
| 450 | /** | |
| 451 | * Saves the given {@link TextResource} to a file. This is typically used | |
| 452 | * to save either an instance of {@link TextEditor} or {@link TextDefinition}. | |
| 453 | * | |
| 454 | * @param resource The resource to export. | |
| 455 | */ | |
| 456 | private void save( final TextResource resource ) { | |
| 457 | try { | |
| 458 | resource.save(); | |
| 459 | } catch( final Exception ex ) { | |
| 460 | clue( ex ); | |
| 461 | sNotifier.alert( | |
| 462 | getWindow(), resource.getPath(), "TextResource.saveFailed", ex | |
| 463 | ); | |
| 464 | } | |
| 465 | } | |
| 466 | ||
| 467 | /** | |
| 468 | * Closes all open {@link TextEditor}s; all {@link TextDefinition}s stay open. | |
| 469 | * | |
| 470 | * @return {@code true} when all editors, modified or otherwise, were | |
| 471 | * permitted to close; {@code false} when one or more editors were modified | |
| 472 | * and the user requested no closing. | |
| 473 | */ | |
| 474 | public boolean closeAll() { | |
| 475 | var closable = true; | |
| 476 | ||
| 477 | for( final var tabPane : mTabPanes ) { | |
| 478 | final var tabIterator = tabPane.getTabs().iterator(); | |
| 479 | ||
| 480 | while( tabIterator.hasNext() ) { | |
| 481 | final var tab = tabIterator.next(); | |
| 482 | final var resource = tab.getContent(); | |
| 483 | ||
| 484 | // The definition panes auto-save, so being specific here prevents | |
| 485 | // closing the definitions in the situation where the user wants to | |
| 486 | // continue editing (i.e., possibly save unsaved work). | |
| 487 | if( !(resource instanceof TextEditor) ) { | |
| 488 | continue; | |
| 489 | } | |
| 490 | ||
| 491 | if( canClose( (TextEditor) resource ) ) { | |
| 492 | tabIterator.remove(); | |
| 493 | close( tab ); | |
| 494 | } | |
| 495 | else { | |
| 496 | closable = false; | |
| 497 | } | |
| 498 | } | |
| 499 | } | |
| 500 | ||
| 501 | return closable; | |
| 502 | } | |
| 503 | ||
| 504 | /** | |
| 505 | * Calls the tab's {@link Tab#getOnClosed()} handler to carry out a close | |
| 506 | * event. | |
| 507 | * | |
| 508 | * @param tab The {@link Tab} that was closed. | |
| 509 | */ | |
| 510 | private void close( final Tab tab ) { | |
| 511 | assert tab != null; | |
| 512 | ||
| 513 | final var handler = tab.getOnClosed(); | |
| 514 | ||
| 515 | if( handler != null ) { | |
| 516 | handler.handle( new ActionEvent() ); | |
| 517 | } | |
| 518 | } | |
| 519 | ||
| 520 | /** | |
| 521 | * Closes the active tab; delegates to {@link #canClose(TextResource)}. | |
| 522 | */ | |
| 523 | public void close() { | |
| 524 | final var editor = getActiveTextEditor(); | |
| 525 | ||
| 526 | if( canClose( editor ) ) { | |
| 527 | close( editor ); | |
| 528 | } | |
| 529 | } | |
| 530 | ||
| 531 | /** | |
| 532 | * Closes the given {@link TextResource}. This must not be called from within | |
| 533 | * a loop that iterates over the tab panes using {@code forEach}, lest a | |
| 534 | * concurrent modification exception be thrown. | |
| 535 | * | |
| 536 | * @param resource The {@link TextResource} to close, without confirming with | |
| 537 | * the user. | |
| 538 | */ | |
| 539 | private void close( final TextResource resource ) { | |
| 540 | getTab( resource ).ifPresent( | |
| 541 | ( tab ) -> { | |
| 542 | close( tab ); | |
| 543 | tab.getTabPane().getTabs().remove( tab ); | |
| 544 | } | |
| 545 | ); | |
| 546 | } | |
| 547 | ||
| 548 | /** | |
| 549 | * Answers whether the given {@link TextResource} may be closed. | |
| 550 | * | |
| 551 | * @param editor The {@link TextResource} to try closing. | |
| 552 | * @return {@code true} when the editor may be closed; {@code false} when | |
| 553 | * the user has requested to keep the editor open. | |
| 554 | */ | |
| 555 | private boolean canClose( final TextResource editor ) { | |
| 556 | final var editorTab = getTab( editor ); | |
| 557 | final var canClose = new AtomicBoolean( true ); | |
| 558 | ||
| 559 | if( editor.isModified() ) { | |
| 560 | final var filename = new StringBuilder(); | |
| 561 | editorTab.ifPresent( ( tab ) -> filename.append( tab.getText() ) ); | |
| 562 | ||
| 563 | final var message = sNotifier.createNotification( | |
| 564 | Messages.get( "Alert.file.close.title" ), | |
| 565 | Messages.get( "Alert.file.close.text" ), | |
| 566 | filename.toString() | |
| 567 | ); | |
| 568 | ||
| 569 | final var dialog = sNotifier.createConfirmation( getWindow(), message ); | |
| 570 | ||
| 571 | dialog.showAndWait().ifPresent( | |
| 572 | save -> canClose.set( save == YES ? editor.save() : save == NO ) | |
| 573 | ); | |
| 574 | } | |
| 575 | ||
| 576 | return canClose.get(); | |
| 577 | } | |
| 578 | ||
| 579 | private ObjectProperty<TextEditor> createActiveTextEditor() { | |
| 580 | final var editor = new SimpleObjectProperty<TextEditor>(); | |
| 581 | ||
| 582 | editor.addListener( ( c, o, n ) -> { | |
| 583 | if( n != null ) { | |
| 584 | mPreview.setBaseUri( n.getPath() ); | |
| 585 | process( n ); | |
| 586 | } | |
| 587 | } ); | |
| 588 | ||
| 589 | return editor; | |
| 590 | } | |
| 591 | ||
| 592 | /** | |
| 593 | * Adds the HTML preview tab to its own, singular tab pane. | |
| 594 | */ | |
| 595 | public void viewPreview() { | |
| 596 | viewTab( mPreview, TEXT_HTML, "Pane.preview.title" ); | |
| 597 | } | |
| 598 | ||
| 599 | /** | |
| 600 | * Adds the document outline tab to its own, singular tab pane. | |
| 601 | */ | |
| 602 | public void viewOutline() { | |
| 603 | viewTab( mOutline, APP_DOCUMENT_OUTLINE, "Pane.outline.title" ); | |
| 604 | } | |
| 605 | ||
| 606 | public void viewStatistics() { | |
| 607 | viewTab( mStatistics, APP_DOCUMENT_STATISTICS, "Pane.statistics.title" ); | |
| 608 | } | |
| 609 | ||
| 610 | public void viewFiles() { | |
| 611 | try { | |
| 612 | final var factory = new FilePickerFactory( mWorkspace ); | |
| 613 | final var fileManager = factory.createModeless(); | |
| 614 | viewTab( fileManager, APP_FILE_MANAGER, "Pane.files.title" ); | |
| 615 | } catch( final Exception ex ) { | |
| 616 | clue( ex ); | |
| 617 | } | |
| 618 | } | |
| 619 | ||
| 620 | private void viewTab( | |
| 621 | final Node node, final MediaType mediaType, final String key ) { | |
| 622 | final var tabPane = obtainTabPane( mediaType ); | |
| 623 | ||
| 624 | for( final var tab : tabPane.getTabs() ) { | |
| 625 | if( tab.getContent() == node ) { | |
| 626 | return; | |
| 627 | } | |
| 628 | } | |
| 629 | ||
| 630 | tabPane.getTabs().add( createTab( get( key ), node ) ); | |
| 631 | addTabPane( tabPane ); | |
| 632 | } | |
| 633 | ||
| 634 | public void viewRefresh() { | |
| 635 | mPreview.refresh(); | |
| 636 | } | |
| 637 | ||
| 638 | /** | |
| 639 | * Returns the tab that contains the given {@link TextEditor}. | |
| 640 | * | |
| 641 | * @param editor The {@link TextEditor} instance to find amongst the tabs. | |
| 642 | * @return The first tab having content that matches the given tab. | |
| 643 | */ | |
| 644 | private Optional<Tab> getTab( final TextResource editor ) { | |
| 645 | return mTabPanes.stream() | |
| 646 | .flatMap( pane -> pane.getTabs().stream() ) | |
| 647 | .filter( tab -> editor.equals( tab.getContent() ) ) | |
| 648 | .findFirst(); | |
| 649 | } | |
| 650 | ||
| 651 | /** | |
| 652 | * Creates a new {@link DefinitionEditor} wrapped in a listener that | |
| 653 | * is used to detect when the active {@link DefinitionEditor} has changed. | |
| 654 | * Upon changing, the {@link #mResolvedMap} is updated and the active | |
| 655 | * text editor is refreshed. | |
| 656 | * | |
| 657 | * @param editor Text editor to update with the revised resolved map. | |
| 658 | * @return A newly configured property that represents the active | |
| 659 | * {@link DefinitionEditor}, never null. | |
| 660 | */ | |
| 661 | private ObjectProperty<TextDefinition> createActiveDefinitionEditor( | |
| 662 | final ObjectProperty<TextEditor> editor ) { | |
| 663 | final var definitions = new SimpleObjectProperty<TextDefinition>(); | |
| 664 | definitions.addListener( ( c, o, n ) -> { | |
| 665 | resolve( n == null ? createDefinitionEditor() : n ); | |
| 666 | process( editor.get() ); | |
| 667 | } ); | |
| 668 | ||
| 669 | return definitions; | |
| 670 | } | |
| 671 | ||
| 672 | private Tab createTab( final String filename, final Node node ) { | |
| 673 | return new DetachableTab( filename, node ); | |
| 674 | } | |
| 675 | ||
| 676 | private Tab createTab( final File file ) { | |
| 677 | final var r = createTextResource( file ); | |
| 678 | final var tab = createTab( r.getFilename(), r.getNode() ); | |
| 679 | ||
| 680 | r.modifiedProperty().addListener( | |
| 681 | ( c, o, n ) -> tab.setText( r.getFilename() + (n ? "*" : "") ) | |
| 682 | ); | |
| 683 | ||
| 684 | // This is called when either the tab is closed by the user clicking on | |
| 685 | // the tab's close icon or when closing (all) from the file menu. | |
| 686 | tab.setOnClosed( | |
| 687 | ( __ ) -> getRecentFiles().remove( file.getAbsolutePath() ) | |
| 688 | ); | |
| 689 | ||
| 690 | // When closing a tab, give focus to the newly revealed tab. | |
| 691 | tab.selectedProperty().addListener( ( c, o, n ) -> { | |
| 692 | if( n != null && n ) { | |
| 693 | final var pane = tab.getTabPane(); | |
| 694 | ||
| 695 | if( pane != null ) { | |
| 696 | pane.requestFocus(); | |
| 697 | } | |
| 698 | } | |
| 699 | } ); | |
| 700 | ||
| 701 | tab.tabPaneProperty().addListener( ( cPane, oPane, nPane ) -> { | |
| 702 | if( nPane != null ) { | |
| 703 | nPane.focusedProperty().addListener( ( c, o, n ) -> { | |
| 704 | if( n != null && n ) { | |
| 705 | final var selected = nPane.getSelectionModel().getSelectedItem(); | |
| 706 | final var node = selected.getContent(); | |
| 707 | node.requestFocus(); | |
| 708 | } | |
| 709 | } ); | |
| 710 | } | |
| 711 | } ); | |
| 712 | ||
| 713 | return tab; | |
| 714 | } | |
| 715 | ||
| 716 | /** | |
| 717 | * Creates bins for the different {@link MediaType}s, which eventually are | |
| 718 | * added to the UI as separate tab panes. If ever a general-purpose scene | |
| 719 | * exporter is developed to serialize a scene to an FXML file, this could | |
| 720 | * be replaced by such a class. | |
| 721 | * <p> | |
| 722 | * When binning the files, this makes sure that at least one file exists | |
| 723 | * for every type. If the user has opted to close a particular type (such | |
| 724 | * as the definition pane), the view will suppressed elsewhere. | |
| 725 | * </p> | |
| 726 | * <p> | |
| 727 | * The order that the binned files are returned will be reflected in the | |
| 728 | * order that the corresponding panes are rendered in the UI. | |
| 729 | * </p> | |
| 730 | * | |
| 731 | * @param paths The file paths to bin according to their type. | |
| 732 | * @return An in-order list of files, first by structured definition files, | |
| 733 | * then by plain text documents. | |
| 734 | */ | |
| 735 | private List<File> bin( final SetProperty<String> paths ) { | |
| 736 | // Treat all files destined for the text editor as plain text documents | |
| 737 | // so that they are added to the same pane. Grouping by TEXT_PLAIN is a | |
| 738 | // bit arbitrary, but means explicitly capturing TEXT_PLAIN isn't needed. | |
| 739 | final Function<MediaType, MediaType> bin = | |
| 740 | m -> PLAIN_TEXT_FORMAT.contains( m ) ? TEXT_PLAIN : m; | |
| 741 | ||
| 742 | // Create two groups: YAML files and plain text files. | |
| 743 | final var bins = paths | |
| 744 | .stream() | |
| 745 | .collect( | |
| 746 | groupingBy( path -> bin.apply( MediaType.fromFilename( path ) ) ) | |
| 747 | ); | |
| 748 | ||
| 749 | bins.putIfAbsent( TEXT_YAML, List.of( DEFINITION_DEFAULT.toString() ) ); | |
| 750 | bins.putIfAbsent( TEXT_PLAIN, List.of( DOCUMENT_DEFAULT.toString() ) ); | |
| 751 | ||
| 752 | final var result = new ArrayList<File>( paths.size() ); | |
| 753 | ||
| 754 | // Ensure that the same types are listed together (keep insertion order). | |
| 755 | bins.forEach( ( mediaType, files ) -> result.addAll( | |
| 756 | files.stream().map( File::new ).collect( Collectors.toList() ) ) | |
| 757 | ); | |
| 758 | ||
| 759 | return result; | |
| 760 | } | |
| 761 | ||
| 762 | /** | |
| 763 | * Uses the given {@link TextDefinition} instance to update the | |
| 764 | * {@link #mResolvedMap}. | |
| 765 | * | |
| 766 | * @param editor A non-null, possibly empty definition editor. | |
| 767 | */ | |
| 768 | private void resolve( final TextDefinition editor ) { | |
| 769 | assert editor != null; | |
| 770 | ||
| 771 | final var tokens = createDefinitionTokens(); | |
| 772 | final var operator = new YamlSigilOperator( tokens ); | |
| 773 | final var map = new HashMap<String, String>(); | |
| 774 | ||
| 775 | editor.toMap().forEach( ( k, v ) -> map.put( operator.entoken( k ), v ) ); | |
| 776 | ||
| 777 | mResolvedMap.clear(); | |
| 778 | mResolvedMap.putAll( editor.interpolate( map, tokens ) ); | |
| 779 | } | |
| 780 | ||
| 781 | /** | |
| 782 | * Force the active editor to update, which will cause the processor | |
| 783 | * to re-evaluate the interpolated definition map thereby updating the | |
| 784 | * preview pane. | |
| 785 | * | |
| 786 | * @param editor Contains the source document to update in the preview pane. | |
| 787 | */ | |
| 788 | private void process( final TextEditor editor ) { | |
| 789 | // Ensure processing does not run on the JavaFX thread, which frees the | |
| 790 | // text editor immediately for caret movement. The preview will have a | |
| 791 | // slight delay when catching up to the caret position. | |
| 792 | final var task = new Task<Void>() { | |
| 793 | @Override | |
| 794 | public Void call() { | |
| 795 | try { | |
| 796 | final var p = mProcessors.getOrDefault( editor, IDENTITY ); | |
| 797 | p.apply( editor == null ? "" : editor.getText() ); | |
| 798 | } catch( final Exception ex ) { | |
| 799 | clue( ex ); | |
| 800 | } | |
| 801 | ||
| 802 | return null; | |
| 803 | } | |
| 804 | }; | |
| 805 | ||
| 806 | task.setOnSucceeded( | |
| 807 | e -> invokeLater( () -> mPreview.scrollTo( CARET_ID ) ) | |
| 808 | ); | |
| 809 | ||
| 810 | // Prevents multiple process requests from executing simultaneously (due | |
| 811 | // to having a restricted queue size). | |
| 812 | sExecutor.execute( task ); | |
| 813 | } | |
| 814 | ||
| 815 | /** | |
| 816 | * Lazily creates a {@link TabPane} configured to listen for tab select | |
| 817 | * events. The tab pane is associated with a given media type so that | |
| 818 | * similar files can be grouped together. | |
| 819 | * | |
| 820 | * @param mediaType The media type to associate with the tab pane. | |
| 821 | * @return An instance of {@link TabPane} that will handle tab docking. | |
| 822 | */ | |
| 823 | private TabPane obtainTabPane( final MediaType mediaType ) { | |
| 824 | for( final var pane : mTabPanes ) { | |
| 825 | for( final var tab : pane.getTabs() ) { | |
| 826 | final var node = tab.getContent(); | |
| 827 | ||
| 828 | if( node instanceof TextResource r && r.supports( mediaType ) ) { | |
| 829 | return pane; | |
| 830 | } | |
| 831 | } | |
| 832 | } | |
| 833 | ||
| 834 | final var pane = createTabPane(); | |
| 835 | mTabPanes.add( pane ); | |
| 836 | return pane; | |
| 837 | } | |
| 838 | ||
| 839 | /** | |
| 840 | * Creates an initialized {@link TabPane} instance. | |
| 841 | * | |
| 842 | * @return A new {@link TabPane} with all listeners configured. | |
| 843 | */ | |
| 844 | private TabPane createTabPane() { | |
| 845 | final var tabPane = new DetachableTabPane(); | |
| 846 | ||
| 847 | initStageOwnerFactory( tabPane ); | |
| 848 | initTabListener( tabPane ); | |
| 849 | ||
| 850 | return tabPane; | |
| 851 | } | |
| 852 | ||
| 853 | /** | |
| 854 | * When any {@link DetachableTabPane} is detached from the main window, | |
| 855 | * the stage owner factory must be given its parent window, which will | |
| 856 | * own the child window. The parent window is the {@link MainPane}'s | |
| 857 | * {@link Scene}'s {@link Window} instance. | |
| 858 | * | |
| 859 | * <p> | |
| 860 | * This will derives the new title from the main window title, incrementing | |
| 861 | * the window count to help uniquely identify the child windows. | |
| 862 | * </p> | |
| 863 | * | |
| 864 | * @param tabPane A new {@link DetachableTabPane} to configure. | |
| 865 | */ | |
| 866 | private void initStageOwnerFactory( final DetachableTabPane tabPane ) { | |
| 867 | tabPane.setStageOwnerFactory( ( stage ) -> { | |
| 868 | final var title = get( | |
| 869 | "Detach.tab.title", | |
| 870 | ((Stage) getWindow()).getTitle(), ++mWindowCount | |
| 871 | ); | |
| 872 | stage.setTitle( title ); | |
| 873 | ||
| 874 | return getScene().getWindow(); | |
| 875 | } ); | |
| 876 | } | |
| 877 | ||
| 878 | /** | |
| 879 | * Responsible for configuring the content of each {@link DetachableTab} when | |
| 880 | * it is added to the given {@link DetachableTabPane} instance. | |
| 881 | * <p> | |
| 882 | * For {@link TextEditor} contents, an instance of {@link ScrollEventHandler} | |
| 883 | * is initialized to perform synchronized scrolling between the editor and | |
| 884 | * its preview window. Additionally, the last tab in the tab pane's list of | |
| 885 | * tabs is given focus. | |
| 886 | * </p> | |
| 887 | * <p> | |
| 888 | * Note that multiple tabs can be added simultaneously. | |
| 889 | * </p> | |
| 890 | * | |
| 891 | * @param tabPane A new {@link TabPane} to configure. | |
| 892 | */ | |
| 893 | private void initTabListener( final TabPane tabPane ) { | |
| 894 | tabPane.getTabs().addListener( | |
| 895 | ( final ListChangeListener.Change<? extends Tab> listener ) -> { | |
| 896 | while( listener.next() ) { | |
| 897 | if( listener.wasAdded() ) { | |
| 898 | final var tabs = listener.getAddedSubList(); | |
| 899 | ||
| 900 | tabs.forEach( ( tab ) -> { | |
| 901 | final var node = tab.getContent(); | |
| 902 | ||
| 903 | if( node instanceof TextEditor ) { | |
| 904 | initScrollEventListener( tab ); | |
| 905 | } | |
| 906 | } ); | |
| 907 | ||
| 908 | // Select and give focus to the last tab opened. | |
| 909 | final var index = tabs.size() - 1; | |
| 910 | if( index >= 0 ) { | |
| 911 | final var tab = tabs.get( index ); | |
| 912 | tabPane.getSelectionModel().select( tab ); | |
| 913 | tab.getContent().requestFocus(); | |
| 914 | } | |
| 915 | } | |
| 916 | } | |
| 917 | } | |
| 918 | ); | |
| 919 | } | |
| 920 | ||
| 921 | /** | |
| 922 | * Synchronizes scrollbar positions between the given {@link Tab} that | |
| 923 | * contains an instance of {@link TextEditor} and {@link HtmlPreview} pane. | |
| 924 | * | |
| 925 | * @param tab The container for an instance of {@link TextEditor}. | |
| 926 | */ | |
| 927 | private void initScrollEventListener( final Tab tab ) { | |
| 928 | final var editor = (TextEditor) tab.getContent(); | |
| 929 | final var scrollPane = editor.getScrollPane(); | |
| 930 | final var scrollBar = mPreview.getVerticalScrollBar(); | |
| 931 | final var handler = new ScrollEventHandler( scrollPane, scrollBar ); | |
| 932 | handler.enabledProperty().bind( tab.selectedProperty() ); | |
| 933 | } | |
| 934 | ||
| 935 | private void addTabPane( final int index, final TabPane tabPane ) { | |
| 936 | final var items = getItems(); | |
| 937 | if( !items.contains( tabPane ) ) { | |
| 938 | items.add( index, tabPane ); | |
| 939 | } | |
| 940 | } | |
| 941 | ||
| 942 | private void addTabPane( final TabPane tabPane ) { | |
| 943 | addTabPane( getItems().size(), tabPane ); | |
| 944 | } | |
| 945 | ||
| 946 | public ProcessorContext createProcessorContext() { | |
| 947 | return createProcessorContext( null, NONE ); | |
| 948 | } | |
| 949 | ||
| 950 | public ProcessorContext createProcessorContext( | |
| 951 | final Path exportPath, final ExportFormat format ) { | |
| 952 | final var editor = getActiveTextEditor(); | |
| 953 | return createProcessorContext( | |
| 954 | editor.getPath(), exportPath, format, editor.getCaret() ); | |
| 955 | } | |
| 956 | ||
| 957 | private ProcessorContext createProcessorContext( | |
| 958 | final Path path, final Caret caret ) { | |
| 959 | return createProcessorContext( path, null, ExportFormat.NONE, caret ); | |
| 960 | } | |
| 961 | ||
| 962 | /** | |
| 963 | * @param path Used by {@link ProcessorFactory} to determine | |
| 964 | * {@link Processor} type to create based on file type. | |
| 965 | * @param exportPath Used when exporting to a PDF file (binary). | |
| 966 | * @param format Used when processors export to a new text format. | |
| 967 | * @param caret Used by {@link CaretExtension} to add ID attribute into | |
| 968 | * preview document for scrollbar synchronization. | |
| 969 | * @return A new {@link ProcessorContext} to use when creating an instance of | |
| 970 | * {@link Processor}. | |
| 971 | */ | |
| 972 | private ProcessorContext createProcessorContext( | |
| 973 | final Path path, final Path exportPath, final ExportFormat format, | |
| 974 | final Caret caret ) { | |
| 975 | return new ProcessorContext( | |
| 976 | mPreview, mResolvedMap, path, exportPath, format, mWorkspace, caret | |
| 977 | ); | |
| 978 | } | |
| 979 | ||
| 980 | private TextResource createTextResource( final File file ) { | |
| 981 | // TODO: Create PlainTextEditor that's returned by default. | |
| 982 | return MediaType.valueFrom( file ) == TEXT_YAML | |
| 983 | ? createDefinitionEditor( file ) | |
| 984 | : createMarkdownEditor( file ); | |
| 985 | } | |
| 986 | ||
| 987 | /** | |
| 988 | * Creates an instance of {@link MarkdownEditor} that listens for both | |
| 989 | * caret change events and text change events. Text change events must | |
| 990 | * take priority over caret change events because it's possible to change | |
| 991 | * the text without moving the caret (e.g., delete selected text). | |
| 992 | * | |
| 993 | * @param file The file containing contents for the text editor. | |
| 994 | * @return A non-null text editor. | |
| 995 | */ | |
| 996 | private TextResource createMarkdownEditor( final File file ) { | |
| 997 | final var path = file.toPath(); | |
| 998 | final var editor = new MarkdownEditor( file, getWorkspace() ); | |
| 999 | final var caret = editor.getCaret(); | |
| 1000 | final var context = createProcessorContext( path, caret ); | |
| 1001 | ||
| 1002 | mProcessors.computeIfAbsent( editor, p -> createProcessors( context ) ); | |
| 1003 | ||
| 1004 | editor.addDirtyListener( ( c, o, n ) -> { | |
| 1005 | if( n ) { | |
| 1006 | // Reset the status to OK after changing the text. | |
| 1007 | clue(); | |
| 1008 | ||
| 1009 | // Processing the text may update the status bar. | |
| 1010 | process( getActiveTextEditor() ); | |
| 1011 | } | |
| 1012 | } ); | |
| 1013 | ||
| 1014 | editor.addEventListener( | |
| 1015 | keyPressed( SPACE, CONTROL_DOWN ), this::autoinsert | |
| 1016 | ); | |
| 1017 | ||
| 1018 | // Set the active editor, which refreshes the preview panel. | |
| 1019 | mActiveTextEditor.set( editor ); | |
| 1020 | ||
| 1021 | return editor; | |
| 1022 | } | |
| 1023 | ||
| 1024 | /** | |
| 1025 | * Delegates to {@link #autoinsert()}. | |
| 1026 | * | |
| 1027 | * @param event Ignored. | |
| 1028 | */ | |
| 1029 | private void autoinsert( final KeyEvent event ) { | |
| 1030 | autoinsert(); | |
| 1031 | } | |
| 1032 | ||
| 1033 | /** | |
| 1034 | * Finds a node that matches the word at the caret, then inserts the | |
| 1035 | * corresponding definition. The definition token delimiters depend on | |
| 1036 | * the type of file being edited. | |
| 1037 | */ | |
| 1038 | public void autoinsert() { | |
| 1039 | final var definitions = getActiveTextDefinition(); | |
| 1040 | final var editor = getActiveTextEditor(); | |
| 1041 | final var mediaType = editor.getMediaType(); | |
| 1042 | final var operator = getSigilOperator( mediaType ); | |
| 1043 | ||
| 1044 | DefinitionNameInjector.autoinsert( editor, definitions, operator ); | |
| 1045 | } | |
| 1046 | ||
| 1047 | private TextDefinition createDefinitionEditor() { | |
| 1048 | return createDefinitionEditor( DEFINITION_DEFAULT ); | |
| 1049 | } | |
| 1050 | ||
| 1051 | private TextDefinition createDefinitionEditor( final File file ) { | |
| 1052 | final var editor = new DefinitionEditor( file, createTreeTransformer() ); | |
| 1053 | editor.addTreeChangeHandler( mTreeHandler ); | |
| 1054 | return editor; | |
| 1055 | } | |
| 1056 | ||
| 1057 | private TreeTransformer createTreeTransformer() { | |
| 1058 | return new YamlTreeTransformer(); | |
| 1059 | } | |
| 1060 | ||
| 1061 | private Tooltip createTooltip( final File file ) { | |
| 1062 | final var path = file.toPath(); | |
| 1063 | final var tooltip = new Tooltip( path.toString() ); | |
| 1064 | ||
| 1065 | tooltip.setShowDelay( millis( 200 ) ); | |
| 1066 | return tooltip; | |
| 1067 | } | |
| 1068 | ||
| 1069 | public TextEditor getActiveTextEditor() { | |
| 1070 | return mActiveTextEditor.get(); | |
| 1071 | } | |
| 1072 | ||
| 1073 | public ReadOnlyObjectProperty<TextEditor> activeTextEditorProperty() { | |
| 1074 | return mActiveTextEditor; | |
| 1075 | } | |
| 1076 | ||
| 1077 | public TextDefinition getActiveTextDefinition() { | |
| 1078 | return mActiveDefinitionEditor.get(); | |
| 1079 | } | |
| 1080 | ||
| 1081 | public Window getWindow() { | |
| 1082 | return getScene().getWindow(); | |
| 1083 | } | |
| 1084 | ||
| 1085 | public Workspace getWorkspace() { | |
| 1086 | return mWorkspace; | |
| 1087 | } | |
| 1088 | ||
| 1089 | /** | |
| 1090 | * Returns the sigil operator for the given {@link MediaType}. | |
| 1091 | * | |
| 1092 | * @param mediaType The type of file being edited. | |
| 1093 | */ | |
| 1094 | private SigilOperator getSigilOperator( final MediaType mediaType ) { | |
| 1095 | final var operator = new YamlSigilOperator( createDefinitionTokens() ); | |
| 1096 | ||
| 1097 | return mediaType == TEXT_R_MARKDOWN | |
| 1098 | ? new RSigilOperator( createRTokens(), operator ) | |
| 1099 | : operator; | |
| 1100 | } | |
| 1101 | ||
| 1102 | /** | |
| 1103 | * Returns the set of file names opened in the application. The names must | |
| 1104 | * be converted to {@link File} objects. | |
| 1105 | * | |
| 1106 | * @return A {@link Set} of file names. | |
| 1107 | */ | |
| 1108 | private SetProperty<String> getRecentFiles() { | |
| 1109 | return getWorkspace().setsProperty( KEY_UI_FILES_PATH ); | |
| 1110 | } | |
| 1111 | ||
| 1112 | private StringProperty stringProperty( final Key key ) { | |
| 1113 | return getWorkspace().stringProperty( key ); | |
| 1114 | } | |
| 1115 | ||
| 1116 | private Tokens createRTokens() { | |
| 1117 | return createTokens( KEY_R_DELIM_BEGAN, KEY_R_DELIM_ENDED ); | |
| 1118 | } | |
| 1119 | ||
| 1120 | private Tokens createDefinitionTokens() { | |
| 1121 | return createTokens( KEY_DEF_DELIM_BEGAN, KEY_DEF_DELIM_ENDED ); | |
| 1122 | } | |
| 1123 | ||
| 1124 | private Tokens createTokens( final Key began, final Key ended ) { | |
| 1125 | return new Tokens( stringProperty( began ), stringProperty( ended ) ); | |
| 23 | import com.keenwrite.sigils.Sigils; | |
| 24 | import com.keenwrite.sigils.YamlSigilOperator; | |
| 25 | import com.keenwrite.ui.explorer.FilePickerFactory; | |
| 26 | import com.keenwrite.ui.heuristics.DocumentStatistics; | |
| 27 | import com.keenwrite.ui.outline.DocumentOutline; | |
| 28 | import com.panemu.tiwulfx.control.dock.DetachableTab; | |
| 29 | import com.panemu.tiwulfx.control.dock.DetachableTabPane; | |
| 30 | import javafx.application.Platform; | |
| 31 | import javafx.beans.property.*; | |
| 32 | import javafx.collections.ListChangeListener; | |
| 33 | import javafx.concurrent.Task; | |
| 34 | import javafx.event.ActionEvent; | |
| 35 | import javafx.event.Event; | |
| 36 | import javafx.event.EventHandler; | |
| 37 | import javafx.scene.Node; | |
| 38 | import javafx.scene.Scene; | |
| 39 | import javafx.scene.control.*; | |
| 40 | import javafx.scene.control.TreeItem.TreeModificationEvent; | |
| 41 | import javafx.scene.input.KeyEvent; | |
| 42 | import javafx.scene.layout.FlowPane; | |
| 43 | import javafx.stage.Stage; | |
| 44 | import javafx.stage.Window; | |
| 45 | import org.greenrobot.eventbus.Subscribe; | |
| 46 | ||
| 47 | import java.io.File; | |
| 48 | import java.io.FileNotFoundException; | |
| 49 | import java.nio.file.Path; | |
| 50 | import java.util.*; | |
| 51 | import java.util.concurrent.ExecutorService; | |
| 52 | import java.util.concurrent.ScheduledExecutorService; | |
| 53 | import java.util.concurrent.ScheduledFuture; | |
| 54 | import java.util.concurrent.atomic.AtomicBoolean; | |
| 55 | import java.util.concurrent.atomic.AtomicReference; | |
| 56 | import java.util.function.Function; | |
| 57 | import java.util.stream.Collectors; | |
| 58 | ||
| 59 | import static com.keenwrite.ExportFormat.NONE; | |
| 60 | import static com.keenwrite.Messages.get; | |
| 61 | import static com.keenwrite.constants.Constants.*; | |
| 62 | import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG_NODE; | |
| 63 | import static com.keenwrite.events.Bus.register; | |
| 64 | import static com.keenwrite.events.StatusEvent.clue; | |
| 65 | import static com.keenwrite.io.MediaType.*; | |
| 66 | import static com.keenwrite.preferences.WorkspaceKeys.*; | |
| 67 | import static com.keenwrite.processors.IdentityProcessor.IDENTITY; | |
| 68 | import static com.keenwrite.processors.ProcessorFactory.createProcessors; | |
| 69 | import static java.lang.String.format; | |
| 70 | import static java.lang.System.getProperty; | |
| 71 | import static java.util.concurrent.Executors.newFixedThreadPool; | |
| 72 | import static java.util.concurrent.Executors.newScheduledThreadPool; | |
| 73 | import static java.util.concurrent.TimeUnit.SECONDS; | |
| 74 | import static java.util.stream.Collectors.groupingBy; | |
| 75 | import static javafx.application.Platform.runLater; | |
| 76 | import static javafx.scene.control.Alert.AlertType.ERROR; | |
| 77 | import static javafx.scene.control.ButtonType.*; | |
| 78 | import static javafx.scene.control.TabPane.TabClosingPolicy.ALL_TABS; | |
| 79 | import static javafx.scene.input.KeyCode.SPACE; | |
| 80 | import static javafx.scene.input.KeyCombination.CONTROL_DOWN; | |
| 81 | import static javafx.util.Duration.millis; | |
| 82 | import static javax.swing.SwingUtilities.invokeLater; | |
| 83 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 84 | ||
| 85 | /** | |
| 86 | * Responsible for wiring together the main application components for a | |
| 87 | * particular {@link Workspace} (project). These include the definition views, | |
| 88 | * text editors, and preview pane along with any corresponding controllers. | |
| 89 | */ | |
| 90 | public final class MainPane extends SplitPane { | |
| 91 | private static final ExecutorService sExecutor = newFixedThreadPool( 1 ); | |
| 92 | ||
| 93 | private final ScheduledExecutorService mSaver = newScheduledThreadPool( 1 ); | |
| 94 | private final AtomicReference<ScheduledFuture<?>> mSaveTask = | |
| 95 | new AtomicReference<>(); | |
| 96 | ||
| 97 | private static final Notifier sNotifier = Services.load( Notifier.class ); | |
| 98 | ||
| 99 | /** | |
| 100 | * Used when opening files to determine how each file should be binned and | |
| 101 | * therefore what tab pane to be opened within. | |
| 102 | */ | |
| 103 | private static final Set<MediaType> PLAIN_TEXT_FORMAT = Set.of( | |
| 104 | TEXT_MARKDOWN, TEXT_R_MARKDOWN, UNDEFINED | |
| 105 | ); | |
| 106 | ||
| 107 | /** | |
| 108 | * Prevents re-instantiation of processing classes. | |
| 109 | */ | |
| 110 | private final Map<TextResource, Processor<String>> mProcessors = | |
| 111 | new HashMap<>(); | |
| 112 | ||
| 113 | private final Workspace mWorkspace; | |
| 114 | ||
| 115 | /** | |
| 116 | * Groups similar file type tabs together. | |
| 117 | */ | |
| 118 | private final List<TabPane> mTabPanes = new ArrayList<>(); | |
| 119 | ||
| 120 | /** | |
| 121 | * Renders the actively selected plain text editor tab. | |
| 122 | */ | |
| 123 | private final HtmlPreview mPreview; | |
| 124 | ||
| 125 | /** | |
| 126 | * Provides an interactive document outline. | |
| 127 | */ | |
| 128 | private final DocumentOutline mOutline = new DocumentOutline(); | |
| 129 | ||
| 130 | /** | |
| 131 | * Changing the active editor fires the value changed event. This allows | |
| 132 | * refreshes to happen when external definitions are modified and need to | |
| 133 | * trigger the processing chain. | |
| 134 | */ | |
| 135 | private final ObjectProperty<TextEditor> mActiveTextEditor = | |
| 136 | createActiveTextEditor(); | |
| 137 | ||
| 138 | /** | |
| 139 | * Changing the active definition editor fires the value changed event. This | |
| 140 | * allows refreshes to happen when external definitions are modified and need | |
| 141 | * to trigger the processing chain. | |
| 142 | */ | |
| 143 | private final ObjectProperty<TextDefinition> mActiveDefinitionEditor; | |
| 144 | ||
| 145 | /** | |
| 146 | * Called when the definition data is changed. | |
| 147 | */ | |
| 148 | private final EventHandler<TreeModificationEvent<Event>> mTreeHandler = | |
| 149 | event -> { | |
| 150 | process( getActiveTextEditor() ); | |
| 151 | save( getActiveTextDefinition() ); | |
| 152 | }; | |
| 153 | ||
| 154 | /** | |
| 155 | * Tracks the number of detached tab panels opened into their own windows, | |
| 156 | * which allows unique identification of subordinate windows by their title. | |
| 157 | * It is doubtful more than 128 windows, much less 256, will be created. | |
| 158 | */ | |
| 159 | private byte mWindowCount; | |
| 160 | ||
| 161 | private final DocumentStatistics mStatistics; | |
| 162 | ||
| 163 | /** | |
| 164 | * Adds all content panels to the main user interface. This will load the | |
| 165 | * configuration settings from the workspace to reproduce the settings from | |
| 166 | * a previous session. | |
| 167 | */ | |
| 168 | public MainPane( final Workspace workspace ) { | |
| 169 | mWorkspace = workspace; | |
| 170 | mPreview = new HtmlPreview( workspace ); | |
| 171 | mStatistics = new DocumentStatistics( workspace ); | |
| 172 | mActiveTextEditor.set( new MarkdownEditor( workspace ) ); | |
| 173 | mActiveDefinitionEditor = createActiveDefinitionEditor( mActiveTextEditor ); | |
| 174 | ||
| 175 | open( collect( getRecentFiles() ) ); | |
| 176 | viewPreview(); | |
| 177 | setDividerPositions( calculateDividerPositions() ); | |
| 178 | ||
| 179 | // Once the main scene's window regains focus, update the active definition | |
| 180 | // editor to the currently selected tab. | |
| 181 | runLater( () -> getWindow().setOnCloseRequest( ( event ) -> { | |
| 182 | // Order matters here. We want to close all the tabs to ensure each | |
| 183 | // is saved, but after they are closed, the workspace should still | |
| 184 | // retain the list of files that were open. If this line came after | |
| 185 | // closing, then restarting the application would list no files. | |
| 186 | mWorkspace.save(); | |
| 187 | ||
| 188 | if( closeAll() ) { | |
| 189 | Platform.exit(); | |
| 190 | System.exit( 0 ); | |
| 191 | } | |
| 192 | else { | |
| 193 | event.consume(); | |
| 194 | } | |
| 195 | } ) ); | |
| 196 | ||
| 197 | register( this ); | |
| 198 | initAutosave( workspace ); | |
| 199 | } | |
| 200 | ||
| 201 | @Subscribe | |
| 202 | public void handle( final TextEditorFocusEvent event ) { | |
| 203 | mActiveTextEditor.set( event.get() ); | |
| 204 | } | |
| 205 | ||
| 206 | @Subscribe | |
| 207 | public void handle( final TextDefinitionFocusEvent event ) { | |
| 208 | mActiveDefinitionEditor.set( event.get() ); | |
| 209 | } | |
| 210 | ||
| 211 | /** | |
| 212 | * Typically called when a file name is clicked in the preview panel. | |
| 213 | * | |
| 214 | * @param event The event to process, must contain a valid file reference. | |
| 215 | */ | |
| 216 | @Subscribe | |
| 217 | public void handle( final FileOpenEvent event ) { | |
| 218 | final File eventFile; | |
| 219 | final var eventUri = event.getUri(); | |
| 220 | ||
| 221 | if( eventUri.isAbsolute() ) { | |
| 222 | eventFile = new File( eventUri.getPath() ); | |
| 223 | } | |
| 224 | else { | |
| 225 | final var activeFile = getActiveTextEditor().getFile(); | |
| 226 | final var parent = activeFile.getParentFile(); | |
| 227 | ||
| 228 | if( parent == null ) { | |
| 229 | clue( new FileNotFoundException( eventUri.getPath() ) ); | |
| 230 | return; | |
| 231 | } | |
| 232 | else { | |
| 233 | final var parentPath = parent.getAbsolutePath(); | |
| 234 | eventFile = Path.of( parentPath, eventUri.getPath() ).toFile(); | |
| 235 | } | |
| 236 | } | |
| 237 | ||
| 238 | runLater( () -> open( eventFile ) ); | |
| 239 | } | |
| 240 | ||
| 241 | @Subscribe | |
| 242 | public void handle( final CaretNavigationEvent event ) { | |
| 243 | runLater( () -> { | |
| 244 | final var textArea = getActiveTextEditor().getTextArea(); | |
| 245 | textArea.moveTo( event.getOffset() ); | |
| 246 | textArea.requestFollowCaret(); | |
| 247 | textArea.requestFocus(); | |
| 248 | } ); | |
| 249 | } | |
| 250 | ||
| 251 | @Subscribe | |
| 252 | @SuppressWarnings( "unused" ) | |
| 253 | public void handle( final ExportFailedEvent event ) { | |
| 254 | final var os = getProperty( "os.name" ); | |
| 255 | final var arch = getProperty( "os.arch" ).toLowerCase(); | |
| 256 | final var bits = getProperty( "sun.arch.data.model" ); | |
| 257 | ||
| 258 | final var title = Messages.get( "Alert.typesetter.missing.title" ); | |
| 259 | final var header = Messages.get( "Alert.typesetter.missing.header" ); | |
| 260 | final var version = Messages.get( | |
| 261 | "Alert.typesetter.missing.version", | |
| 262 | os, | |
| 263 | arch | |
| 264 | .replaceAll( "amd.*|i.*|x86.*", "X86" ) | |
| 265 | .replaceAll( "mips.*", "MIPS" ) | |
| 266 | .replaceAll( "armv.*", "ARM" ), | |
| 267 | bits ); | |
| 268 | final var text = Messages.get( "Alert.typesetter.missing.installer.text" ); | |
| 269 | ||
| 270 | // Download and install ConTeXt for {0} {1} {2}-bit | |
| 271 | final var content = format( "%s %s", text, version ); | |
| 272 | final var flowPane = new FlowPane(); | |
| 273 | final var link = new Hyperlink( text ); | |
| 274 | final var label = new Label( version ); | |
| 275 | flowPane.getChildren().addAll( link, label ); | |
| 276 | ||
| 277 | final var alert = new Alert( ERROR, content, OK ); | |
| 278 | alert.setTitle( title ); | |
| 279 | alert.setHeaderText( header ); | |
| 280 | alert.getDialogPane().contentProperty().set( flowPane ); | |
| 281 | alert.setGraphic( ICON_DIALOG_NODE ); | |
| 282 | ||
| 283 | link.setOnAction( ( e ) -> { | |
| 284 | alert.close(); | |
| 285 | final var url = Messages.get( "Alert.typesetter.missing.installer.url" ); | |
| 286 | runLater( () -> HyperlinkOpenEvent.fire( url ) ); | |
| 287 | } ); | |
| 288 | ||
| 289 | alert.showAndWait(); | |
| 290 | } | |
| 291 | ||
| 292 | private void initAutosave( final Workspace workspace ) { | |
| 293 | final var rate = workspace.integerProperty( KEY_EDITOR_AUTOSAVE ); | |
| 294 | ||
| 295 | rate.addListener( | |
| 296 | ( c, o, n ) -> { | |
| 297 | final var taskRef = mSaveTask.get(); | |
| 298 | ||
| 299 | // Prevent multiple autosaves from running. | |
| 300 | if( taskRef != null ) { | |
| 301 | taskRef.cancel( false ); | |
| 302 | } | |
| 303 | ||
| 304 | initAutosave( rate ); | |
| 305 | } | |
| 306 | ); | |
| 307 | ||
| 308 | // Start the save listener (avoids duplicating some code). | |
| 309 | initAutosave( rate ); | |
| 310 | } | |
| 311 | ||
| 312 | private void initAutosave( final IntegerProperty rate ) { | |
| 313 | mSaveTask.set( | |
| 314 | mSaver.scheduleAtFixedRate( | |
| 315 | () -> { | |
| 316 | if( getActiveTextEditor().isModified() ) { | |
| 317 | // Ensure the modified indicator is cleared by running on EDT. | |
| 318 | runLater( this::save ); | |
| 319 | } | |
| 320 | }, 0, rate.intValue(), SECONDS | |
| 321 | ) | |
| 322 | ); | |
| 323 | } | |
| 324 | ||
| 325 | /** | |
| 326 | * TODO: Load divider positions from exported settings, see | |
| 327 | * {@link #collect(SetProperty)} comment. | |
| 328 | */ | |
| 329 | private double[] calculateDividerPositions() { | |
| 330 | final var ratio = 100f / getItems().size() / 100; | |
| 331 | final var positions = getDividerPositions(); | |
| 332 | ||
| 333 | for( int i = 0; i < positions.length; i++ ) { | |
| 334 | positions[ i ] = ratio * i; | |
| 335 | } | |
| 336 | ||
| 337 | return positions; | |
| 338 | } | |
| 339 | ||
| 340 | /** | |
| 341 | * Opens all the files into the application, provided the paths are unique. | |
| 342 | * This may only be called for any type of files that a user can edit | |
| 343 | * (i.e., update and persist), such as definitions and text files. | |
| 344 | * | |
| 345 | * @param files The list of files to open. | |
| 346 | */ | |
| 347 | public void open( final List<File> files ) { | |
| 348 | files.forEach( this::open ); | |
| 349 | } | |
| 350 | ||
| 351 | /** | |
| 352 | * This opens the given file. Since the preview pane is not a file that | |
| 353 | * can be opened, it is safe to add a listener to the detachable pane. | |
| 354 | * | |
| 355 | * @param inputFile The file to open. | |
| 356 | */ | |
| 357 | private void open( final File inputFile ) { | |
| 358 | final var tab = createTab( inputFile ); | |
| 359 | final var node = tab.getContent(); | |
| 360 | final var mediaType = MediaType.valueFrom( inputFile ); | |
| 361 | final var tabPane = obtainTabPane( mediaType ); | |
| 362 | ||
| 363 | tab.setTooltip( createTooltip( inputFile ) ); | |
| 364 | tabPane.setFocusTraversable( false ); | |
| 365 | tabPane.setTabClosingPolicy( ALL_TABS ); | |
| 366 | tabPane.getTabs().add( tab ); | |
| 367 | ||
| 368 | // Attach the tab scene factory for new tab panes. | |
| 369 | if( !getItems().contains( tabPane ) ) { | |
| 370 | addTabPane( | |
| 371 | node instanceof TextDefinition ? 0 : getItems().size(), tabPane | |
| 372 | ); | |
| 373 | } | |
| 374 | ||
| 375 | getRecentFiles().add( inputFile.getAbsolutePath() ); | |
| 376 | } | |
| 377 | ||
| 378 | /** | |
| 379 | * Opens a new text editor document using the default document file name. | |
| 380 | */ | |
| 381 | public void newTextEditor() { | |
| 382 | open( DOCUMENT_DEFAULT ); | |
| 383 | } | |
| 384 | ||
| 385 | /** | |
| 386 | * Opens a new definition editor document using the default definition | |
| 387 | * file name. | |
| 388 | */ | |
| 389 | public void newDefinitionEditor() { | |
| 390 | open( DEFINITION_DEFAULT ); | |
| 391 | } | |
| 392 | ||
| 393 | /** | |
| 394 | * Iterates over all tab panes to find all {@link TextEditor}s and request | |
| 395 | * that they save themselves. | |
| 396 | */ | |
| 397 | public void saveAll() { | |
| 398 | mTabPanes.forEach( | |
| 399 | ( tp ) -> tp.getTabs().forEach( ( tab ) -> { | |
| 400 | final var node = tab.getContent(); | |
| 401 | if( node instanceof final TextEditor editor ) { | |
| 402 | save( editor ); | |
| 403 | } | |
| 404 | } ) | |
| 405 | ); | |
| 406 | } | |
| 407 | ||
| 408 | /** | |
| 409 | * Requests that the active {@link TextEditor} saves itself. Don't bother | |
| 410 | * checking if modified first because if the user swaps external media from | |
| 411 | * an external source (e.g., USB thumb drive), save should not second-guess | |
| 412 | * the user: save always re-saves. Also, it's less code. | |
| 413 | */ | |
| 414 | public void save() { | |
| 415 | save( getActiveTextEditor() ); | |
| 416 | } | |
| 417 | ||
| 418 | /** | |
| 419 | * Saves the active {@link TextEditor} under a new name. | |
| 420 | * | |
| 421 | * @param files The new active editor {@link File} reference, must contain | |
| 422 | * at least one element. | |
| 423 | */ | |
| 424 | public void saveAs( final List<File> files ) { | |
| 425 | assert files != null; | |
| 426 | assert !files.isEmpty(); | |
| 427 | final var editor = getActiveTextEditor(); | |
| 428 | final var tab = getTab( editor ); | |
| 429 | final var file = files.get( 0 ); | |
| 430 | ||
| 431 | editor.rename( file ); | |
| 432 | tab.ifPresent( t -> { | |
| 433 | t.setText( editor.getFilename() ); | |
| 434 | t.setTooltip( createTooltip( file ) ); | |
| 435 | } ); | |
| 436 | ||
| 437 | save(); | |
| 438 | } | |
| 439 | ||
| 440 | /** | |
| 441 | * Saves the given {@link TextResource} to a file. This is typically used | |
| 442 | * to save either an instance of {@link TextEditor} or {@link TextDefinition}. | |
| 443 | * | |
| 444 | * @param resource The resource to export. | |
| 445 | */ | |
| 446 | private void save( final TextResource resource ) { | |
| 447 | try { | |
| 448 | resource.save(); | |
| 449 | } catch( final Exception ex ) { | |
| 450 | clue( ex ); | |
| 451 | sNotifier.alert( | |
| 452 | getWindow(), resource.getPath(), "TextResource.saveFailed", ex | |
| 453 | ); | |
| 454 | } | |
| 455 | } | |
| 456 | ||
| 457 | /** | |
| 458 | * Closes all open {@link TextEditor}s; all {@link TextDefinition}s stay open. | |
| 459 | * | |
| 460 | * @return {@code true} when all editors, modified or otherwise, were | |
| 461 | * permitted to close; {@code false} when one or more editors were modified | |
| 462 | * and the user requested no closing. | |
| 463 | */ | |
| 464 | public boolean closeAll() { | |
| 465 | var closable = true; | |
| 466 | ||
| 467 | for( final var tabPane : mTabPanes ) { | |
| 468 | final var tabIterator = tabPane.getTabs().iterator(); | |
| 469 | ||
| 470 | while( tabIterator.hasNext() ) { | |
| 471 | final var tab = tabIterator.next(); | |
| 472 | final var resource = tab.getContent(); | |
| 473 | ||
| 474 | // The definition panes auto-save, so being specific here prevents | |
| 475 | // closing the definitions in the situation where the user wants to | |
| 476 | // continue editing (i.e., possibly save unsaved work). | |
| 477 | if( !(resource instanceof TextEditor) ) { | |
| 478 | continue; | |
| 479 | } | |
| 480 | ||
| 481 | if( canClose( (TextEditor) resource ) ) { | |
| 482 | tabIterator.remove(); | |
| 483 | close( tab ); | |
| 484 | } | |
| 485 | else { | |
| 486 | closable = false; | |
| 487 | } | |
| 488 | } | |
| 489 | } | |
| 490 | ||
| 491 | return closable; | |
| 492 | } | |
| 493 | ||
| 494 | /** | |
| 495 | * Calls the tab's {@link Tab#getOnClosed()} handler to carry out a close | |
| 496 | * event. | |
| 497 | * | |
| 498 | * @param tab The {@link Tab} that was closed. | |
| 499 | */ | |
| 500 | private void close( final Tab tab ) { | |
| 501 | assert tab != null; | |
| 502 | ||
| 503 | final var handler = tab.getOnClosed(); | |
| 504 | ||
| 505 | if( handler != null ) { | |
| 506 | handler.handle( new ActionEvent() ); | |
| 507 | } | |
| 508 | } | |
| 509 | ||
| 510 | /** | |
| 511 | * Closes the active tab; delegates to {@link #canClose(TextResource)}. | |
| 512 | */ | |
| 513 | public void close() { | |
| 514 | final var editor = getActiveTextEditor(); | |
| 515 | ||
| 516 | if( canClose( editor ) ) { | |
| 517 | close( editor ); | |
| 518 | } | |
| 519 | } | |
| 520 | ||
| 521 | /** | |
| 522 | * Closes the given {@link TextResource}. This must not be called from within | |
| 523 | * a loop that iterates over the tab panes using {@code forEach}, lest a | |
| 524 | * concurrent modification exception be thrown. | |
| 525 | * | |
| 526 | * @param resource The {@link TextResource} to close, without confirming with | |
| 527 | * the user. | |
| 528 | */ | |
| 529 | private void close( final TextResource resource ) { | |
| 530 | getTab( resource ).ifPresent( | |
| 531 | ( tab ) -> { | |
| 532 | close( tab ); | |
| 533 | tab.getTabPane().getTabs().remove( tab ); | |
| 534 | } | |
| 535 | ); | |
| 536 | } | |
| 537 | ||
| 538 | /** | |
| 539 | * Answers whether the given {@link TextResource} may be closed. | |
| 540 | * | |
| 541 | * @param editor The {@link TextResource} to try closing. | |
| 542 | * @return {@code true} when the editor may be closed; {@code false} when | |
| 543 | * the user has requested to keep the editor open. | |
| 544 | */ | |
| 545 | private boolean canClose( final TextResource editor ) { | |
| 546 | final var editorTab = getTab( editor ); | |
| 547 | final var canClose = new AtomicBoolean( true ); | |
| 548 | ||
| 549 | if( editor.isModified() ) { | |
| 550 | final var filename = new StringBuilder(); | |
| 551 | editorTab.ifPresent( ( tab ) -> filename.append( tab.getText() ) ); | |
| 552 | ||
| 553 | final var message = sNotifier.createNotification( | |
| 554 | Messages.get( "Alert.file.close.title" ), | |
| 555 | Messages.get( "Alert.file.close.text" ), | |
| 556 | filename.toString() | |
| 557 | ); | |
| 558 | ||
| 559 | final var dialog = sNotifier.createConfirmation( getWindow(), message ); | |
| 560 | ||
| 561 | dialog.showAndWait().ifPresent( | |
| 562 | save -> canClose.set( save == YES ? editor.save() : save == NO ) | |
| 563 | ); | |
| 564 | } | |
| 565 | ||
| 566 | return canClose.get(); | |
| 567 | } | |
| 568 | ||
| 569 | private ObjectProperty<TextEditor> createActiveTextEditor() { | |
| 570 | final var editor = new SimpleObjectProperty<TextEditor>(); | |
| 571 | ||
| 572 | editor.addListener( ( c, o, n ) -> { | |
| 573 | if( n != null ) { | |
| 574 | mPreview.setBaseUri( n.getPath() ); | |
| 575 | process( n ); | |
| 576 | } | |
| 577 | } ); | |
| 578 | ||
| 579 | return editor; | |
| 580 | } | |
| 581 | ||
| 582 | /** | |
| 583 | * Adds the HTML preview tab to its own, singular tab pane. | |
| 584 | */ | |
| 585 | public void viewPreview() { | |
| 586 | viewTab( mPreview, TEXT_HTML, "Pane.preview.title" ); | |
| 587 | } | |
| 588 | ||
| 589 | /** | |
| 590 | * Adds the document outline tab to its own, singular tab pane. | |
| 591 | */ | |
| 592 | public void viewOutline() { | |
| 593 | viewTab( mOutline, APP_DOCUMENT_OUTLINE, "Pane.outline.title" ); | |
| 594 | } | |
| 595 | ||
| 596 | public void viewStatistics() { | |
| 597 | viewTab( mStatistics, APP_DOCUMENT_STATISTICS, "Pane.statistics.title" ); | |
| 598 | } | |
| 599 | ||
| 600 | public void viewFiles() { | |
| 601 | try { | |
| 602 | final var factory = new FilePickerFactory( getWorkspace() ); | |
| 603 | final var fileManager = factory.createModeless(); | |
| 604 | viewTab( fileManager, APP_FILE_MANAGER, "Pane.files.title" ); | |
| 605 | } catch( final Exception ex ) { | |
| 606 | clue( ex ); | |
| 607 | } | |
| 608 | } | |
| 609 | ||
| 610 | private void viewTab( | |
| 611 | final Node node, final MediaType mediaType, final String key ) { | |
| 612 | final var tabPane = obtainTabPane( mediaType ); | |
| 613 | ||
| 614 | for( final var tab : tabPane.getTabs() ) { | |
| 615 | if( tab.getContent() == node ) { | |
| 616 | return; | |
| 617 | } | |
| 618 | } | |
| 619 | ||
| 620 | tabPane.getTabs().add( createTab( get( key ), node ) ); | |
| 621 | addTabPane( tabPane ); | |
| 622 | } | |
| 623 | ||
| 624 | public void viewRefresh() { | |
| 625 | mPreview.refresh(); | |
| 626 | } | |
| 627 | ||
| 628 | /** | |
| 629 | * Returns the tab that contains the given {@link TextEditor}. | |
| 630 | * | |
| 631 | * @param editor The {@link TextEditor} instance to find amongst the tabs. | |
| 632 | * @return The first tab having content that matches the given tab. | |
| 633 | */ | |
| 634 | private Optional<Tab> getTab( final TextResource editor ) { | |
| 635 | return mTabPanes.stream() | |
| 636 | .flatMap( pane -> pane.getTabs().stream() ) | |
| 637 | .filter( tab -> editor.equals( tab.getContent() ) ) | |
| 638 | .findFirst(); | |
| 639 | } | |
| 640 | ||
| 641 | /** | |
| 642 | * Creates a new {@link DefinitionEditor} wrapped in a listener that | |
| 643 | * is used to detect when the active {@link DefinitionEditor} has changed. | |
| 644 | * Upon changing, the variables are interpolated and the active text editor | |
| 645 | * is refreshed. | |
| 646 | * | |
| 647 | * @param textEditor Text editor to update with the revised resolved map. | |
| 648 | * @return A newly configured property that represents the active | |
| 649 | * {@link DefinitionEditor}, never null. | |
| 650 | */ | |
| 651 | private ObjectProperty<TextDefinition> createActiveDefinitionEditor( | |
| 652 | final ObjectProperty<TextEditor> textEditor ) { | |
| 653 | final var defEditor = new SimpleObjectProperty<>( | |
| 654 | createDefinitionEditor() | |
| 655 | ); | |
| 656 | ||
| 657 | defEditor.addListener( ( c, o, n ) -> process( textEditor.get() ) ); | |
| 658 | ||
| 659 | return defEditor; | |
| 660 | } | |
| 661 | ||
| 662 | private Tab createTab( final String filename, final Node node ) { | |
| 663 | return new DetachableTab( filename, node ); | |
| 664 | } | |
| 665 | ||
| 666 | private Tab createTab( final File file ) { | |
| 667 | final var r = createTextResource( file ); | |
| 668 | final var tab = createTab( r.getFilename(), r.getNode() ); | |
| 669 | ||
| 670 | r.modifiedProperty().addListener( | |
| 671 | ( c, o, n ) -> tab.setText( r.getFilename() + (n ? "*" : "") ) | |
| 672 | ); | |
| 673 | ||
| 674 | // This is called when either the tab is closed by the user clicking on | |
| 675 | // the tab's close icon or when closing (all) from the file menu. | |
| 676 | tab.setOnClosed( | |
| 677 | ( __ ) -> getRecentFiles().remove( file.getAbsolutePath() ) | |
| 678 | ); | |
| 679 | ||
| 680 | // When closing a tab, give focus to the newly revealed tab. | |
| 681 | tab.selectedProperty().addListener( ( c, o, n ) -> { | |
| 682 | if( n != null && n ) { | |
| 683 | final var pane = tab.getTabPane(); | |
| 684 | ||
| 685 | if( pane != null ) { | |
| 686 | pane.requestFocus(); | |
| 687 | } | |
| 688 | } | |
| 689 | } ); | |
| 690 | ||
| 691 | tab.tabPaneProperty().addListener( ( cPane, oPane, nPane ) -> { | |
| 692 | if( nPane != null ) { | |
| 693 | nPane.focusedProperty().addListener( ( c, o, n ) -> { | |
| 694 | if( n != null && n ) { | |
| 695 | final var selected = nPane.getSelectionModel().getSelectedItem(); | |
| 696 | final var node = selected.getContent(); | |
| 697 | node.requestFocus(); | |
| 698 | } | |
| 699 | } ); | |
| 700 | } | |
| 701 | } ); | |
| 702 | ||
| 703 | return tab; | |
| 704 | } | |
| 705 | ||
| 706 | /** | |
| 707 | * Creates bins for the different {@link MediaType}s, which eventually are | |
| 708 | * added to the UI as separate tab panes. If ever a general-purpose scene | |
| 709 | * exporter is developed to serialize a scene to an FXML file, this could | |
| 710 | * be replaced by such a class. | |
| 711 | * <p> | |
| 712 | * When binning the files, this makes sure that at least one file exists | |
| 713 | * for every type. If the user has opted to close a particular type (such | |
| 714 | * as the definition pane), the view will suppressed elsewhere. | |
| 715 | * </p> | |
| 716 | * <p> | |
| 717 | * The order that the binned files are returned will be reflected in the | |
| 718 | * order that the corresponding panes are rendered in the UI. | |
| 719 | * </p> | |
| 720 | * | |
| 721 | * @param paths The file paths to bin according to their type. | |
| 722 | * @return An in-order list of files, first by structured definition files, | |
| 723 | * then by plain text documents. | |
| 724 | */ | |
| 725 | private List<File> collect( final SetProperty<String> paths ) { | |
| 726 | // Treat all files destined for the text editor as plain text documents | |
| 727 | // so that they are added to the same pane. Grouping by TEXT_PLAIN is a | |
| 728 | // bit arbitrary, but means explicitly capturing TEXT_PLAIN isn't needed. | |
| 729 | final Function<MediaType, MediaType> bin = | |
| 730 | m -> PLAIN_TEXT_FORMAT.contains( m ) ? TEXT_PLAIN : m; | |
| 731 | ||
| 732 | // Create two groups: YAML files and plain text files. | |
| 733 | final var bins = paths | |
| 734 | .stream() | |
| 735 | .collect( | |
| 736 | groupingBy( path -> bin.apply( MediaType.fromFilename( path ) ) ) | |
| 737 | ); | |
| 738 | ||
| 739 | bins.putIfAbsent( TEXT_YAML, List.of( DEFINITION_DEFAULT.toString() ) ); | |
| 740 | bins.putIfAbsent( TEXT_PLAIN, List.of( DOCUMENT_DEFAULT.toString() ) ); | |
| 741 | ||
| 742 | final var result = new ArrayList<File>( paths.size() ); | |
| 743 | ||
| 744 | // Ensure that the same types are listed together (keep insertion order). | |
| 745 | bins.forEach( ( mediaType, files ) -> result.addAll( | |
| 746 | files.stream().map( File::new ).collect( Collectors.toList() ) ) | |
| 747 | ); | |
| 748 | ||
| 749 | return result; | |
| 750 | } | |
| 751 | ||
| 752 | /** | |
| 753 | * Force the active editor to update, which will cause the processor | |
| 754 | * to re-evaluate the interpolated definition map thereby updating the | |
| 755 | * preview pane. | |
| 756 | * | |
| 757 | * @param editor Contains the source document to update in the preview pane. | |
| 758 | */ | |
| 759 | private void process( final TextEditor editor ) { | |
| 760 | // Ensure processing does not run on the JavaFX thread, which frees the | |
| 761 | // text editor immediately for caret movement. The preview will have a | |
| 762 | // slight delay when catching up to the caret position. | |
| 763 | final var task = new Task<Void>() { | |
| 764 | @Override | |
| 765 | public Void call() { | |
| 766 | try { | |
| 767 | final var p = mProcessors.getOrDefault( editor, IDENTITY ); | |
| 768 | p.apply( editor == null ? "" : editor.getText() ); | |
| 769 | } catch( final Exception ex ) { | |
| 770 | clue( ex ); | |
| 771 | } | |
| 772 | ||
| 773 | return null; | |
| 774 | } | |
| 775 | }; | |
| 776 | ||
| 777 | task.setOnSucceeded( | |
| 778 | e -> invokeLater( () -> mPreview.scrollTo( CARET_ID ) ) | |
| 779 | ); | |
| 780 | ||
| 781 | // Prevents multiple process requests from executing simultaneously (due | |
| 782 | // to having a restricted queue size). | |
| 783 | sExecutor.execute( task ); | |
| 784 | } | |
| 785 | ||
| 786 | /** | |
| 787 | * Lazily creates a {@link TabPane} configured to listen for tab select | |
| 788 | * events. The tab pane is associated with a given media type so that | |
| 789 | * similar files can be grouped together. | |
| 790 | * | |
| 791 | * @param mediaType The media type to associate with the tab pane. | |
| 792 | * @return An instance of {@link TabPane} that will handle tab docking. | |
| 793 | */ | |
| 794 | private TabPane obtainTabPane( final MediaType mediaType ) { | |
| 795 | for( final var pane : mTabPanes ) { | |
| 796 | for( final var tab : pane.getTabs() ) { | |
| 797 | final var node = tab.getContent(); | |
| 798 | ||
| 799 | if( node instanceof TextResource r && r.supports( mediaType ) ) { | |
| 800 | return pane; | |
| 801 | } | |
| 802 | } | |
| 803 | } | |
| 804 | ||
| 805 | final var pane = createTabPane(); | |
| 806 | mTabPanes.add( pane ); | |
| 807 | return pane; | |
| 808 | } | |
| 809 | ||
| 810 | /** | |
| 811 | * Creates an initialized {@link TabPane} instance. | |
| 812 | * | |
| 813 | * @return A new {@link TabPane} with all listeners configured. | |
| 814 | */ | |
| 815 | private TabPane createTabPane() { | |
| 816 | final var tabPane = new DetachableTabPane(); | |
| 817 | ||
| 818 | initStageOwnerFactory( tabPane ); | |
| 819 | initTabListener( tabPane ); | |
| 820 | ||
| 821 | return tabPane; | |
| 822 | } | |
| 823 | ||
| 824 | /** | |
| 825 | * When any {@link DetachableTabPane} is detached from the main window, | |
| 826 | * the stage owner factory must be given its parent window, which will | |
| 827 | * own the child window. The parent window is the {@link MainPane}'s | |
| 828 | * {@link Scene}'s {@link Window} instance. | |
| 829 | * | |
| 830 | * <p> | |
| 831 | * This will derives the new title from the main window title, incrementing | |
| 832 | * the window count to help uniquely identify the child windows. | |
| 833 | * </p> | |
| 834 | * | |
| 835 | * @param tabPane A new {@link DetachableTabPane} to configure. | |
| 836 | */ | |
| 837 | private void initStageOwnerFactory( final DetachableTabPane tabPane ) { | |
| 838 | tabPane.setStageOwnerFactory( ( stage ) -> { | |
| 839 | final var title = get( | |
| 840 | "Detach.tab.title", | |
| 841 | ((Stage) getWindow()).getTitle(), ++mWindowCount | |
| 842 | ); | |
| 843 | stage.setTitle( title ); | |
| 844 | ||
| 845 | return getScene().getWindow(); | |
| 846 | } ); | |
| 847 | } | |
| 848 | ||
| 849 | /** | |
| 850 | * Responsible for configuring the content of each {@link DetachableTab} when | |
| 851 | * it is added to the given {@link DetachableTabPane} instance. | |
| 852 | * <p> | |
| 853 | * For {@link TextEditor} contents, an instance of {@link ScrollEventHandler} | |
| 854 | * is initialized to perform synchronized scrolling between the editor and | |
| 855 | * its preview window. Additionally, the last tab in the tab pane's list of | |
| 856 | * tabs is given focus. | |
| 857 | * </p> | |
| 858 | * <p> | |
| 859 | * Note that multiple tabs can be added simultaneously. | |
| 860 | * </p> | |
| 861 | * | |
| 862 | * @param tabPane A new {@link TabPane} to configure. | |
| 863 | */ | |
| 864 | private void initTabListener( final TabPane tabPane ) { | |
| 865 | tabPane.getTabs().addListener( | |
| 866 | ( final ListChangeListener.Change<? extends Tab> listener ) -> { | |
| 867 | while( listener.next() ) { | |
| 868 | if( listener.wasAdded() ) { | |
| 869 | final var tabs = listener.getAddedSubList(); | |
| 870 | ||
| 871 | tabs.forEach( ( tab ) -> { | |
| 872 | final var node = tab.getContent(); | |
| 873 | ||
| 874 | if( node instanceof TextEditor ) { | |
| 875 | initScrollEventListener( tab ); | |
| 876 | } | |
| 877 | } ); | |
| 878 | ||
| 879 | // Select and give focus to the last tab opened. | |
| 880 | final var index = tabs.size() - 1; | |
| 881 | if( index >= 0 ) { | |
| 882 | final var tab = tabs.get( index ); | |
| 883 | tabPane.getSelectionModel().select( tab ); | |
| 884 | tab.getContent().requestFocus(); | |
| 885 | } | |
| 886 | } | |
| 887 | } | |
| 888 | } | |
| 889 | ); | |
| 890 | } | |
| 891 | ||
| 892 | /** | |
| 893 | * Synchronizes scrollbar positions between the given {@link Tab} that | |
| 894 | * contains an instance of {@link TextEditor} and {@link HtmlPreview} pane. | |
| 895 | * | |
| 896 | * @param tab The container for an instance of {@link TextEditor}. | |
| 897 | */ | |
| 898 | private void initScrollEventListener( final Tab tab ) { | |
| 899 | final var editor = (TextEditor) tab.getContent(); | |
| 900 | final var scrollPane = editor.getScrollPane(); | |
| 901 | final var scrollBar = mPreview.getVerticalScrollBar(); | |
| 902 | final var handler = new ScrollEventHandler( scrollPane, scrollBar ); | |
| 903 | handler.enabledProperty().bind( tab.selectedProperty() ); | |
| 904 | } | |
| 905 | ||
| 906 | private void addTabPane( final int index, final TabPane tabPane ) { | |
| 907 | final var items = getItems(); | |
| 908 | if( !items.contains( tabPane ) ) { | |
| 909 | items.add( index, tabPane ); | |
| 910 | } | |
| 911 | } | |
| 912 | ||
| 913 | private void addTabPane( final TabPane tabPane ) { | |
| 914 | addTabPane( getItems().size(), tabPane ); | |
| 915 | } | |
| 916 | ||
| 917 | public ProcessorContext createProcessorContext() { | |
| 918 | return createProcessorContext( null, NONE ); | |
| 919 | } | |
| 920 | ||
| 921 | public ProcessorContext createProcessorContext( | |
| 922 | final Path exportPath, final ExportFormat format ) { | |
| 923 | final var textEditor = getActiveTextEditor(); | |
| 924 | return createProcessorContext( | |
| 925 | textEditor.getPath(), exportPath, format, textEditor.getCaret() ); | |
| 926 | } | |
| 927 | ||
| 928 | private ProcessorContext createProcessorContext( | |
| 929 | final Path inputPath, final Caret caret ) { | |
| 930 | return createProcessorContext( inputPath, null, NONE, caret ); | |
| 931 | } | |
| 932 | ||
| 933 | /** | |
| 934 | * @param inputPath Used by {@link ProcessorFactory} to determine | |
| 935 | * {@link Processor} type to create based on file type. | |
| 936 | * @param outputPath Used when exporting to a PDF file (binary). | |
| 937 | * @param format Used when processors export to a new text format. | |
| 938 | * @param caret Used by {@link CaretExtension} to add ID attribute into | |
| 939 | * preview document for scrollbar synchronization. | |
| 940 | * @return A new {@link ProcessorContext} to use when creating an instance of | |
| 941 | * {@link Processor}. | |
| 942 | */ | |
| 943 | private ProcessorContext createProcessorContext( | |
| 944 | final Path inputPath, | |
| 945 | final Path outputPath, | |
| 946 | final ExportFormat format, | |
| 947 | final Caret caret ) { | |
| 948 | return ProcessorContext.builder() | |
| 949 | .with( ProcessorContext.Mutator::setInputPath, inputPath ) | |
| 950 | .with( ProcessorContext.Mutator::setOutputPath, outputPath ) | |
| 951 | .with( ProcessorContext.Mutator::setExportFormat, format ) | |
| 952 | .with( ProcessorContext.Mutator::setHtmlPreview, mPreview ) | |
| 953 | .with( ProcessorContext.Mutator::setTextDefinition, mActiveDefinitionEditor ) | |
| 954 | .with( ProcessorContext.Mutator::setWorkspace, mWorkspace ) | |
| 955 | .with( ProcessorContext.Mutator::setCaret, caret ) | |
| 956 | .build(); | |
| 957 | } | |
| 958 | ||
| 959 | private TextResource createTextResource( final File file ) { | |
| 960 | // TODO: Create PlainTextEditor that's returned by default. | |
| 961 | return MediaType.valueFrom( file ) == TEXT_YAML | |
| 962 | ? createDefinitionEditor( file ) | |
| 963 | : createMarkdownEditor( file ); | |
| 964 | } | |
| 965 | ||
| 966 | /** | |
| 967 | * Creates an instance of {@link MarkdownEditor} that listens for both | |
| 968 | * caret change events and text change events. Text change events must | |
| 969 | * take priority over caret change events because it's possible to change | |
| 970 | * the text without moving the caret (e.g., delete selected text). | |
| 971 | * | |
| 972 | * @param inputFile The file containing contents for the text editor. | |
| 973 | * @return A non-null text editor. | |
| 974 | */ | |
| 975 | private TextResource createMarkdownEditor( final File inputFile ) { | |
| 976 | final var inputPath = inputFile.toPath(); | |
| 977 | final var editor = new MarkdownEditor( inputFile, getWorkspace() ); | |
| 978 | final var caret = editor.getCaret(); | |
| 979 | final var context = createProcessorContext( inputPath, caret ); | |
| 980 | ||
| 981 | mProcessors.computeIfAbsent( editor, p -> createProcessors( context ) ); | |
| 982 | ||
| 983 | editor.addDirtyListener( ( c, o, n ) -> { | |
| 984 | if( n ) { | |
| 985 | // Reset the status to OK after changing the text. | |
| 986 | clue(); | |
| 987 | ||
| 988 | // Processing the text may update the status bar. | |
| 989 | process( getActiveTextEditor() ); | |
| 990 | } | |
| 991 | } ); | |
| 992 | ||
| 993 | editor.addEventListener( | |
| 994 | keyPressed( SPACE, CONTROL_DOWN ), this::autoinsert | |
| 995 | ); | |
| 996 | ||
| 997 | // Set the active editor, which refreshes the preview panel. | |
| 998 | mActiveTextEditor.set( editor ); | |
| 999 | ||
| 1000 | return editor; | |
| 1001 | } | |
| 1002 | ||
| 1003 | /** | |
| 1004 | * Delegates to {@link #autoinsert()}. | |
| 1005 | * | |
| 1006 | * @param event Ignored. | |
| 1007 | */ | |
| 1008 | private void autoinsert( final KeyEvent event ) { | |
| 1009 | autoinsert(); | |
| 1010 | } | |
| 1011 | ||
| 1012 | /** | |
| 1013 | * Finds a node that matches the word at the caret, then inserts the | |
| 1014 | * corresponding definition. The definition token delimiters depend on | |
| 1015 | * the type of file being edited. | |
| 1016 | */ | |
| 1017 | public void autoinsert() { | |
| 1018 | final var definitions = getActiveTextDefinition(); | |
| 1019 | final var editor = getActiveTextEditor(); | |
| 1020 | final var mediaType = editor.getMediaType(); | |
| 1021 | final var operator = getSigilOperator( mediaType ); | |
| 1022 | ||
| 1023 | DefinitionNameInjector.autoinsert( editor, definitions, operator ); | |
| 1024 | } | |
| 1025 | ||
| 1026 | private TextDefinition createDefinitionEditor() { | |
| 1027 | return createDefinitionEditor( DEFINITION_DEFAULT ); | |
| 1028 | } | |
| 1029 | ||
| 1030 | private TextDefinition createDefinitionEditor( final File file ) { | |
| 1031 | final var editor = new DefinitionEditor( | |
| 1032 | file, createTreeTransformer(), createYamlSigilOperator() ); | |
| 1033 | editor.addTreeChangeHandler( mTreeHandler ); | |
| 1034 | return editor; | |
| 1035 | } | |
| 1036 | ||
| 1037 | private TreeTransformer createTreeTransformer() { | |
| 1038 | return new YamlTreeTransformer(); | |
| 1039 | } | |
| 1040 | ||
| 1041 | private Tooltip createTooltip( final File file ) { | |
| 1042 | final var path = file.toPath(); | |
| 1043 | final var tooltip = new Tooltip( path.toString() ); | |
| 1044 | ||
| 1045 | tooltip.setShowDelay( millis( 200 ) ); | |
| 1046 | return tooltip; | |
| 1047 | } | |
| 1048 | ||
| 1049 | public TextEditor getActiveTextEditor() { | |
| 1050 | return mActiveTextEditor.get(); | |
| 1051 | } | |
| 1052 | ||
| 1053 | public ReadOnlyObjectProperty<TextEditor> activeTextEditorProperty() { | |
| 1054 | return mActiveTextEditor; | |
| 1055 | } | |
| 1056 | ||
| 1057 | public TextDefinition getActiveTextDefinition() { | |
| 1058 | return mActiveDefinitionEditor.get(); | |
| 1059 | } | |
| 1060 | ||
| 1061 | public Window getWindow() { | |
| 1062 | return getScene().getWindow(); | |
| 1063 | } | |
| 1064 | ||
| 1065 | public Workspace getWorkspace() { | |
| 1066 | return mWorkspace; | |
| 1067 | } | |
| 1068 | ||
| 1069 | /** | |
| 1070 | * Returns the sigil operator for the given {@link MediaType}. | |
| 1071 | * | |
| 1072 | * @param mediaType The type of file being edited. | |
| 1073 | */ | |
| 1074 | private SigilOperator getSigilOperator( final MediaType mediaType ) { | |
| 1075 | final var operator = new YamlSigilOperator( createDefinitionSigils() ); | |
| 1076 | ||
| 1077 | return mediaType == TEXT_R_MARKDOWN | |
| 1078 | ? new RSigilOperator( createRSigils(), operator ) | |
| 1079 | : operator; | |
| 1080 | } | |
| 1081 | ||
| 1082 | /** | |
| 1083 | * Returns the set of file names opened in the application. The names must | |
| 1084 | * be converted to {@link File} objects. | |
| 1085 | * | |
| 1086 | * @return A {@link Set} of file names. | |
| 1087 | */ | |
| 1088 | private SetProperty<String> getRecentFiles() { | |
| 1089 | return getWorkspace().setsProperty( KEY_UI_FILES_PATH ); | |
| 1090 | } | |
| 1091 | ||
| 1092 | private StringProperty stringProperty( final Key key ) { | |
| 1093 | return getWorkspace().stringProperty( key ); | |
| 1094 | } | |
| 1095 | ||
| 1096 | private SigilOperator createYamlSigilOperator() { | |
| 1097 | return new YamlSigilOperator( createDefinitionSigils() ); | |
| 1098 | } | |
| 1099 | ||
| 1100 | private Sigils createRSigils() { | |
| 1101 | return createSigils( KEY_R_DELIM_BEGAN, KEY_R_DELIM_ENDED ); | |
| 1102 | } | |
| 1103 | ||
| 1104 | private Sigils createDefinitionSigils() { | |
| 1105 | return createSigils( KEY_DEF_DELIM_BEGAN, KEY_DEF_DELIM_ENDED ); | |
| 1106 | } | |
| 1107 | ||
| 1108 | private Sigils createSigils( final Key began, final Key ended ) { | |
| 1109 | return new Sigils( stringProperty( began ), stringProperty( ended ) ); | |
| 1126 | 1110 | } |
| 1127 | 1111 | } |
| 5 | 5 | import com.keenwrite.io.FileWatchService; |
| 6 | 6 | import com.keenwrite.preferences.Workspace; |
| 7 | import com.keenwrite.ui.actions.ApplicationActions; | |
| 7 | import com.keenwrite.ui.actions.GuiCommands; | |
| 8 | 8 | import com.keenwrite.ui.listeners.CaretListener; |
| 9 | 9 | import javafx.scene.Node; |
| ... | ||
| 16 | 16 | |
| 17 | 17 | import java.io.File; |
| 18 | import java.text.MessageFormat; | |
| 18 | 19 | |
| 19 | import static com.keenwrite.Messages.get; | |
| 20 | 20 | import static com.keenwrite.constants.Constants.*; |
| 21 | 21 | import static com.keenwrite.events.ScrollLockEvent.fireScrollLockEvent; |
| ... | ||
| 134 | 134 | |
| 135 | 135 | private String getStylesheet( final String filename ) { |
| 136 | return get( STYLESHEET_APPLICATION_SKIN, filename ); | |
| 136 | return MessageFormat.format( STYLESHEET_APPLICATION_SKIN, filename ); | |
| 137 | 137 | } |
| 138 | 138 | |
| ... | ||
| 166 | 166 | } |
| 167 | 167 | |
| 168 | private ApplicationActions createApplicationActions( | |
| 168 | private GuiCommands createApplicationActions( | |
| 169 | 169 | final MainPane mainPane ) { |
| 170 | return new ApplicationActions( this, mainPane ); | |
| 170 | return new GuiCommands( this, mainPane ); | |
| 171 | 171 | } |
| 172 | 172 | |
| 3 | 3 | |
| 4 | 4 | import com.keenwrite.preferences.Key; |
| 5 | import com.keenwrite.sigils.SigilOperator; | |
| 6 | import com.keenwrite.util.InterpolatingMap; | |
| 5 | 7 | |
| 6 | 8 | import java.text.MessageFormat; |
| 7 | import java.util.Enumeration; | |
| 8 | 9 | import java.util.ResourceBundle; |
| 9 | import java.util.Stack; | |
| 10 | 10 | |
| 11 | 11 | import static com.keenwrite.constants.Constants.APP_BUNDLE_NAME; |
| ... | ||
| 18 | 18 | public final class Messages { |
| 19 | 19 | |
| 20 | private static final ResourceBundle RESOURCE_BUNDLE = | |
| 21 | getBundle( APP_BUNDLE_NAME ); | |
| 20 | private static final SigilOperator OPERATOR = createBundleSigilOperator(); | |
| 21 | private static final InterpolatingMap MAP = new InterpolatingMap(); | |
| 22 | 22 | |
| 23 | private Messages() { | |
| 23 | static { | |
| 24 | // Obtains the application resource bundle using the default locale. The | |
| 25 | // locale cannot be changed using the application, making interpolation of | |
| 26 | // values viable as a one-time operation. | |
| 27 | final var BUNDLE = getBundle( APP_BUNDLE_NAME ); | |
| 28 | BUNDLE.keySet().forEach( key -> MAP.put( key, BUNDLE.getString( key ) ) ); | |
| 29 | MAP.interpolate( OPERATOR ); | |
| 24 | 30 | } |
| 25 | ||
| 26 | /** | |
| 27 | * Return the value of a resource bundle value after having resolved any | |
| 28 | * references to other bundle variables. | |
| 29 | * | |
| 30 | * @param props The bundle containing resolvable properties. | |
| 31 | * @param s The value for a key to resolve. | |
| 32 | * @return The value of the key with all references recursively dereferenced. | |
| 33 | */ | |
| 34 | @SuppressWarnings( "SameParameterValue" ) | |
| 35 | private static String resolve( final ResourceBundle props, final String s ) { | |
| 36 | final var len = s.length(); | |
| 37 | final var stack = new Stack<StringBuilder>(); | |
| 38 | var sb = new StringBuilder( 256 ); | |
| 39 | var open = false; | |
| 40 | ||
| 41 | for( var i = 0; i < len; i++ ) { | |
| 42 | final var c = s.charAt( i ); | |
| 43 | ||
| 44 | switch( c ) { | |
| 45 | case '$': { | |
| 46 | if( i + 1 < len && s.charAt( i + 1 ) == '{' ) { | |
| 47 | stack.push( sb ); | |
| 48 | ||
| 49 | if( stack.size() > 20 ) { | |
| 50 | final var m = get( "Main.status.error.messages.recursion", s ); | |
| 51 | throw new IllegalArgumentException( m ); | |
| 52 | } | |
| 53 | ||
| 54 | sb = new StringBuilder( 256 ); | |
| 55 | i++; | |
| 56 | open = true; | |
| 57 | } | |
| 58 | ||
| 59 | break; | |
| 60 | } | |
| 61 | ||
| 62 | case '}': { | |
| 63 | if( open ) { | |
| 64 | open = false; | |
| 65 | final var name = sb.toString(); | |
| 66 | ||
| 67 | sb = stack.pop(); | |
| 68 | sb.append( props.getString( name ) ); | |
| 69 | break; | |
| 70 | } | |
| 71 | } | |
| 72 | ||
| 73 | default: { | |
| 74 | sb.append( c ); | |
| 75 | break; | |
| 76 | } | |
| 77 | } | |
| 78 | } | |
| 79 | ||
| 80 | if( open ) { | |
| 81 | final var m = get( "Main.status.error.messages.syntax", s ); | |
| 82 | throw new IllegalArgumentException( m ); | |
| 83 | } | |
| 84 | 31 | |
| 85 | return sb.toString(); | |
| 32 | private Messages() { | |
| 86 | 33 | } |
| 87 | 34 | |
| 88 | 35 | /** |
| 89 | * Returns the value for a key from the message bundle. | |
| 36 | * Returns the value for a key from the message bundle. If the value cannot | |
| 37 | * be found, this returns the key. | |
| 90 | 38 | * |
| 91 | 39 | * @param key Retrieve the value for this key. |
| 92 | * @return The value for the key. | |
| 40 | * @return The value for the key, or the key itself if not found. | |
| 93 | 41 | */ |
| 94 | 42 | public static String get( final String key ) { |
| 95 | try { | |
| 96 | return resolve( RESOURCE_BUNDLE, RESOURCE_BUNDLE.getString( key ) ); | |
| 97 | } catch( final Exception ignored ) { | |
| 98 | return key; | |
| 99 | } | |
| 43 | final var v = MAP.get( OPERATOR.entoken( key ) ); | |
| 44 | return v == null ? key : v; | |
| 100 | 45 | } |
| 101 | 46 | |
| ... | ||
| 108 | 53 | public static String get( final Key key ) { |
| 109 | 54 | return get( key.toString() ); |
| 110 | } | |
| 111 | ||
| 112 | public static String getLiteral( final String key ) { | |
| 113 | return RESOURCE_BUNDLE.getString( key ); | |
| 114 | } | |
| 115 | ||
| 116 | public static String get( final String key, final boolean interpolate ) { | |
| 117 | return interpolate ? get( key ) : getLiteral( key ); | |
| 118 | 55 | } |
| 119 | 56 | |
| 120 | 57 | /** |
| 121 | 58 | * Returns the value for a key from the message bundle with the arguments |
| 122 | * replacing <code>{#}</code> place holders. | |
| 59 | * replacing <code>{#}</code> placeholders. | |
| 123 | 60 | * |
| 124 | 61 | * @param key Retrieve the value for this key. |
| 125 | * @param args The values to substitute for place holders. | |
| 62 | * @param args The values to substitute for placeholders. | |
| 126 | 63 | * @return The value for the key. |
| 127 | 64 | */ |
| ... | ||
| 138 | 75 | */ |
| 139 | 76 | public static boolean containsKey( final String key ) { |
| 140 | return RESOURCE_BUNDLE.containsKey( key ); | |
| 77 | return MAP.containsKey( OPERATOR.entoken( key ) ); | |
| 141 | 78 | } |
| 142 | 79 | |
| 143 | /** | |
| 144 | * Returns all key names in the application's messages properties file. | |
| 145 | * | |
| 146 | * @return All key names in the {@link ResourceBundle} encapsulated by | |
| 147 | * this class. | |
| 148 | */ | |
| 149 | public static Enumeration<String> getKeys() { | |
| 150 | return RESOURCE_BUNDLE.getKeys(); | |
| 80 | private static SigilOperator createBundleSigilOperator() { | |
| 81 | return new SigilOperator( "${", "}" ); | |
| 151 | 82 | } |
| 152 | 83 | } |
| 1 | package com.keenwrite.cmdline; | |
| 2 | ||
| 3 | import com.keenwrite.ExportFormat; | |
| 4 | import com.keenwrite.processors.ProcessorContext; | |
| 5 | import com.keenwrite.processors.ProcessorContext.Mutator; | |
| 6 | import picocli.CommandLine; | |
| 7 | ||
| 8 | import java.io.File; | |
| 9 | import java.nio.file.Path; | |
| 10 | import java.util.Map; | |
| 11 | import java.util.Set; | |
| 12 | import java.util.concurrent.Callable; | |
| 13 | import java.util.function.Consumer; | |
| 14 | ||
| 15 | @CommandLine.Command( | |
| 16 | name = "KeenWrite", | |
| 17 | mixinStandardHelpOptions = true, | |
| 18 | description = "Plain text editor for editing with variables." | |
| 19 | ) | |
| 20 | @SuppressWarnings( "unused" ) | |
| 21 | public final class Arguments implements Callable<Integer> { | |
| 22 | @CommandLine.Option( | |
| 23 | names = {"-a", "--all"}, | |
| 24 | description = | |
| 25 | "Concatenate files in directory before processing (${DEFAULT-VALUE}).", | |
| 26 | defaultValue = "false" | |
| 27 | ) | |
| 28 | private boolean mAll; | |
| 29 | ||
| 30 | @CommandLine.Option( | |
| 31 | names = {"-d", "--debug"}, | |
| 32 | description = | |
| 33 | "Enable logging to the console (${DEFAULT-VALUE}).", | |
| 34 | defaultValue = "false" | |
| 35 | ) | |
| 36 | private boolean mDebug; | |
| 37 | ||
| 38 | @CommandLine.Option( | |
| 39 | names = {"-i", "--input"}, | |
| 40 | description = | |
| 41 | "Set the file name to read.", | |
| 42 | paramLabel = "FILE", | |
| 43 | defaultValue = "stdin", | |
| 44 | required = true | |
| 45 | ) | |
| 46 | private File mFileInput; | |
| 47 | ||
| 48 | @CommandLine.Option( | |
| 49 | names = {"-f", "--format-type"}, | |
| 50 | description = | |
| 51 | "Export type: html, md, pdf, xml (${DEFAULT-VALUE})", | |
| 52 | paramLabel = "String", | |
| 53 | defaultValue = "pdf", | |
| 54 | required = true | |
| 55 | ) | |
| 56 | private String mFormatType; | |
| 57 | ||
| 58 | @CommandLine.Option( | |
| 59 | names = {"-m", "--metadata"}, | |
| 60 | description = | |
| 61 | "Map metadata keys to values, variable names allowed.", | |
| 62 | paramLabel = "key=value" | |
| 63 | ) | |
| 64 | private Map<String, String> mMetadata; | |
| 65 | ||
| 66 | @CommandLine.Option( | |
| 67 | names = {"-o", "--output"}, | |
| 68 | description = | |
| 69 | "Set the file name to write.", | |
| 70 | paramLabel = "FILE", | |
| 71 | defaultValue = "stdout", | |
| 72 | required = true | |
| 73 | ) | |
| 74 | private File mFileOutput; | |
| 75 | ||
| 76 | @CommandLine.Option( | |
| 77 | names = {"-p", "--images-path"}, | |
| 78 | description = | |
| 79 | "Absolute path to images directory", | |
| 80 | paramLabel = "PATH" | |
| 81 | ) | |
| 82 | private Path mImages; | |
| 83 | ||
| 84 | @CommandLine.Option( | |
| 85 | names = {"-q", "--quiet"}, | |
| 86 | description = | |
| 87 | "Suppress all status messages (${DEFAULT-VALUE}).", | |
| 88 | defaultValue = "false" | |
| 89 | ) | |
| 90 | private boolean mQuiet; | |
| 91 | ||
| 92 | @CommandLine.Option( | |
| 93 | names = {"-s", "--format-subtype-tex"}, | |
| 94 | description = | |
| 95 | "Export subtype for HTML formats: svg, delimited", | |
| 96 | paramLabel = "String" | |
| 97 | ) | |
| 98 | private String mFormatSubtype; | |
| 99 | ||
| 100 | @CommandLine.Option( | |
| 101 | names = {"-t", "--theme"}, | |
| 102 | description = | |
| 103 | "Full theme name file path to use when exporting as a PDF file.", | |
| 104 | paramLabel = "PATH" | |
| 105 | ) | |
| 106 | private String mThemeName; | |
| 107 | ||
| 108 | @CommandLine.Option( | |
| 109 | names = {"-x", "--image-extensions"}, | |
| 110 | description = | |
| 111 | "Space-separated image file name extensions (${DEFAULT-VALUE}).", | |
| 112 | paramLabel = "String", | |
| 113 | defaultValue = "svg pdf png jpg tiff" | |
| 114 | ) | |
| 115 | private Set<String> mExtensions; | |
| 116 | ||
| 117 | @CommandLine.Option( | |
| 118 | names = {"-v", "--variables"}, | |
| 119 | description = | |
| 120 | "Set the file name containing variable definitions (${DEFAULT-VALUE}).", | |
| 121 | paramLabel = "FILE", | |
| 122 | defaultValue = "variables.yaml" | |
| 123 | ) | |
| 124 | private String mFileVariables; | |
| 125 | ||
| 126 | private final Consumer<Arguments> mLauncher; | |
| 127 | ||
| 128 | public Arguments( final Consumer<Arguments> launcher ) { | |
| 129 | mLauncher = launcher; | |
| 130 | } | |
| 131 | ||
| 132 | public ProcessorContext createProcessorContext() { | |
| 133 | final var format = ExportFormat.valueFrom( mFormatType, mFormatSubtype ); | |
| 134 | return ProcessorContext | |
| 135 | .builder() | |
| 136 | .with( Mutator::setInputPath, mFileInput ) | |
| 137 | .with( Mutator::setOutputPath, mFileOutput ) | |
| 138 | .with( Mutator::setExportFormat, format ) | |
| 139 | .build(); | |
| 140 | } | |
| 141 | ||
| 142 | public boolean quiet() { | |
| 143 | return mQuiet; | |
| 144 | } | |
| 145 | ||
| 146 | public boolean debug() { | |
| 147 | return mDebug; | |
| 148 | } | |
| 149 | ||
| 150 | /** | |
| 151 | * Launches the main application window. This is called when not running | |
| 152 | * in headless mode. | |
| 153 | * | |
| 154 | * @return {@code 0} | |
| 155 | * @throws Exception The application encountered an unrecoverable error. | |
| 156 | */ | |
| 157 | @Override | |
| 158 | public Integer call() throws Exception { | |
| 159 | mLauncher.accept( this ); | |
| 160 | return 0; | |
| 161 | } | |
| 162 | } | |
| 1 | 163 |
| 1 | package com.keenwrite.cmdline; | |
| 2 | ||
| 3 | import static picocli.CommandLine.Help.Ansi.Style.*; | |
| 4 | import static picocli.CommandLine.Help.ColorScheme; | |
| 5 | import static picocli.CommandLine.Help.ColorScheme.Builder; | |
| 6 | ||
| 7 | /** | |
| 8 | * Responsible for creating the command-line parser's colour scheme. | |
| 9 | */ | |
| 10 | public class ColourScheme { | |
| 11 | public static ColorScheme create() { | |
| 12 | return new Builder() | |
| 13 | .commands( bold ) | |
| 14 | .options( fg_blue, bold ) | |
| 15 | .parameters( fg_blue ) | |
| 16 | .optionParams( italic ) | |
| 17 | .errors( fg_red, bold ) | |
| 18 | .stackTraces( italic ) | |
| 19 | .build(); | |
| 20 | } | |
| 21 | } | |
| 1 | 22 |
| 1 | package com.keenwrite.cmdline; | |
| 2 | ||
| 3 | import com.keenwrite.AppCommands; | |
| 4 | ||
| 5 | /** | |
| 6 | * Responsible for running the application in headless mode. | |
| 7 | */ | |
| 8 | public class HeadlessApp { | |
| 9 | ||
| 10 | /** | |
| 11 | * Entry point for running the application in headless mode. | |
| 12 | * | |
| 13 | * @param args The parsed command-line arguments. | |
| 14 | */ | |
| 15 | public static void main( final Arguments args ) { | |
| 16 | AppCommands.run( args ); | |
| 17 | } | |
| 18 | } | |
| 1 | 19 |
| 45 | 45 | public static final File DOCUMENT_DEFAULT = getFile( "document" ); |
| 46 | 46 | public static final File DEFINITION_DEFAULT = getFile( "definition" ); |
| 47 | public static final File PDF_DEFAULT = getFile( "pdf" ); | |
| 47 | 48 | |
| 48 | 49 | public static final String APP_BUNDLE_NAME = get( "application.messages" ); |
| ... | ||
| 90 | 91 | public static final String STATUS_DEFINITION_MISSING = |
| 91 | 92 | "Main.status.error.def.missing"; |
| 92 | ||
| 93 | /** | |
| 94 | * Used when creating flat maps relating to resolved variables. | |
| 95 | */ | |
| 96 | public static final int MAP_SIZE_DEFAULT = 128; | |
| 97 | 93 | |
| 98 | 94 | /** |
| 2 | 2 | package com.keenwrite.dom; |
| 3 | 3 | |
| 4 | import org.jetbrains.annotations.NotNull; | |
| 4 | 5 | import org.jsoup.helper.W3CDom; |
| 5 | 6 | import org.jsoup.nodes.Node; |
| ... | ||
| 39 | 40 | private static final NodeVisitor LIGATURE_VISITOR = new NodeVisitor() { |
| 40 | 41 | @Override |
| 41 | public void head( final Node node, final int depth ) { | |
| 42 | public void head( final @NotNull Node node, final int depth ) { | |
| 42 | 43 | if( node instanceof final TextNode textNode ) { |
| 43 | 44 | final var parent = node.parentNode(); |
| ... | ||
| 57 | 58 | |
| 58 | 59 | @Override |
| 59 | public void tail( final Node node, final int depth ) { | |
| 60 | public void tail( final @NotNull Node node, final int depth ) { | |
| 60 | 61 | } |
| 61 | 62 | }; |
| 62 | 63 | |
| 63 | 64 | @Override |
| 64 | public Document fromJsoup( final org.jsoup.nodes.Document in ) { | |
| 65 | public @NotNull Document fromJsoup( final org.jsoup.nodes.Document in ) { | |
| 65 | 66 | assert in != null; |
| 66 | 67 | |
| 5 | 5 | import com.keenwrite.editors.definition.DefinitionTreeItem; |
| 6 | 6 | import com.keenwrite.editors.markdown.MarkdownEditor; |
| 7 | import com.keenwrite.sigils.Tokens; | |
| 8 | 7 | import javafx.scene.control.TreeItem; |
| 9 | 8 | |
| 10 | 9 | import java.util.Map; |
| 11 | 10 | |
| 12 | 11 | /** |
| 13 | 12 | * Differentiates an instance of {@link TextResource} from an instance of |
| 14 | 13 | * {@link DefinitionEditor} or {@link MarkdownEditor}. |
| 15 | 14 | */ |
| 16 | 15 | public interface TextDefinition extends TextResource { |
| 17 | /** | |
| 18 | * Converts the definitions into a map, ready for interpolation. | |
| 19 | * | |
| 20 | * @return The list of key value pairs delimited with tokens. | |
| 21 | */ | |
| 22 | Map<String, String> toMap(); | |
| 23 | 16 | |
| 24 | 17 | /** |
| 25 | * Performs string interpolation on the values in the given map. This will | |
| 26 | * change any value in the map that contains a variable that matches | |
| 27 | * the definition regex pattern against the given {@link Tokens}. | |
| 18 | * Requests the interpolated version of the variable definitions. | |
| 28 | 19 | * |
| 29 | * @param map Contains values that represent references to keys. | |
| 30 | * @param tokens The beginning and ending tokens that delimit variables. | |
| 20 | * @return The definition map with all variables interpolated. | |
| 31 | 21 | */ |
| 32 | Map<String, String> interpolate( Map<String, String> map, Tokens tokens ); | |
| 22 | Map<String, String> getDefinitions(); | |
| 33 | 23 | |
| 34 | 24 | /** |
| 4 | 4 | import com.keenwrite.constants.Constants; |
| 5 | 5 | import com.keenwrite.editors.TextDefinition; |
| 6 | import com.keenwrite.sigils.Tokens; | |
| 7 | import com.keenwrite.ui.tree.AltTreeView; | |
| 8 | import com.keenwrite.ui.tree.TreeItemConverter; | |
| 9 | import javafx.beans.property.BooleanProperty; | |
| 10 | import javafx.beans.property.ReadOnlyBooleanProperty; | |
| 11 | import javafx.beans.property.SimpleBooleanProperty; | |
| 12 | import javafx.beans.value.ObservableValue; | |
| 13 | import javafx.collections.ObservableList; | |
| 14 | import javafx.event.ActionEvent; | |
| 15 | import javafx.event.Event; | |
| 16 | import javafx.event.EventHandler; | |
| 17 | import javafx.scene.Node; | |
| 18 | import javafx.scene.control.*; | |
| 19 | import javafx.scene.input.KeyEvent; | |
| 20 | import javafx.scene.layout.BorderPane; | |
| 21 | import javafx.scene.layout.HBox; | |
| 22 | ||
| 23 | import java.io.File; | |
| 24 | import java.nio.charset.Charset; | |
| 25 | import java.util.*; | |
| 26 | import java.util.regex.Pattern; | |
| 27 | ||
| 28 | import static com.keenwrite.constants.Constants.*; | |
| 29 | import static com.keenwrite.Messages.get; | |
| 30 | import static com.keenwrite.events.StatusEvent.clue; | |
| 31 | import static com.keenwrite.events.TextDefinitionFocusEvent.fireTextDefinitionFocus; | |
| 32 | import static com.keenwrite.ui.fonts.IconFactory.createGraphic; | |
| 33 | import static java.lang.String.format; | |
| 34 | import static java.util.regex.Pattern.compile; | |
| 35 | import static java.util.regex.Pattern.quote; | |
| 36 | import static javafx.geometry.Pos.CENTER; | |
| 37 | import static javafx.geometry.Pos.TOP_CENTER; | |
| 38 | import static javafx.scene.control.SelectionMode.MULTIPLE; | |
| 39 | import static javafx.scene.control.TreeItem.childrenModificationEvent; | |
| 40 | import static javafx.scene.control.TreeItem.valueChangedEvent; | |
| 41 | import static javafx.scene.input.KeyEvent.KEY_PRESSED; | |
| 42 | ||
| 43 | /** | |
| 44 | * Provides the user interface that holds a {@link TreeView}, which | |
| 45 | * allows users to interact with key/value pairs loaded from the | |
| 46 | * document parser and adapted using a {@link TreeTransformer}. | |
| 47 | */ | |
| 48 | public final class DefinitionEditor extends BorderPane | |
| 49 | implements TextDefinition { | |
| 50 | private static final int GROUP_DELIMITED = 1; | |
| 51 | ||
| 52 | /** | |
| 53 | * Contains the root that is added to the view. | |
| 54 | */ | |
| 55 | private final DefinitionTreeItem<String> mTreeRoot = createRootTreeItem(); | |
| 56 | ||
| 57 | /** | |
| 58 | * Contains a view of the definitions. | |
| 59 | */ | |
| 60 | private final TreeView<String> mTreeView = | |
| 61 | new AltTreeView<>( mTreeRoot, new TreeItemConverter() ); | |
| 62 | ||
| 63 | /** | |
| 64 | * Used to adapt the structured document into a {@link TreeView}. | |
| 65 | */ | |
| 66 | private final TreeTransformer mTreeTransformer; | |
| 67 | ||
| 68 | /** | |
| 69 | * Handlers for key press events. | |
| 70 | */ | |
| 71 | private final Set<EventHandler<? super KeyEvent>> mKeyEventHandlers | |
| 72 | = new HashSet<>(); | |
| 73 | ||
| 74 | /** | |
| 75 | * File being edited by this editor instance. | |
| 76 | */ | |
| 77 | private File mFile; | |
| 78 | ||
| 79 | /** | |
| 80 | * Opened file's character encoding, or {@link Constants#DEFAULT_CHARSET} if | |
| 81 | * either no encoding could be determined or this is a new (empty) file. | |
| 82 | */ | |
| 83 | private final Charset mEncoding; | |
| 84 | ||
| 85 | /** | |
| 86 | * Tracks whether the in-memory definitions have changed with respect to the | |
| 87 | * persisted definitions. | |
| 88 | */ | |
| 89 | private final BooleanProperty mModified = new SimpleBooleanProperty(); | |
| 90 | ||
| 91 | /** | |
| 92 | * This is provided for unit tests that are not backed by files. | |
| 93 | * | |
| 94 | * @param treeTransformer Responsible for transforming the definitions into | |
| 95 | * {@link TreeItem} instances. | |
| 96 | */ | |
| 97 | public DefinitionEditor( | |
| 98 | final TreeTransformer treeTransformer ) { | |
| 99 | this( DEFINITION_DEFAULT, treeTransformer ); | |
| 100 | } | |
| 101 | ||
| 102 | /** | |
| 103 | * Constructs a definition pane with a given tree view root. | |
| 104 | * | |
| 105 | * @param file The file of definitions to maintain through the UI. | |
| 106 | */ | |
| 107 | public DefinitionEditor( | |
| 108 | final File file, | |
| 109 | final TreeTransformer treeTransformer ) { | |
| 110 | assert file != null; | |
| 111 | assert treeTransformer != null; | |
| 112 | ||
| 113 | mFile = file; | |
| 114 | mTreeTransformer = treeTransformer; | |
| 115 | ||
| 116 | //mTreeView.setCellFactory( new TreeCellFactory() ); | |
| 117 | mTreeView.setContextMenu( createContextMenu() ); | |
| 118 | mTreeView.addEventFilter( KEY_PRESSED, this::keyEventFilter ); | |
| 119 | mTreeView.focusedProperty().addListener( this::focused ); | |
| 120 | getSelectionModel().setSelectionMode( MULTIPLE ); | |
| 121 | ||
| 122 | final var buttonBar = new HBox(); | |
| 123 | buttonBar.getChildren().addAll( | |
| 124 | createButton( "create", e -> createDefinition() ), | |
| 125 | createButton( "rename", e -> renameDefinition() ), | |
| 126 | createButton( "delete", e -> deleteDefinitions() ) | |
| 127 | ); | |
| 128 | buttonBar.setAlignment( CENTER ); | |
| 129 | buttonBar.setSpacing( UI_CONTROL_SPACING ); | |
| 130 | ||
| 131 | setTop( buttonBar ); | |
| 132 | setCenter( mTreeView ); | |
| 133 | setAlignment( buttonBar, TOP_CENTER ); | |
| 134 | mEncoding = open( mFile ); | |
| 135 | ||
| 136 | // After the file is opened, watch for changes, not before. Otherwise, | |
| 137 | // upon saving, users will be prompted to save a file that hasn't had | |
| 138 | // any modifications (from their perspective). | |
| 139 | addTreeChangeHandler( event -> mModified.set( true ) ); | |
| 140 | } | |
| 141 | ||
| 142 | @Override | |
| 143 | public void setText( final String document ) { | |
| 144 | final var foster = mTreeTransformer.transform( document ); | |
| 145 | final var biological = getTreeRoot(); | |
| 146 | ||
| 147 | for( final var child : foster.getChildren() ) { | |
| 148 | biological.getChildren().add( child ); | |
| 149 | } | |
| 150 | ||
| 151 | getTreeView().refresh(); | |
| 152 | } | |
| 153 | ||
| 154 | @Override | |
| 155 | public String getText() { | |
| 156 | final var result = new StringBuilder( 32768 ); | |
| 157 | ||
| 158 | try { | |
| 159 | final var root = getTreeView().getRoot(); | |
| 160 | final var problem = isTreeWellFormed(); | |
| 161 | ||
| 162 | problem.ifPresentOrElse( | |
| 163 | ( node ) -> clue( "yaml.error.tree.form", node ), | |
| 164 | () -> result.append( mTreeTransformer.transform( root ) ) | |
| 165 | ); | |
| 166 | } catch( final Exception ex ) { | |
| 167 | // Catch errors while checking for a well-formed tree (e.g., stack smash). | |
| 168 | // Also catch any transformation exceptions (e.g., Json processing). | |
| 169 | clue( ex ); | |
| 170 | } | |
| 171 | ||
| 172 | return result.toString(); | |
| 173 | } | |
| 174 | ||
| 175 | @Override | |
| 176 | public File getFile() { | |
| 177 | return mFile; | |
| 178 | } | |
| 179 | ||
| 180 | @Override | |
| 181 | public void rename( final File file ) { | |
| 182 | mFile = file; | |
| 183 | } | |
| 184 | ||
| 185 | @Override | |
| 186 | public Charset getEncoding() { | |
| 187 | return mEncoding; | |
| 188 | } | |
| 189 | ||
| 190 | @Override | |
| 191 | public Node getNode() { | |
| 192 | return this; | |
| 193 | } | |
| 194 | ||
| 195 | @Override | |
| 196 | public ReadOnlyBooleanProperty modifiedProperty() { | |
| 197 | return mModified; | |
| 198 | } | |
| 199 | ||
| 200 | @Override | |
| 201 | public void clearModifiedProperty() { | |
| 202 | mModified.setValue( false ); | |
| 203 | } | |
| 204 | ||
| 205 | private Button createButton( | |
| 206 | final String msgKey, final EventHandler<ActionEvent> eventHandler ) { | |
| 207 | final var keyPrefix = Constants.ACTION_PREFIX + "definition." + msgKey; | |
| 208 | final var button = new Button( get( keyPrefix + ".text" ) ); | |
| 209 | final var graphic = createGraphic( get( keyPrefix + ".icon" ) ); | |
| 210 | ||
| 211 | button.setOnAction( eventHandler ); | |
| 212 | button.setGraphic( graphic ); | |
| 213 | button.setTooltip( new Tooltip( get( keyPrefix + ".tooltip" ) ) ); | |
| 214 | ||
| 215 | return button; | |
| 216 | } | |
| 217 | ||
| 218 | @Override | |
| 219 | public Map<String, String> toMap() { | |
| 220 | return new TreeItemMapper().toMap( getTreeView().getRoot() ); | |
| 221 | } | |
| 222 | ||
| 223 | @Override | |
| 224 | public Map<String, String> interpolate( | |
| 225 | final Map<String, String> map, final Tokens tokens ) { | |
| 226 | ||
| 227 | // Non-greedy match of key names delimited by definition tokens. | |
| 228 | final var pattern = compile( | |
| 229 | format( "(%s.*?%s)", | |
| 230 | quote( tokens.getBegan() ), | |
| 231 | quote( tokens.getEnded() ) | |
| 232 | ) | |
| 233 | ); | |
| 234 | ||
| 235 | map.replaceAll( ( k, v ) -> resolve( map, v, pattern ) ); | |
| 236 | return map; | |
| 237 | } | |
| 238 | ||
| 239 | /** | |
| 240 | * Given a value with zero or more key references, this will resolve all | |
| 241 | * the values, recursively. If a key cannot be de-referenced, the value will | |
| 242 | * contain the key name. | |
| 243 | * | |
| 244 | * @param map Map to search for keys when resolving key references. | |
| 245 | * @param value Value containing zero or more key references. | |
| 246 | * @param pattern The regular expression pattern to match variable key names. | |
| 247 | * @return The given value with all embedded key references interpolated. | |
| 248 | */ | |
| 249 | private String resolve( | |
| 250 | final Map<String, String> map, String value, final Pattern pattern ) { | |
| 251 | final var matcher = pattern.matcher( value ); | |
| 252 | ||
| 253 | while( matcher.find() ) { | |
| 254 | final var keyName = matcher.group( GROUP_DELIMITED ); | |
| 255 | final var mapValue = map.get( keyName ); | |
| 256 | final var keyValue = mapValue == null | |
| 257 | ? keyName | |
| 258 | : resolve( map, mapValue, pattern ); | |
| 259 | ||
| 260 | value = value.replace( keyName, keyValue ); | |
| 261 | } | |
| 262 | ||
| 263 | return value; | |
| 264 | } | |
| 265 | ||
| 266 | ||
| 267 | /** | |
| 268 | * Informs the caller of whenever any {@link TreeItem} in the {@link TreeView} | |
| 269 | * is modified. The modifications include: item value changes, item additions, | |
| 270 | * and item removals. | |
| 271 | * <p> | |
| 272 | * Safe to call multiple times; if a handler is already registered, the | |
| 273 | * old handler is used. | |
| 274 | * </p> | |
| 275 | * | |
| 276 | * @param handler The handler to call whenever any {@link TreeItem} changes. | |
| 277 | */ | |
| 278 | public void addTreeChangeHandler( | |
| 279 | final EventHandler<TreeItem.TreeModificationEvent<Event>> handler ) { | |
| 280 | final var root = getTreeView().getRoot(); | |
| 281 | root.addEventHandler( valueChangedEvent(), handler ); | |
| 282 | root.addEventHandler( childrenModificationEvent(), handler ); | |
| 283 | } | |
| 284 | ||
| 285 | /** | |
| 286 | * Answers whether the {@link TreeItem}s in the {@link TreeView} are suitably | |
| 287 | * well-formed for export. A tree is considered well-formed if the following | |
| 288 | * conditions are met: | |
| 289 | * | |
| 290 | * <ul> | |
| 291 | * <li>The root node contains at least one child node having a leaf.</li> | |
| 292 | * <li>There are no leaf nodes with sibling leaf nodes.</li> | |
| 293 | * </ul> | |
| 294 | * | |
| 295 | * @return {@code null} if the document is well-formed, otherwise the | |
| 296 | * problematic child {@link TreeItem}. | |
| 297 | */ | |
| 298 | public Optional<TreeItem<String>> isTreeWellFormed() { | |
| 299 | final var root = getTreeView().getRoot(); | |
| 300 | ||
| 301 | for( final var child : root.getChildren() ) { | |
| 302 | final var problemChild = isWellFormed( child ); | |
| 303 | ||
| 304 | if( child.isLeaf() || problemChild != null ) { | |
| 305 | return Optional.ofNullable( problemChild ); | |
| 306 | } | |
| 307 | } | |
| 308 | ||
| 309 | return Optional.empty(); | |
| 310 | } | |
| 311 | ||
| 312 | /** | |
| 313 | * Determines whether the document is well-formed by ensuring that | |
| 314 | * child branches do not contain multiple leaves. | |
| 315 | * | |
| 316 | * @param item The sub-tree to check for well-formedness. | |
| 317 | * @return {@code null} when the tree is well-formed, otherwise the | |
| 318 | * problematic {@link TreeItem}. | |
| 319 | */ | |
| 320 | private TreeItem<String> isWellFormed( final TreeItem<String> item ) { | |
| 321 | int childLeafs = 0; | |
| 322 | int childBranches = 0; | |
| 323 | ||
| 324 | for( final var child : item.getChildren() ) { | |
| 325 | if( child.isLeaf() ) { | |
| 326 | childLeafs++; | |
| 327 | } | |
| 328 | else { | |
| 329 | childBranches++; | |
| 330 | } | |
| 331 | ||
| 332 | final var problemChild = isWellFormed( child ); | |
| 333 | ||
| 334 | if( problemChild != null ) { | |
| 335 | return problemChild; | |
| 336 | } | |
| 337 | } | |
| 338 | ||
| 339 | return ((childBranches > 0 && childLeafs == 0) || | |
| 340 | (childBranches == 0 && childLeafs <= 1)) ? null : item; | |
| 341 | } | |
| 342 | ||
| 343 | @Override | |
| 344 | public DefinitionTreeItem<String> findLeafExact( final String text ) { | |
| 345 | return getTreeRoot().findLeafExact( text ); | |
| 346 | } | |
| 347 | ||
| 348 | @Override | |
| 349 | public DefinitionTreeItem<String> findLeafContains( final String text ) { | |
| 350 | return getTreeRoot().findLeafContains( text ); | |
| 351 | } | |
| 352 | ||
| 353 | @Override | |
| 354 | public DefinitionTreeItem<String> findLeafContainsNoCase( | |
| 355 | final String text ) { | |
| 356 | return getTreeRoot().findLeafContainsNoCase( text ); | |
| 357 | } | |
| 358 | ||
| 359 | @Override | |
| 360 | public DefinitionTreeItem<String> findLeafStartsWith( final String text ) { | |
| 361 | return getTreeRoot().findLeafStartsWith( text ); | |
| 362 | } | |
| 363 | ||
| 364 | public void select( final TreeItem<String> item ) { | |
| 365 | getSelectionModel().clearSelection(); | |
| 366 | getSelectionModel().select( getTreeView().getRow( item ) ); | |
| 367 | } | |
| 368 | ||
| 369 | /** | |
| 370 | * Collapses the tree, recursively. | |
| 371 | */ | |
| 372 | public void collapse() { | |
| 373 | collapse( getTreeRoot().getChildren() ); | |
| 374 | } | |
| 375 | ||
| 376 | /** | |
| 377 | * Collapses the tree, recursively. | |
| 378 | * | |
| 379 | * @param <T> The type of tree item to expand (usually String). | |
| 380 | * @param nodes The nodes to collapse. | |
| 381 | */ | |
| 382 | private <T> void collapse( final ObservableList<TreeItem<T>> nodes ) { | |
| 383 | for( final var node : nodes ) { | |
| 384 | node.setExpanded( false ); | |
| 385 | collapse( node.getChildren() ); | |
| 386 | } | |
| 387 | } | |
| 388 | ||
| 389 | /** | |
| 390 | * @return {@code true} when the user is editing a {@link TreeItem}. | |
| 391 | */ | |
| 392 | private boolean isEditingTreeItem() { | |
| 393 | return getTreeView().editingItemProperty().getValue() != null; | |
| 394 | } | |
| 395 | ||
| 396 | /** | |
| 397 | * Changes to edit mode for the selected item. | |
| 398 | */ | |
| 399 | @Override | |
| 400 | public void renameDefinition() { | |
| 401 | getTreeView().edit( getSelectedItem() ); | |
| 402 | } | |
| 403 | ||
| 404 | /** | |
| 405 | * Removes all selected items from the {@link TreeView}. | |
| 406 | */ | |
| 407 | @Override | |
| 408 | public void deleteDefinitions() { | |
| 409 | for( final var item : getSelectedItems() ) { | |
| 410 | final var parent = item.getParent(); | |
| 411 | ||
| 412 | if( parent != null ) { | |
| 413 | parent.getChildren().remove( item ); | |
| 414 | } | |
| 415 | } | |
| 416 | } | |
| 417 | ||
| 418 | /** | |
| 419 | * Deletes the selected item. | |
| 420 | */ | |
| 421 | private void deleteSelectedItem() { | |
| 422 | final var c = getSelectedItem(); | |
| 423 | getSiblings( c ).remove( c ); | |
| 424 | } | |
| 425 | ||
| 426 | /** | |
| 427 | * Adds a new item under the selected item (or root if nothing is selected). | |
| 428 | * There are a few conditions to consider: when adding to the root, | |
| 429 | * when adding to a leaf, and when adding to a non-leaf. Items added to the | |
| 430 | * root must contain two items: a key and a value. | |
| 431 | */ | |
| 432 | @Override | |
| 433 | public void createDefinition() { | |
| 434 | final var value = createDefinitionTreeItem(); | |
| 435 | getSelectedItem().getChildren().add( value ); | |
| 436 | expand( value ); | |
| 437 | select( value ); | |
| 438 | } | |
| 439 | ||
| 440 | private ContextMenu createContextMenu() { | |
| 441 | final var menu = new ContextMenu(); | |
| 442 | final var items = menu.getItems(); | |
| 443 | ||
| 444 | addMenuItem( items, ACTION_PREFIX + "definition.create.text" ) | |
| 445 | .setOnAction( e -> createDefinition() ); | |
| 446 | addMenuItem( items, ACTION_PREFIX + "definition.rename.text" ) | |
| 447 | .setOnAction( e -> renameDefinition() ); | |
| 448 | addMenuItem( items, ACTION_PREFIX + "definition.delete.text" ) | |
| 449 | .setOnAction( e -> deleteSelectedItem() ); | |
| 450 | ||
| 451 | return menu; | |
| 452 | } | |
| 453 | ||
| 454 | /** | |
| 455 | * Executes hot-keys for edits to the definition tree. | |
| 456 | * | |
| 457 | * @param event Contains the key code of the key that was pressed. | |
| 458 | */ | |
| 459 | private void keyEventFilter( final KeyEvent event ) { | |
| 460 | if( !isEditingTreeItem() ) { | |
| 461 | switch( event.getCode() ) { | |
| 462 | case ENTER -> { | |
| 463 | expand( getSelectedItem() ); | |
| 464 | event.consume(); | |
| 465 | } | |
| 466 | ||
| 467 | case DELETE -> deleteDefinitions(); | |
| 468 | case INSERT -> createDefinition(); | |
| 469 | ||
| 470 | case R -> { | |
| 471 | if( event.isControlDown() ) { | |
| 472 | renameDefinition(); | |
| 473 | } | |
| 474 | } | |
| 475 | } | |
| 476 | ||
| 477 | for( final var handler : getKeyEventHandlers() ) { | |
| 478 | handler.handle( event ); | |
| 479 | } | |
| 480 | } | |
| 481 | } | |
| 482 | ||
| 483 | /** | |
| 484 | * Called when the editor's input focus changes. This will fire an event | |
| 485 | * for subscribers. | |
| 486 | * | |
| 487 | * @param ignored Not used. | |
| 488 | * @param o The old input focus property value. | |
| 489 | * @param n The new input focus property value. | |
| 490 | */ | |
| 491 | private void focused( | |
| 492 | final ObservableValue<? extends Boolean> ignored, | |
| 493 | final Boolean o, | |
| 494 | final Boolean n ) { | |
| 495 | if( n != null && n ) { | |
| 496 | fireTextDefinitionFocus( this ); | |
| 497 | } | |
| 498 | } | |
| 499 | ||
| 500 | /** | |
| 501 | * Adds a menu item to a list of menu items. | |
| 502 | * | |
| 503 | * @param items The list of menu items to append to. | |
| 504 | * @param labelKey The resource bundle key name for the menu item's label. | |
| 505 | * @return The menu item added to the list of menu items. | |
| 506 | */ | |
| 507 | private MenuItem addMenuItem( | |
| 508 | final List<MenuItem> items, final String labelKey ) { | |
| 509 | final MenuItem menuItem = createMenuItem( labelKey ); | |
| 510 | items.add( menuItem ); | |
| 511 | return menuItem; | |
| 512 | } | |
| 513 | ||
| 514 | private MenuItem createMenuItem( final String labelKey ) { | |
| 515 | return new MenuItem( get( labelKey ) ); | |
| 516 | } | |
| 517 | ||
| 518 | /** | |
| 519 | * Creates a new {@link TreeItem} that is intended to be the root-level item | |
| 520 | * added to the {@link TreeView}. This allows the root item to be | |
| 521 | * distinguished from the other items so that reference keys do not include | |
| 522 | * "Definition" as part of their name. | |
| 523 | * | |
| 524 | * @return A new {@link TreeItem}, never {@code null}. | |
| 525 | */ | |
| 526 | private RootTreeItem<String> createRootTreeItem() { | |
| 527 | return new RootTreeItem<>( get( "Pane.definition.node.root.title" ) ); | |
| 528 | } | |
| 529 | ||
| 530 | private DefinitionTreeItem<String> createDefinitionTreeItem() { | |
| 531 | return new DefinitionTreeItem<>( get( "Definition.menu.add.default" ) ); | |
| 532 | } | |
| 533 | ||
| 534 | @Override | |
| 535 | public void requestFocus() { | |
| 536 | //super.requestFocus(); | |
| 6 | import com.keenwrite.events.TextDefinitionFocusEvent; | |
| 7 | import com.keenwrite.sigils.SigilOperator; | |
| 8 | import com.keenwrite.ui.tree.AltTreeView; | |
| 9 | import com.keenwrite.ui.tree.TreeItemConverter; | |
| 10 | import javafx.beans.property.BooleanProperty; | |
| 11 | import javafx.beans.property.ReadOnlyBooleanProperty; | |
| 12 | import javafx.beans.property.SimpleBooleanProperty; | |
| 13 | import javafx.beans.value.ObservableValue; | |
| 14 | import javafx.collections.ObservableList; | |
| 15 | import javafx.event.ActionEvent; | |
| 16 | import javafx.event.Event; | |
| 17 | import javafx.event.EventHandler; | |
| 18 | import javafx.scene.Node; | |
| 19 | import javafx.scene.control.*; | |
| 20 | import javafx.scene.input.KeyEvent; | |
| 21 | import javafx.scene.layout.BorderPane; | |
| 22 | import javafx.scene.layout.HBox; | |
| 23 | ||
| 24 | import java.io.File; | |
| 25 | import java.nio.charset.Charset; | |
| 26 | import java.util.*; | |
| 27 | ||
| 28 | import static com.keenwrite.Messages.get; | |
| 29 | import static com.keenwrite.constants.Constants.*; | |
| 30 | import static com.keenwrite.events.StatusEvent.clue; | |
| 31 | import static com.keenwrite.ui.fonts.IconFactory.createGraphic; | |
| 32 | import static javafx.geometry.Pos.CENTER; | |
| 33 | import static javafx.geometry.Pos.TOP_CENTER; | |
| 34 | import static javafx.scene.control.SelectionMode.MULTIPLE; | |
| 35 | import static javafx.scene.control.TreeItem.childrenModificationEvent; | |
| 36 | import static javafx.scene.control.TreeItem.valueChangedEvent; | |
| 37 | import static javafx.scene.input.KeyEvent.KEY_PRESSED; | |
| 38 | ||
| 39 | /** | |
| 40 | * Provides the user interface that holds a {@link TreeView}, which | |
| 41 | * allows users to interact with key/value pairs loaded from the | |
| 42 | * document parser and adapted using a {@link TreeTransformer}. | |
| 43 | */ | |
| 44 | public final class DefinitionEditor extends BorderPane | |
| 45 | implements TextDefinition { | |
| 46 | ||
| 47 | /** | |
| 48 | * Contains the root that is added to the view. | |
| 49 | */ | |
| 50 | private final DefinitionTreeItem<String> mTreeRoot = createRootTreeItem(); | |
| 51 | ||
| 52 | /** | |
| 53 | * Contains a view of the definitions. | |
| 54 | */ | |
| 55 | private final TreeView<String> mTreeView = | |
| 56 | new AltTreeView<>( mTreeRoot, new TreeItemConverter() ); | |
| 57 | ||
| 58 | /** | |
| 59 | * Used to adapt the structured document into a {@link TreeView}. | |
| 60 | */ | |
| 61 | private final TreeTransformer mTreeTransformer; | |
| 62 | ||
| 63 | /** | |
| 64 | * Handlers for key press events. | |
| 65 | */ | |
| 66 | private final Set<EventHandler<? super KeyEvent>> mKeyEventHandlers | |
| 67 | = new HashSet<>(); | |
| 68 | ||
| 69 | /** | |
| 70 | * File being edited by this editor instance. | |
| 71 | */ | |
| 72 | private File mFile; | |
| 73 | ||
| 74 | private final Map<String, String> mDefinitions = new HashMap<>(); | |
| 75 | ||
| 76 | /** | |
| 77 | * Opened file's character encoding, or {@link Constants#DEFAULT_CHARSET} if | |
| 78 | * either no encoding could be determined or this is a new (empty) file. | |
| 79 | */ | |
| 80 | private final Charset mEncoding; | |
| 81 | ||
| 82 | /** | |
| 83 | * Tracks whether the in-memory definitions have changed with respect to the | |
| 84 | * persisted definitions. | |
| 85 | */ | |
| 86 | private final BooleanProperty mModified = new SimpleBooleanProperty(); | |
| 87 | ||
| 88 | /** | |
| 89 | * This is provided for unit tests that are not backed by files. | |
| 90 | * | |
| 91 | * @param treeTransformer Responsible for transforming the definitions into | |
| 92 | * {@link TreeItem} instances. | |
| 93 | * @param operator Defines how detect variables within values so | |
| 94 | * that they are interpolated when returning the | |
| 95 | * definitions. | |
| 96 | */ | |
| 97 | public DefinitionEditor( | |
| 98 | final TreeTransformer treeTransformer, | |
| 99 | final SigilOperator operator ) { | |
| 100 | this( DEFINITION_DEFAULT, treeTransformer, operator ); | |
| 101 | } | |
| 102 | ||
| 103 | /** | |
| 104 | * Constructs a definition pane with a given tree view root. | |
| 105 | * | |
| 106 | * @param file The file of definitions to maintain through the UI. | |
| 107 | */ | |
| 108 | public DefinitionEditor( | |
| 109 | final File file, | |
| 110 | final TreeTransformer treeTransformer, | |
| 111 | final SigilOperator operator ) { | |
| 112 | assert file != null; | |
| 113 | assert treeTransformer != null; | |
| 114 | ||
| 115 | mFile = file; | |
| 116 | mTreeTransformer = treeTransformer; | |
| 117 | ||
| 118 | mTreeView.setContextMenu( createContextMenu() ); | |
| 119 | mTreeView.addEventFilter( KEY_PRESSED, this::keyEventFilter ); | |
| 120 | mTreeView.focusedProperty().addListener( this::focused ); | |
| 121 | getSelectionModel().setSelectionMode( MULTIPLE ); | |
| 122 | ||
| 123 | final var buttonBar = new HBox(); | |
| 124 | buttonBar.getChildren().addAll( | |
| 125 | createButton( "create", e -> createDefinition() ), | |
| 126 | createButton( "rename", e -> renameDefinition() ), | |
| 127 | createButton( "delete", e -> deleteDefinitions() ) | |
| 128 | ); | |
| 129 | buttonBar.setAlignment( CENTER ); | |
| 130 | buttonBar.setSpacing( UI_CONTROL_SPACING ); | |
| 131 | ||
| 132 | setTop( buttonBar ); | |
| 133 | setCenter( mTreeView ); | |
| 134 | setAlignment( buttonBar, TOP_CENTER ); | |
| 135 | mEncoding = open( mFile ); | |
| 136 | ||
| 137 | // After the file is opened, watch for changes, not before. Otherwise, | |
| 138 | // upon saving, users will be prompted to save a file that hasn't had | |
| 139 | // any modifications (from their perspective). | |
| 140 | addTreeChangeHandler( event -> { | |
| 141 | interpolate( operator ); | |
| 142 | mModified.set( true ); | |
| 143 | } ); | |
| 144 | ||
| 145 | interpolate( operator ); | |
| 146 | } | |
| 147 | ||
| 148 | /** | |
| 149 | * Returns the variable definitions. This is called in critical parts of the | |
| 150 | * application, necessitating a cache. The cache is updated by calling | |
| 151 | * {@link #interpolate(SigilOperator)}, which happens upon tree modifications | |
| 152 | * via the editor or immediately after the definition file is loaded. | |
| 153 | * | |
| 154 | * @return The definition map with all variable references fully interpolated | |
| 155 | * and replaced. | |
| 156 | */ | |
| 157 | @Override | |
| 158 | public Map<String, String> getDefinitions() { | |
| 159 | return mDefinitions; | |
| 160 | } | |
| 161 | ||
| 162 | @Override | |
| 163 | public void setText( final String document ) { | |
| 164 | final var foster = mTreeTransformer.transform( document ); | |
| 165 | final var biological = getTreeRoot(); | |
| 166 | ||
| 167 | for( final var child : foster.getChildren() ) { | |
| 168 | biological.getChildren().add( child ); | |
| 169 | } | |
| 170 | ||
| 171 | getTreeView().refresh(); | |
| 172 | } | |
| 173 | ||
| 174 | @Override | |
| 175 | public String getText() { | |
| 176 | final var result = new StringBuilder( 32768 ); | |
| 177 | ||
| 178 | try { | |
| 179 | final var root = getTreeView().getRoot(); | |
| 180 | final var problem = isTreeWellFormed(); | |
| 181 | ||
| 182 | problem.ifPresentOrElse( | |
| 183 | ( node ) -> clue( "yaml.error.tree.form", node ), | |
| 184 | () -> result.append( mTreeTransformer.transform( root ) ) | |
| 185 | ); | |
| 186 | } catch( final Exception ex ) { | |
| 187 | // Catch errors while checking for a well-formed tree (e.g., stack smash). | |
| 188 | // Also catch any transformation exceptions (e.g., Json processing). | |
| 189 | clue( ex ); | |
| 190 | } | |
| 191 | ||
| 192 | return result.toString(); | |
| 193 | } | |
| 194 | ||
| 195 | @Override | |
| 196 | public File getFile() { | |
| 197 | return mFile; | |
| 198 | } | |
| 199 | ||
| 200 | @Override | |
| 201 | public void rename( final File file ) { | |
| 202 | mFile = file; | |
| 203 | } | |
| 204 | ||
| 205 | @Override | |
| 206 | public Charset getEncoding() { | |
| 207 | return mEncoding; | |
| 208 | } | |
| 209 | ||
| 210 | @Override | |
| 211 | public Node getNode() { | |
| 212 | return this; | |
| 213 | } | |
| 214 | ||
| 215 | @Override | |
| 216 | public ReadOnlyBooleanProperty modifiedProperty() { | |
| 217 | return mModified; | |
| 218 | } | |
| 219 | ||
| 220 | @Override | |
| 221 | public void clearModifiedProperty() { | |
| 222 | mModified.setValue( false ); | |
| 223 | } | |
| 224 | ||
| 225 | private void interpolate( final SigilOperator operator ) { | |
| 226 | final var map = TreeItemMapper.convert( getTreeView().getRoot() ); | |
| 227 | ||
| 228 | mDefinitions.clear(); | |
| 229 | mDefinitions.putAll( map.interpolate( operator ) ); | |
| 230 | } | |
| 231 | ||
| 232 | private Button createButton( | |
| 233 | final String msgKey, final EventHandler<ActionEvent> eventHandler ) { | |
| 234 | final var keyPrefix = Constants.ACTION_PREFIX + "definition." + msgKey; | |
| 235 | final var button = new Button( get( keyPrefix + ".text" ) ); | |
| 236 | final var graphic = createGraphic( get( keyPrefix + ".icon" ) ); | |
| 237 | ||
| 238 | button.setOnAction( eventHandler ); | |
| 239 | button.setGraphic( graphic ); | |
| 240 | button.setTooltip( new Tooltip( get( keyPrefix + ".tooltip" ) ) ); | |
| 241 | ||
| 242 | return button; | |
| 243 | } | |
| 244 | ||
| 245 | /** | |
| 246 | * Informs the caller of whenever any {@link TreeItem} in the {@link TreeView} | |
| 247 | * is modified. The modifications include: item value changes, item additions, | |
| 248 | * and item removals. | |
| 249 | * <p> | |
| 250 | * Safe to call multiple times; if a handler is already registered, the | |
| 251 | * old handler is used. | |
| 252 | * </p> | |
| 253 | * | |
| 254 | * @param handler The handler to call whenever any {@link TreeItem} changes. | |
| 255 | */ | |
| 256 | public void addTreeChangeHandler( | |
| 257 | final EventHandler<TreeItem.TreeModificationEvent<Event>> handler ) { | |
| 258 | final var root = getTreeView().getRoot(); | |
| 259 | root.addEventHandler( valueChangedEvent(), handler ); | |
| 260 | root.addEventHandler( childrenModificationEvent(), handler ); | |
| 261 | } | |
| 262 | ||
| 263 | /** | |
| 264 | * Answers whether the {@link TreeItem}s in the {@link TreeView} are suitably | |
| 265 | * well-formed for export. A tree is considered well-formed if the following | |
| 266 | * conditions are met: | |
| 267 | * | |
| 268 | * <ul> | |
| 269 | * <li>The root node contains at least one child node having a leaf.</li> | |
| 270 | * <li>There are no leaf nodes with sibling leaf nodes.</li> | |
| 271 | * </ul> | |
| 272 | * | |
| 273 | * @return {@code null} if the document is well-formed, otherwise the | |
| 274 | * problematic child {@link TreeItem}. | |
| 275 | */ | |
| 276 | public Optional<TreeItem<String>> isTreeWellFormed() { | |
| 277 | final var root = getTreeView().getRoot(); | |
| 278 | ||
| 279 | for( final var child : root.getChildren() ) { | |
| 280 | final var problemChild = isWellFormed( child ); | |
| 281 | ||
| 282 | if( child.isLeaf() || problemChild != null ) { | |
| 283 | return Optional.ofNullable( problemChild ); | |
| 284 | } | |
| 285 | } | |
| 286 | ||
| 287 | return Optional.empty(); | |
| 288 | } | |
| 289 | ||
| 290 | /** | |
| 291 | * Determines whether the document is well-formed by ensuring that | |
| 292 | * child branches do not contain multiple leaves. | |
| 293 | * | |
| 294 | * @param item The sub-tree to check for well-formedness. | |
| 295 | * @return {@code null} when the tree is well-formed, otherwise the | |
| 296 | * problematic {@link TreeItem}. | |
| 297 | */ | |
| 298 | private TreeItem<String> isWellFormed( final TreeItem<String> item ) { | |
| 299 | int childLeafs = 0; | |
| 300 | int childBranches = 0; | |
| 301 | ||
| 302 | for( final var child : item.getChildren() ) { | |
| 303 | if( child.isLeaf() ) { | |
| 304 | childLeafs++; | |
| 305 | } | |
| 306 | else { | |
| 307 | childBranches++; | |
| 308 | } | |
| 309 | ||
| 310 | final var problemChild = isWellFormed( child ); | |
| 311 | ||
| 312 | if( problemChild != null ) { | |
| 313 | return problemChild; | |
| 314 | } | |
| 315 | } | |
| 316 | ||
| 317 | return ((childBranches > 0 && childLeafs == 0) || | |
| 318 | (childBranches == 0 && childLeafs <= 1)) ? null : item; | |
| 319 | } | |
| 320 | ||
| 321 | @Override | |
| 322 | public DefinitionTreeItem<String> findLeafExact( final String text ) { | |
| 323 | return getTreeRoot().findLeafExact( text ); | |
| 324 | } | |
| 325 | ||
| 326 | @Override | |
| 327 | public DefinitionTreeItem<String> findLeafContains( final String text ) { | |
| 328 | return getTreeRoot().findLeafContains( text ); | |
| 329 | } | |
| 330 | ||
| 331 | @Override | |
| 332 | public DefinitionTreeItem<String> findLeafContainsNoCase( | |
| 333 | final String text ) { | |
| 334 | return getTreeRoot().findLeafContainsNoCase( text ); | |
| 335 | } | |
| 336 | ||
| 337 | @Override | |
| 338 | public DefinitionTreeItem<String> findLeafStartsWith( final String text ) { | |
| 339 | return getTreeRoot().findLeafStartsWith( text ); | |
| 340 | } | |
| 341 | ||
| 342 | public void select( final TreeItem<String> item ) { | |
| 343 | getSelectionModel().clearSelection(); | |
| 344 | getSelectionModel().select( getTreeView().getRow( item ) ); | |
| 345 | } | |
| 346 | ||
| 347 | /** | |
| 348 | * Collapses the tree, recursively. | |
| 349 | */ | |
| 350 | public void collapse() { | |
| 351 | collapse( getTreeRoot().getChildren() ); | |
| 352 | } | |
| 353 | ||
| 354 | /** | |
| 355 | * Collapses the tree, recursively. | |
| 356 | * | |
| 357 | * @param <T> The type of tree item to expand (usually String). | |
| 358 | * @param nodes The nodes to collapse. | |
| 359 | */ | |
| 360 | private <T> void collapse( final ObservableList<TreeItem<T>> nodes ) { | |
| 361 | for( final var node : nodes ) { | |
| 362 | node.setExpanded( false ); | |
| 363 | collapse( node.getChildren() ); | |
| 364 | } | |
| 365 | } | |
| 366 | ||
| 367 | /** | |
| 368 | * @return {@code true} when the user is editing a {@link TreeItem}. | |
| 369 | */ | |
| 370 | private boolean isEditingTreeItem() { | |
| 371 | return getTreeView().editingItemProperty().getValue() != null; | |
| 372 | } | |
| 373 | ||
| 374 | /** | |
| 375 | * Changes to edit mode for the selected item. | |
| 376 | */ | |
| 377 | @Override | |
| 378 | public void renameDefinition() { | |
| 379 | getTreeView().edit( getSelectedItem() ); | |
| 380 | } | |
| 381 | ||
| 382 | /** | |
| 383 | * Removes all selected items from the {@link TreeView}. | |
| 384 | */ | |
| 385 | @Override | |
| 386 | public void deleteDefinitions() { | |
| 387 | for( final var item : getSelectedItems() ) { | |
| 388 | final var parent = item.getParent(); | |
| 389 | ||
| 390 | if( parent != null ) { | |
| 391 | parent.getChildren().remove( item ); | |
| 392 | } | |
| 393 | } | |
| 394 | } | |
| 395 | ||
| 396 | /** | |
| 397 | * Deletes the selected item. | |
| 398 | */ | |
| 399 | private void deleteSelectedItem() { | |
| 400 | final var c = getSelectedItem(); | |
| 401 | getSiblings( c ).remove( c ); | |
| 402 | } | |
| 403 | ||
| 404 | /** | |
| 405 | * Adds a new item under the selected item (or root if nothing is selected). | |
| 406 | * There are a few conditions to consider: when adding to the root, | |
| 407 | * when adding to a leaf, and when adding to a non-leaf. Items added to the | |
| 408 | * root must contain two items: a key and a value. | |
| 409 | */ | |
| 410 | @Override | |
| 411 | public void createDefinition() { | |
| 412 | final var value = createDefinitionTreeItem(); | |
| 413 | getSelectedItem().getChildren().add( value ); | |
| 414 | expand( value ); | |
| 415 | select( value ); | |
| 416 | } | |
| 417 | ||
| 418 | private ContextMenu createContextMenu() { | |
| 419 | final var menu = new ContextMenu(); | |
| 420 | final var items = menu.getItems(); | |
| 421 | ||
| 422 | addMenuItem( items, ACTION_PREFIX + "definition.create.text" ) | |
| 423 | .setOnAction( e -> createDefinition() ); | |
| 424 | addMenuItem( items, ACTION_PREFIX + "definition.rename.text" ) | |
| 425 | .setOnAction( e -> renameDefinition() ); | |
| 426 | addMenuItem( items, ACTION_PREFIX + "definition.delete.text" ) | |
| 427 | .setOnAction( e -> deleteSelectedItem() ); | |
| 428 | ||
| 429 | return menu; | |
| 430 | } | |
| 431 | ||
| 432 | /** | |
| 433 | * Executes hot-keys for edits to the definition tree. | |
| 434 | * | |
| 435 | * @param event Contains the key code of the key that was pressed. | |
| 436 | */ | |
| 437 | private void keyEventFilter( final KeyEvent event ) { | |
| 438 | if( !isEditingTreeItem() ) { | |
| 439 | switch( event.getCode() ) { | |
| 440 | case ENTER -> { | |
| 441 | expand( getSelectedItem() ); | |
| 442 | event.consume(); | |
| 443 | } | |
| 444 | ||
| 445 | case DELETE -> deleteDefinitions(); | |
| 446 | case INSERT -> createDefinition(); | |
| 447 | ||
| 448 | case R -> { | |
| 449 | if( event.isControlDown() ) { | |
| 450 | renameDefinition(); | |
| 451 | } | |
| 452 | } | |
| 453 | } | |
| 454 | ||
| 455 | for( final var handler : getKeyEventHandlers() ) { | |
| 456 | handler.handle( event ); | |
| 457 | } | |
| 458 | } | |
| 459 | } | |
| 460 | ||
| 461 | /** | |
| 462 | * Called when the editor's input focus changes. This will fire an event | |
| 463 | * for subscribers. | |
| 464 | * | |
| 465 | * @param ignored Not used. | |
| 466 | * @param o The old input focus property value. | |
| 467 | * @param n The new input focus property value. | |
| 468 | */ | |
| 469 | private void focused( | |
| 470 | final ObservableValue<? extends Boolean> ignored, | |
| 471 | final Boolean o, | |
| 472 | final Boolean n ) { | |
| 473 | if( n != null && n ) { | |
| 474 | TextDefinitionFocusEvent.fire( this ); | |
| 475 | } | |
| 476 | } | |
| 477 | ||
| 478 | /** | |
| 479 | * Adds a menu item to a list of menu items. | |
| 480 | * | |
| 481 | * @param items The list of menu items to append to. | |
| 482 | * @param labelKey The resource bundle key name for the menu item's label. | |
| 483 | * @return The menu item added to the list of menu items. | |
| 484 | */ | |
| 485 | private MenuItem addMenuItem( | |
| 486 | final List<MenuItem> items, final String labelKey ) { | |
| 487 | final MenuItem menuItem = createMenuItem( labelKey ); | |
| 488 | items.add( menuItem ); | |
| 489 | return menuItem; | |
| 490 | } | |
| 491 | ||
| 492 | private MenuItem createMenuItem( final String labelKey ) { | |
| 493 | return new MenuItem( get( labelKey ) ); | |
| 494 | } | |
| 495 | ||
| 496 | /** | |
| 497 | * Creates a new {@link TreeItem} that is intended to be the root-level item | |
| 498 | * added to the {@link TreeView}. This allows the root item to be | |
| 499 | * distinguished from the other items so that reference keys do not include | |
| 500 | * "Definition" as part of their name. | |
| 501 | * | |
| 502 | * @return A new {@link TreeItem}, never {@code null}. | |
| 503 | */ | |
| 504 | private RootTreeItem<String> createRootTreeItem() { | |
| 505 | return new RootTreeItem<>( get( "Pane.definition.node.root.title" ) ); | |
| 506 | } | |
| 507 | ||
| 508 | private DefinitionTreeItem<String> createDefinitionTreeItem() { | |
| 509 | return new DefinitionTreeItem<>( get( "Definition.menu.add.default" ) ); | |
| 510 | } | |
| 511 | ||
| 512 | @Override | |
| 513 | public void requestFocus() { | |
| 537 | 514 | getTreeView().requestFocus(); |
| 538 | 515 | } |
| 23 | 23 | * |
| 24 | 24 | * @param value The {@link TreeItem} node name to construct the superclass. |
| 25 | * @see TreeItemMapper#toMap(TreeItem) for details on how this | |
| 25 | * @see TreeItemMapper#convert(TreeItem) for details on how this | |
| 26 | 26 | * class is used. |
| 27 | 27 | */ |
| 3 | 3 | |
| 4 | 4 | import com.fasterxml.jackson.databind.JsonNode; |
| 5 | import com.keenwrite.util.InterpolatingMap; | |
| 5 | 6 | import javafx.scene.control.TreeItem; |
| 6 | import javafx.scene.control.TreeView; | |
| 7 | 7 | |
| 8 | import java.util.HashMap; | |
| 9 | 8 | import java.util.Iterator; |
| 10 | import java.util.Map; | |
| 11 | 9 | import java.util.Stack; |
| 12 | ||
| 13 | import static com.keenwrite.constants.Constants.MAP_SIZE_DEFAULT; | |
| 14 | 10 | |
| 15 | 11 | /** |
| 16 | 12 | * Given a {@link TreeItem}, this will generate a flat map with all the |
| 17 | * values in the tree recursively interpolated. The application integrates | |
| 18 | * definition files as follows: | |
| 13 | * keys using a dot-separated notation to represent the tree's hierarchy. | |
| 14 | * | |
| 19 | 15 | * <ol> |
| 20 | 16 | * <li>Load YAML file into {@link JsonNode} hierarchy.</li> |
| 21 | 17 | * <li>Convert JsonNode to a {@link TreeItem} hierarchy.</li> |
| 22 | * <li>Interpolate {@link TreeItem} hierarchy as a flat map.</li> | |
| 23 | * <li>Substitute flat map variables into document as required.</li> | |
| 18 | * <li>Convert the {@link TreeItem} hierarchy into a flat map.</li> | |
| 24 | 19 | * </ol> |
| 25 | * | |
| 26 | * <p> | |
| 27 | * This class is responsible for producing the interpolated flat map. This | |
| 28 | * allows dynamic edits of the {@link TreeView} to be displayed without | |
| 29 | * having to reload the definition file. Reloading the definition file would | |
| 30 | * work, but has a number of drawbacks. | |
| 31 | * </p> | |
| 32 | 20 | */ |
| 33 | 21 | public final class TreeItemMapper { |
| 34 | 22 | /** |
| 35 | * Separates definition keys (e.g., the dots in {@code $root.node.var$}). | |
| 23 | * Key name hierarchy separator (i.e., the dots in {@code root.node.var}). | |
| 36 | 24 | */ |
| 37 | 25 | public static final String SEPARATOR = "."; |
| 38 | 26 | |
| 39 | 27 | /** |
| 40 | * Default buffer length for keys ({@link StringBuilder} has 16 character | |
| 41 | * buffer) that should be large enough for most keys to avoid reallocating | |
| 42 | * memory to increase the {@link StringBuilder}'s buffer. | |
| 28 | * Default buffer length for key names that should be large enough to | |
| 29 | * avoid reallocating memory to increase the {@link StringBuilder}'s | |
| 30 | * buffer. | |
| 43 | 31 | */ |
| 44 | 32 | public static final int DEFAULT_KEY_LENGTH = 64; |
| ... | ||
| 65 | 53 | @Override |
| 66 | 54 | public TreeItem<String> next() { |
| 67 | final TreeItem<String> next = mStack.pop(); | |
| 55 | final var next = mStack.pop(); | |
| 68 | 56 | next.getChildren().forEach( mStack::push ); |
| 69 | 57 | |
| 70 | 58 | return next; |
| 71 | 59 | } |
| 72 | } | |
| 73 | ||
| 74 | public TreeItemMapper() { | |
| 75 | 60 | } |
| 76 | 61 | |
| 77 | 62 | /** |
| 78 | 63 | * Iterate over a given root node (at any level of the tree) and process each |
| 79 | * leaf node into a flat map. Values must be interpolated separately. | |
| 64 | * leaf node into a flat map. | |
| 65 | * | |
| 66 | * @param root The topmost item in the tree. | |
| 80 | 67 | */ |
| 81 | public Map<String, String> toMap( final TreeItem<String> root ) { | |
| 82 | final var map = new HashMap<String, String>( MAP_SIZE_DEFAULT ); | |
| 83 | final var iterator = new TreeIterator( root ); | |
| 68 | public static InterpolatingMap convert( final TreeItem<String> root ) { | |
| 69 | final var map = new InterpolatingMap(); | |
| 84 | 70 | |
| 85 | iterator.forEachRemaining( item -> { | |
| 71 | new TreeIterator( root ).forEachRemaining( item -> { | |
| 86 | 72 | if( item.isLeaf() ) { |
| 87 | 73 | map.put( toPath( item.getParent() ), item.getValue() ); |
| ... | ||
| 100 | 86 | * @return The string representation of the node's unique key. |
| 101 | 87 | */ |
| 102 | public <T> String toPath( TreeItem<T> node ) { | |
| 88 | public static <T> String toPath( TreeItem<T> node ) { | |
| 103 | 89 | assert node != null; |
| 104 | 90 | |
| 29 | 29 | import java.nio.charset.Charset; |
| 30 | 30 | import java.text.BreakIterator; |
| 31 | import java.util.*; | |
| 32 | import java.util.function.Consumer; | |
| 33 | import java.util.function.Supplier; | |
| 34 | import java.util.regex.Pattern; | |
| 35 | ||
| 36 | import static com.keenwrite.MainApp.keyDown; | |
| 37 | import static com.keenwrite.Messages.get; | |
| 38 | import static com.keenwrite.constants.Constants.*; | |
| 39 | import static com.keenwrite.events.StatusEvent.clue; | |
| 40 | import static com.keenwrite.events.TextEditorFocusEvent.fireTextEditorFocus; | |
| 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.WorkspaceKeys.*; | |
| 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 | /** | |
| 69 | * The text editor. | |
| 70 | */ | |
| 71 | private final StyleClassedTextArea mTextArea = | |
| 72 | new StyleClassedTextArea( false ); | |
| 73 | ||
| 74 | /** | |
| 75 | * Wraps the text editor in scrollbars. | |
| 76 | */ | |
| 77 | private final VirtualizedScrollPane<StyleClassedTextArea> mScrollPane = | |
| 78 | new VirtualizedScrollPane<>( mTextArea ); | |
| 79 | ||
| 80 | /** | |
| 81 | * | |
| 82 | */ | |
| 83 | private final TextEditorSpeller mSpeller = new TextEditorSpeller(); | |
| 84 | ||
| 85 | private final Workspace mWorkspace; | |
| 86 | ||
| 87 | /** | |
| 88 | * Tracks where the caret is located in this document. This offers observable | |
| 89 | * properties for caret position changes. | |
| 90 | */ | |
| 91 | private final Caret mCaret = createCaret( mTextArea ); | |
| 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 a caret position change from raising the 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 | fireTextEditorFocus( 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 ) | |
| 478 | .build(); | |
| 479 | } | |
| 480 | ||
| 481 | /** | |
| 482 | * This method adds listeners to editor events. | |
| 483 | * | |
| 484 | * @param <T> The event type. | |
| 485 | * @param <U> The consumer type for the given event type. | |
| 486 | * @param event The event of interest. | |
| 487 | * @param consumer The method to call when the event happens. | |
| 488 | */ | |
| 489 | public <T extends Event, U extends T> void addEventListener( | |
| 490 | final EventPattern<? super T, ? extends U> event, | |
| 491 | final Consumer<? super U> consumer ) { | |
| 492 | Nodes.addInputMap( mTextArea, consume( event, consumer ) ); | |
| 493 | } | |
| 494 | ||
| 495 | private void onEnterPressed( final KeyEvent ignored ) { | |
| 496 | final var currentLine = getCaretParagraph(); | |
| 497 | final var matcher = PATTERN_AUTO_INDENT.matcher( currentLine ); | |
| 498 | ||
| 499 | // By default, insert a new line by itself. | |
| 500 | String newText = NEWLINE; | |
| 501 | ||
| 502 | // If the pattern was matched then determine what block type to continue. | |
| 503 | if( matcher.matches() ) { | |
| 504 | if( matcher.group( 2 ).isEmpty() ) { | |
| 505 | final var pos = mTextArea.getCaretPosition(); | |
| 506 | mTextArea.selectRange( pos - currentLine.length(), pos ); | |
| 507 | } | |
| 508 | else { | |
| 509 | // Indent the new line with the same whitespace characters and | |
| 510 | // list markers as current line. This ensures that the indentation | |
| 511 | // is propagated. | |
| 512 | newText = newText.concat( matcher.group( 1 ) ); | |
| 513 | } | |
| 514 | } | |
| 515 | ||
| 516 | mTextArea.replaceSelection( newText ); | |
| 517 | } | |
| 518 | ||
| 519 | /** | |
| 520 | * Delegates to {@link #autofix()}. | |
| 521 | * | |
| 522 | * @param event Ignored. | |
| 523 | */ | |
| 524 | private void autofix( final KeyEvent event ) { | |
| 525 | autofix(); | |
| 526 | } | |
| 527 | ||
| 528 | public void autofix() { | |
| 529 | final var caretWord = getCaretWord(); | |
| 530 | final var textArea = getTextArea(); | |
| 531 | final var word = textArea.getText( caretWord ); | |
| 532 | final var suggestions = mSpeller.checkWord( word, 10 ); | |
| 533 | ||
| 534 | if( suggestions.isEmpty() ) { | |
| 535 | clue( "Editor.spelling.check.matches.none", word ); | |
| 536 | } | |
| 537 | else if( !suggestions.contains( word ) ) { | |
| 538 | final var menu = createSuggestionsPopup(); | |
| 539 | final var items = menu.getItems(); | |
| 540 | textArea.setContextMenu( menu ); | |
| 541 | ||
| 542 | for( final var correction : suggestions ) { | |
| 543 | items.add( createSuggestedItem( caretWord, correction ) ); | |
| 544 | } | |
| 545 | ||
| 546 | textArea.getCaretBounds().ifPresent( | |
| 547 | bounds -> menu.show( | |
| 548 | textArea, bounds.getCenterX(), bounds.getCenterY() | |
| 549 | ) | |
| 550 | ); | |
| 551 | } | |
| 552 | else { | |
| 553 | clue( "Editor.spelling.check.matches.okay", word ); | |
| 554 | } | |
| 555 | } | |
| 556 | ||
| 557 | private ContextMenu createSuggestionsPopup() { | |
| 558 | final var menu = new ContextMenu(); | |
| 559 | ||
| 560 | menu.setAutoHide( true ); | |
| 561 | menu.setHideOnEscape( true ); | |
| 562 | menu.setOnHidden( event -> getTextArea().setContextMenu( null ) ); | |
| 563 | ||
| 564 | return menu; | |
| 565 | } | |
| 566 | ||
| 567 | /** | |
| 568 | * Creates a menu item capable of replacing a word under the cursor. | |
| 569 | * | |
| 570 | * @param i The beginning and ending text offset to replace. | |
| 571 | * @param s The text to replace at the given offset. | |
| 572 | * @return The menu item that, if actioned, will replace the text. | |
| 573 | */ | |
| 574 | private MenuItem createSuggestedItem( final IndexRange i, final String s ) { | |
| 575 | final var menuItem = new MenuItem( s ); | |
| 576 | ||
| 577 | menuItem.setOnAction( event -> getTextArea().replaceText( i, s ) ); | |
| 578 | ||
| 579 | return menuItem; | |
| 580 | } | |
| 581 | ||
| 582 | private void cut( final KeyEvent event ) { | |
| 583 | cut(); | |
| 584 | } | |
| 585 | ||
| 586 | private void tab( final KeyEvent event ) { | |
| 587 | final var range = mTextArea.selectionProperty().getValue(); | |
| 588 | final var sb = new StringBuilder( 1024 ); | |
| 589 | ||
| 590 | if( range.getLength() > 0 ) { | |
| 591 | final var selection = mTextArea.getSelectedText(); | |
| 592 | ||
| 593 | selection.lines().forEach( | |
| 594 | ( l ) -> sb.append( "\t" ).append( l ).append( NEWLINE ) | |
| 595 | ); | |
| 596 | } | |
| 597 | else { | |
| 598 | sb.append( "\t" ); | |
| 599 | } | |
| 600 | ||
| 601 | mTextArea.replaceSelection( sb.toString() ); | |
| 602 | } | |
| 603 | ||
| 604 | private void untab( final KeyEvent event ) { | |
| 605 | final var range = mTextArea.selectionProperty().getValue(); | |
| 606 | ||
| 607 | if( range.getLength() > 0 ) { | |
| 608 | final var selection = mTextArea.getSelectedText(); | |
| 609 | final var sb = new StringBuilder( selection.length() ); | |
| 610 | ||
| 611 | selection.lines().forEach( | |
| 612 | ( l ) -> sb.append( l.startsWith( "\t" ) ? l.substring( 1 ) : l ) | |
| 613 | .append( NEWLINE ) | |
| 614 | ); | |
| 615 | ||
| 616 | mTextArea.replaceSelection( sb.toString() ); | |
| 617 | } | |
| 618 | else { | |
| 619 | final var p = getCaretParagraph(); | |
| 620 | ||
| 621 | if( p.startsWith( "\t" ) ) { | |
| 622 | mTextArea.selectParagraph(); | |
| 623 | mTextArea.replaceSelection( p.substring( 1 ) ); | |
| 624 | } | |
| 625 | } | |
| 626 | } | |
| 627 | ||
| 628 | /** | |
| 629 | * Observers may listen for changes to the property returned from this method | |
| 630 | * to receive notifications when either the text or caret have changed. This | |
| 631 | * should not be used to track whether the text has been modified. | |
| 632 | */ | |
| 633 | public void addDirtyListener( ChangeListener<Boolean> listener ) { | |
| 634 | mDirty.addListener( listener ); | |
| 635 | } | |
| 636 | ||
| 637 | /** | |
| 638 | * Surrounds the selected text or word under the caret in Markdown markup. | |
| 639 | * | |
| 640 | * @param token The beginning and ending token for enclosing the text. | |
| 641 | */ | |
| 642 | private void enwrap( final String token ) { | |
| 643 | enwrap( token, token ); | |
| 644 | } | |
| 645 | ||
| 646 | /** | |
| 647 | * Surrounds the selected text or word under the caret in Markdown markup. | |
| 648 | * | |
| 649 | * @param began The beginning token for enclosing the text. | |
| 650 | * @param ended The ending token for enclosing the text. | |
| 651 | */ | |
| 652 | private void enwrap( final String began, String ended ) { | |
| 653 | // Ensure selected text takes precedence over the word at caret position. | |
| 654 | final var selected = mTextArea.selectionProperty().getValue(); | |
| 655 | final var range = selected.getLength() == 0 | |
| 656 | ? getCaretWord() | |
| 657 | : selected; | |
| 658 | String text = mTextArea.getText( range ); | |
| 659 | ||
| 660 | int length = range.getLength(); | |
| 661 | text = stripStart( text, null ); | |
| 662 | final int beganIndex = range.getStart() + (length - text.length()); | |
| 663 | ||
| 664 | length = text.length(); | |
| 665 | text = stripEnd( text, null ); | |
| 666 | final int endedIndex = range.getEnd() - (length - text.length()); | |
| 667 | ||
| 668 | mTextArea.replaceText( beganIndex, endedIndex, began + text + ended ); | |
| 669 | } | |
| 670 | ||
| 671 | /** | |
| 672 | * Inserts the given block-level markup at the current caret position | |
| 673 | * within the document. This will prepend two blank lines to ensure that | |
| 674 | * the block element begins at the start of a new line. | |
| 675 | * | |
| 676 | * @param markup The text to insert at the caret. | |
| 677 | */ | |
| 678 | private void block( final String markup ) { | |
| 679 | final int pos = mTextArea.getCaretPosition(); | |
| 680 | mTextArea.insertText( pos, format( "%n%n%s", markup ) ); | |
| 681 | } | |
| 682 | ||
| 683 | /** | |
| 684 | * Returns the caret position within the current paragraph. | |
| 685 | * | |
| 686 | * @return A value from 0 to the length of the current paragraph. | |
| 687 | */ | |
| 688 | private int getCaretColumn() { | |
| 689 | return mTextArea.getCaretColumn(); | |
| 690 | } | |
| 691 | ||
| 692 | @Override | |
| 693 | public IndexRange getCaretWord() { | |
| 694 | final var paragraph = getCaretParagraph() | |
| 695 | .replaceAll( "---", " " ) | |
| 696 | .replaceAll( "--", " " ) | |
| 697 | .replaceAll( "[\\[\\]{}()]", " " ); | |
| 698 | final var length = paragraph.length(); | |
| 699 | final var column = getCaretColumn(); | |
| 700 | ||
| 701 | var began = column; | |
| 702 | var ended = column; | |
| 703 | ||
| 704 | while( began > 0 && !isWhitespace( paragraph.charAt( began - 1 ) ) ) { | |
| 705 | began--; | |
| 706 | } | |
| 707 | ||
| 708 | while( ended < length && !isWhitespace( paragraph.charAt( ended ) ) ) { | |
| 709 | ended++; | |
| 710 | } | |
| 711 | ||
| 712 | final var iterator = BreakIterator.getWordInstance(); | |
| 713 | iterator.setText( paragraph ); | |
| 714 | ||
| 715 | while( began < length && iterator.isBoundary( began + 1 ) ) { | |
| 716 | began++; | |
| 717 | } | |
| 718 | ||
| 719 | while( ended > 0 && iterator.isBoundary( ended - 1 ) ) { | |
| 720 | ended--; | |
| 721 | } | |
| 722 | ||
| 723 | final var offset = getCaretDocumentOffset( column ); | |
| 724 | ||
| 725 | return IndexRange.normalize( began + offset, ended + offset ); | |
| 726 | } | |
| 727 | ||
| 728 | private int getCaretDocumentOffset( final int column ) { | |
| 729 | return mTextArea.getCaretPosition() - column; | |
| 730 | } | |
| 731 | ||
| 732 | /** | |
| 733 | * Returns the index of the paragraph where the caret resides. | |
| 734 | * | |
| 735 | * @return A number greater than or equal to 0. | |
| 736 | */ | |
| 737 | private int getCurrentParagraph() { | |
| 738 | return mTextArea.getCurrentParagraph(); | |
| 739 | } | |
| 740 | ||
| 741 | /** | |
| 742 | * Returns the text for the paragraph that contains the caret. | |
| 743 | * | |
| 744 | * @return A non-null string, possibly empty. | |
| 745 | */ | |
| 746 | private String getCaretParagraph() { | |
| 747 | return getText( getCurrentParagraph() ); | |
| 748 | } | |
| 749 | ||
| 750 | @Override | |
| 751 | public String getText( final int paragraph ) { | |
| 752 | return mTextArea.getText( paragraph ); | |
| 753 | } | |
| 754 | ||
| 755 | @Override | |
| 756 | public String getText( final IndexRange indexes ) | |
| 757 | throws IndexOutOfBoundsException { | |
| 758 | return mTextArea.getText( indexes.getStart(), indexes.getEnd() ); | |
| 759 | } | |
| 760 | ||
| 761 | @Override | |
| 762 | public void replaceText( final IndexRange indexes, final String s ) { | |
| 763 | mTextArea.replaceText( indexes, s ); | |
| 764 | } | |
| 765 | ||
| 766 | private UndoManager<?> getUndoManager() { | |
| 767 | return mTextArea.getUndoManager(); | |
| 768 | } | |
| 769 | ||
| 770 | /** | |
| 771 | * Returns the path to a {@link Locale}-specific stylesheet. | |
| 772 | * | |
| 773 | * @return A non-null string to inject into the HTML document head. | |
| 774 | */ | |
| 775 | private static String getStylesheetPath( final Locale locale ) { | |
| 776 | return get( | |
| 31 | import java.text.MessageFormat; | |
| 32 | import java.util.*; | |
| 33 | import java.util.function.Consumer; | |
| 34 | import java.util.function.Supplier; | |
| 35 | import java.util.regex.Pattern; | |
| 36 | ||
| 37 | import static com.keenwrite.MainApp.keyDown; | |
| 38 | import static com.keenwrite.constants.Constants.*; | |
| 39 | import static com.keenwrite.events.StatusEvent.clue; | |
| 40 | import static com.keenwrite.events.TextEditorFocusEvent.fireTextEditorFocus; | |
| 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.WorkspaceKeys.*; | |
| 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 | /** | |
| 69 | * The text editor. | |
| 70 | */ | |
| 71 | private final StyleClassedTextArea mTextArea = | |
| 72 | new StyleClassedTextArea( false ); | |
| 73 | ||
| 74 | /** | |
| 75 | * Wraps the text editor in scrollbars. | |
| 76 | */ | |
| 77 | private final VirtualizedScrollPane<StyleClassedTextArea> mScrollPane = | |
| 78 | new VirtualizedScrollPane<>( mTextArea ); | |
| 79 | ||
| 80 | /** | |
| 81 | * | |
| 82 | */ | |
| 83 | private final TextEditorSpeller mSpeller = new TextEditorSpeller(); | |
| 84 | ||
| 85 | private final Workspace mWorkspace; | |
| 86 | ||
| 87 | /** | |
| 88 | * Tracks where the caret is located in this document. This offers observable | |
| 89 | * properties for caret position changes. | |
| 90 | */ | |
| 91 | private final Caret mCaret = createCaret( mTextArea ); | |
| 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 a caret position change from raising the 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 | fireTextEditorFocus( 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 ) | |
| 478 | .build(); | |
| 479 | } | |
| 480 | ||
| 481 | /** | |
| 482 | * This method adds listeners to editor events. | |
| 483 | * | |
| 484 | * @param <T> The event type. | |
| 485 | * @param <U> The consumer type for the given event type. | |
| 486 | * @param event The event of interest. | |
| 487 | * @param consumer The method to call when the event happens. | |
| 488 | */ | |
| 489 | public <T extends Event, U extends T> void addEventListener( | |
| 490 | final EventPattern<? super T, ? extends U> event, | |
| 491 | final Consumer<? super U> consumer ) { | |
| 492 | Nodes.addInputMap( mTextArea, consume( event, consumer ) ); | |
| 493 | } | |
| 494 | ||
| 495 | private void onEnterPressed( final KeyEvent ignored ) { | |
| 496 | final var currentLine = getCaretParagraph(); | |
| 497 | final var matcher = PATTERN_AUTO_INDENT.matcher( currentLine ); | |
| 498 | ||
| 499 | // By default, insert a new line by itself. | |
| 500 | String newText = NEWLINE; | |
| 501 | ||
| 502 | // If the pattern was matched then determine what block type to continue. | |
| 503 | if( matcher.matches() ) { | |
| 504 | if( matcher.group( 2 ).isEmpty() ) { | |
| 505 | final var pos = mTextArea.getCaretPosition(); | |
| 506 | mTextArea.selectRange( pos - currentLine.length(), pos ); | |
| 507 | } | |
| 508 | else { | |
| 509 | // Indent the new line with the same whitespace characters and | |
| 510 | // list markers as current line. This ensures that the indentation | |
| 511 | // is propagated. | |
| 512 | newText = newText.concat( matcher.group( 1 ) ); | |
| 513 | } | |
| 514 | } | |
| 515 | ||
| 516 | mTextArea.replaceSelection( newText ); | |
| 517 | } | |
| 518 | ||
| 519 | /** | |
| 520 | * Delegates to {@link #autofix()}. | |
| 521 | * | |
| 522 | * @param event Ignored. | |
| 523 | */ | |
| 524 | private void autofix( final KeyEvent event ) { | |
| 525 | autofix(); | |
| 526 | } | |
| 527 | ||
| 528 | public void autofix() { | |
| 529 | final var caretWord = getCaretWord(); | |
| 530 | final var textArea = getTextArea(); | |
| 531 | final var word = textArea.getText( caretWord ); | |
| 532 | final var suggestions = mSpeller.checkWord( word, 10 ); | |
| 533 | ||
| 534 | if( suggestions.isEmpty() ) { | |
| 535 | clue( "Editor.spelling.check.matches.none", word ); | |
| 536 | } | |
| 537 | else if( !suggestions.contains( word ) ) { | |
| 538 | final var menu = createSuggestionsPopup(); | |
| 539 | final var items = menu.getItems(); | |
| 540 | textArea.setContextMenu( menu ); | |
| 541 | ||
| 542 | for( final var correction : suggestions ) { | |
| 543 | items.add( createSuggestedItem( caretWord, correction ) ); | |
| 544 | } | |
| 545 | ||
| 546 | textArea.getCaretBounds().ifPresent( | |
| 547 | bounds -> menu.show( | |
| 548 | textArea, bounds.getCenterX(), bounds.getCenterY() | |
| 549 | ) | |
| 550 | ); | |
| 551 | } | |
| 552 | else { | |
| 553 | clue( "Editor.spelling.check.matches.okay", word ); | |
| 554 | } | |
| 555 | } | |
| 556 | ||
| 557 | private ContextMenu createSuggestionsPopup() { | |
| 558 | final var menu = new ContextMenu(); | |
| 559 | ||
| 560 | menu.setAutoHide( true ); | |
| 561 | menu.setHideOnEscape( true ); | |
| 562 | menu.setOnHidden( event -> getTextArea().setContextMenu( null ) ); | |
| 563 | ||
| 564 | return menu; | |
| 565 | } | |
| 566 | ||
| 567 | /** | |
| 568 | * Creates a menu item capable of replacing a word under the cursor. | |
| 569 | * | |
| 570 | * @param i The beginning and ending text offset to replace. | |
| 571 | * @param s The text to replace at the given offset. | |
| 572 | * @return The menu item that, if actioned, will replace the text. | |
| 573 | */ | |
| 574 | private MenuItem createSuggestedItem( final IndexRange i, final String s ) { | |
| 575 | final var menuItem = new MenuItem( s ); | |
| 576 | ||
| 577 | menuItem.setOnAction( event -> getTextArea().replaceText( i, s ) ); | |
| 578 | ||
| 579 | return menuItem; | |
| 580 | } | |
| 581 | ||
| 582 | private void cut( final KeyEvent event ) { | |
| 583 | cut(); | |
| 584 | } | |
| 585 | ||
| 586 | private void tab( final KeyEvent event ) { | |
| 587 | final var range = mTextArea.selectionProperty().getValue(); | |
| 588 | final var sb = new StringBuilder( 1024 ); | |
| 589 | ||
| 590 | if( range.getLength() > 0 ) { | |
| 591 | final var selection = mTextArea.getSelectedText(); | |
| 592 | ||
| 593 | selection.lines().forEach( | |
| 594 | ( l ) -> sb.append( "\t" ).append( l ).append( NEWLINE ) | |
| 595 | ); | |
| 596 | } | |
| 597 | else { | |
| 598 | sb.append( "\t" ); | |
| 599 | } | |
| 600 | ||
| 601 | mTextArea.replaceSelection( sb.toString() ); | |
| 602 | } | |
| 603 | ||
| 604 | private void untab( final KeyEvent event ) { | |
| 605 | final var range = mTextArea.selectionProperty().getValue(); | |
| 606 | ||
| 607 | if( range.getLength() > 0 ) { | |
| 608 | final var selection = mTextArea.getSelectedText(); | |
| 609 | final var sb = new StringBuilder( selection.length() ); | |
| 610 | ||
| 611 | selection.lines().forEach( | |
| 612 | ( l ) -> sb.append( l.startsWith( "\t" ) ? l.substring( 1 ) : l ) | |
| 613 | .append( NEWLINE ) | |
| 614 | ); | |
| 615 | ||
| 616 | mTextArea.replaceSelection( sb.toString() ); | |
| 617 | } | |
| 618 | else { | |
| 619 | final var p = getCaretParagraph(); | |
| 620 | ||
| 621 | if( p.startsWith( "\t" ) ) { | |
| 622 | mTextArea.selectParagraph(); | |
| 623 | mTextArea.replaceSelection( p.substring( 1 ) ); | |
| 624 | } | |
| 625 | } | |
| 626 | } | |
| 627 | ||
| 628 | /** | |
| 629 | * Observers may listen for changes to the property returned from this method | |
| 630 | * to receive notifications when either the text or caret have changed. This | |
| 631 | * should not be used to track whether the text has been modified. | |
| 632 | */ | |
| 633 | public void addDirtyListener( ChangeListener<Boolean> listener ) { | |
| 634 | mDirty.addListener( listener ); | |
| 635 | } | |
| 636 | ||
| 637 | /** | |
| 638 | * Surrounds the selected text or word under the caret in Markdown markup. | |
| 639 | * | |
| 640 | * @param token The beginning and ending token for enclosing the text. | |
| 641 | */ | |
| 642 | private void enwrap( final String token ) { | |
| 643 | enwrap( token, token ); | |
| 644 | } | |
| 645 | ||
| 646 | /** | |
| 647 | * Surrounds the selected text or word under the caret in Markdown markup. | |
| 648 | * | |
| 649 | * @param began The beginning token for enclosing the text. | |
| 650 | * @param ended The ending token for enclosing the text. | |
| 651 | */ | |
| 652 | private void enwrap( final String began, String ended ) { | |
| 653 | // Ensure selected text takes precedence over the word at caret position. | |
| 654 | final var selected = mTextArea.selectionProperty().getValue(); | |
| 655 | final var range = selected.getLength() == 0 | |
| 656 | ? getCaretWord() | |
| 657 | : selected; | |
| 658 | String text = mTextArea.getText( range ); | |
| 659 | ||
| 660 | int length = range.getLength(); | |
| 661 | text = stripStart( text, null ); | |
| 662 | final int beganIndex = range.getStart() + (length - text.length()); | |
| 663 | ||
| 664 | length = text.length(); | |
| 665 | text = stripEnd( text, null ); | |
| 666 | final int endedIndex = range.getEnd() - (length - text.length()); | |
| 667 | ||
| 668 | mTextArea.replaceText( beganIndex, endedIndex, began + text + ended ); | |
| 669 | } | |
| 670 | ||
| 671 | /** | |
| 672 | * Inserts the given block-level markup at the current caret position | |
| 673 | * within the document. This will prepend two blank lines to ensure that | |
| 674 | * the block element begins at the start of a new line. | |
| 675 | * | |
| 676 | * @param markup The text to insert at the caret. | |
| 677 | */ | |
| 678 | private void block( final String markup ) { | |
| 679 | final int pos = mTextArea.getCaretPosition(); | |
| 680 | mTextArea.insertText( pos, format( "%n%n%s", markup ) ); | |
| 681 | } | |
| 682 | ||
| 683 | /** | |
| 684 | * Returns the caret position within the current paragraph. | |
| 685 | * | |
| 686 | * @return A value from 0 to the length of the current paragraph. | |
| 687 | */ | |
| 688 | private int getCaretColumn() { | |
| 689 | return mTextArea.getCaretColumn(); | |
| 690 | } | |
| 691 | ||
| 692 | @Override | |
| 693 | public IndexRange getCaretWord() { | |
| 694 | final var paragraph = getCaretParagraph() | |
| 695 | .replaceAll( "---", " " ) | |
| 696 | .replaceAll( "--", " " ) | |
| 697 | .replaceAll( "[\\[\\]{}()]", " " ); | |
| 698 | final var length = paragraph.length(); | |
| 699 | final var column = getCaretColumn(); | |
| 700 | ||
| 701 | var began = column; | |
| 702 | var ended = column; | |
| 703 | ||
| 704 | while( began > 0 && !isWhitespace( paragraph.charAt( began - 1 ) ) ) { | |
| 705 | began--; | |
| 706 | } | |
| 707 | ||
| 708 | while( ended < length && !isWhitespace( paragraph.charAt( ended ) ) ) { | |
| 709 | ended++; | |
| 710 | } | |
| 711 | ||
| 712 | final var iterator = BreakIterator.getWordInstance(); | |
| 713 | iterator.setText( paragraph ); | |
| 714 | ||
| 715 | while( began < length && iterator.isBoundary( began + 1 ) ) { | |
| 716 | began++; | |
| 717 | } | |
| 718 | ||
| 719 | while( ended > 0 && iterator.isBoundary( ended - 1 ) ) { | |
| 720 | ended--; | |
| 721 | } | |
| 722 | ||
| 723 | final var offset = getCaretDocumentOffset( column ); | |
| 724 | ||
| 725 | return IndexRange.normalize( began + offset, ended + offset ); | |
| 726 | } | |
| 727 | ||
| 728 | private int getCaretDocumentOffset( final int column ) { | |
| 729 | return mTextArea.getCaretPosition() - column; | |
| 730 | } | |
| 731 | ||
| 732 | /** | |
| 733 | * Returns the index of the paragraph where the caret resides. | |
| 734 | * | |
| 735 | * @return A number greater than or equal to 0. | |
| 736 | */ | |
| 737 | private int getCurrentParagraph() { | |
| 738 | return mTextArea.getCurrentParagraph(); | |
| 739 | } | |
| 740 | ||
| 741 | /** | |
| 742 | * Returns the text for the paragraph that contains the caret. | |
| 743 | * | |
| 744 | * @return A non-null string, possibly empty. | |
| 745 | */ | |
| 746 | private String getCaretParagraph() { | |
| 747 | return getText( getCurrentParagraph() ); | |
| 748 | } | |
| 749 | ||
| 750 | @Override | |
| 751 | public String getText( final int paragraph ) { | |
| 752 | return mTextArea.getText( paragraph ); | |
| 753 | } | |
| 754 | ||
| 755 | @Override | |
| 756 | public String getText( final IndexRange indexes ) | |
| 757 | throws IndexOutOfBoundsException { | |
| 758 | return mTextArea.getText( indexes.getStart(), indexes.getEnd() ); | |
| 759 | } | |
| 760 | ||
| 761 | @Override | |
| 762 | public void replaceText( final IndexRange indexes, final String s ) { | |
| 763 | mTextArea.replaceText( indexes, s ); | |
| 764 | } | |
| 765 | ||
| 766 | private UndoManager<?> getUndoManager() { | |
| 767 | return mTextArea.getUndoManager(); | |
| 768 | } | |
| 769 | ||
| 770 | /** | |
| 771 | * Returns the path to a {@link Locale}-specific stylesheet. | |
| 772 | * | |
| 773 | * @return A non-null string to inject into the HTML document head. | |
| 774 | */ | |
| 775 | private static String getStylesheetPath( final Locale locale ) { | |
| 776 | return MessageFormat.format( | |
| 777 | 777 | sSettings.getSetting( STYLESHEET_MARKDOWN_LOCALE, "" ), |
| 778 | 778 | locale.getLanguage(), |
| 12 | 12 | * Submits this event to the {@link Bus}. |
| 13 | 13 | */ |
| 14 | default void fire() { | |
| 14 | default void publish() { | |
| 15 | 15 | post( this ); |
| 16 | 16 | } |
| 23 | 23 | * @param offset Move the caret to this document offset. |
| 24 | 24 | */ |
| 25 | public static void fireCaretNavigationEvent( final int offset ) { | |
| 26 | new CaretNavigationEvent( offset ).fire(); | |
| 25 | public static void fire( final int offset ) { | |
| 26 | new CaretNavigationEvent( offset ).publish(); | |
| 27 | 27 | } |
| 28 | 28 |
| 32 | 32 | * @param html The document that may have changed. |
| 33 | 33 | */ |
| 34 | public static void fireDocumentChangedEvent( final String html ) { | |
| 34 | public static void fire( final String html ) { | |
| 35 | 35 | // Hashing the document text ignores caret position changes. |
| 36 | 36 | final var hash = html.hashCode(); |
| 37 | 37 | |
| 38 | 38 | if( hash != sHash ) { |
| 39 | 39 | sHash = hash; |
| 40 | new DocumentChangedEvent( html ).fire(); | |
| 40 | new DocumentChangedEvent( html ).publish(); | |
| 41 | 41 | } |
| 42 | 42 | } |
| 8 | 8 | */ |
| 9 | 9 | public class ExportFailedEvent implements AppEvent { |
| 10 | public static void fireExportFailedEvent() { | |
| 11 | new ExportFailedEvent().fire(); | |
| 10 | public static void fire() { | |
| 11 | new ExportFailedEvent().publish(); | |
| 12 | 12 | } |
| 13 | 13 | } |
| 21 | 21 | * @param uri The instance of {@link URI} to open as a file in a text editor. |
| 22 | 22 | */ |
| 23 | public static void fireFileOpenEvent( final URI uri ) { | |
| 24 | new FileOpenEvent( uri ).fire(); | |
| 23 | public static void fire( final URI uri ) { | |
| 24 | new FileOpenEvent( uri ).publish(); | |
| 25 | 25 | } |
| 26 | 26 |
| 22 | 22 | * @param uri The location to open. |
| 23 | 23 | */ |
| 24 | public static void fireHyperlinkOpenEvent( final URI uri ) | |
| 24 | public static void fire( final URI uri ) | |
| 25 | 25 | throws IOException { |
| 26 | new HyperlinkOpenEvent( uri ).fire(); | |
| 26 | new HyperlinkOpenEvent( uri ).publish(); | |
| 27 | 27 | } |
| 28 | 28 | |
| 29 | 29 | /** |
| 30 | 30 | * Requests to open the default browser at the given location. |
| 31 | 31 | * |
| 32 | 32 | * @param uri The location to open. |
| 33 | 33 | */ |
| 34 | public static void fireHyperlinkOpenEvent( final String uri ) { | |
| 34 | public static void fire( final String uri ) { | |
| 35 | 35 | try { |
| 36 | fireHyperlinkOpenEvent( new URI( uri ) ); | |
| 36 | fire( new URI( uri ) ); | |
| 37 | 37 | } catch( final Exception ex ) { |
| 38 | 38 | clue( ex ); |
| 38 | 38 | */ |
| 39 | 39 | public static void fireNewOutlineEvent() { |
| 40 | new ParseHeadingEvent( NEW_OUTLINE_LEVEL, "Document", 0 ).fire(); | |
| 40 | new ParseHeadingEvent( NEW_OUTLINE_LEVEL, "Document", 0 ).publish(); | |
| 41 | 41 | } |
| 42 | 42 | |
| 43 | 43 | /** |
| 44 | 44 | * Call to indicate that a new heading must be added to the document outline. |
| 45 | 45 | * |
| 46 | 46 | * @param text The heading text (parsed and processed). |
| 47 | 47 | * @param level A value between 1 and 6. |
| 48 | 48 | * @param offset Absolute offset into document where heading is found. |
| 49 | 49 | */ |
| 50 | public static void fireNewHeadingEvent( | |
| 50 | public static void fire( | |
| 51 | 51 | final int level, final String text, final int offset ) { |
| 52 | 52 | assert text != null; |
| 53 | 53 | assert 1 <= level && level <= 6; |
| 54 | 54 | assert 0 <= offset; |
| 55 | new ParseHeadingEvent( level, text, offset ).fire(); | |
| 55 | new ParseHeadingEvent( level, text, offset ).publish(); | |
| 56 | 56 | } |
| 57 | 57 |
| 47 | 47 | |
| 48 | 48 | private static void fire( final boolean locked ) { |
| 49 | new ScrollLockEvent( locked ).fire(); | |
| 49 | new ScrollLockEvent( locked ).publish(); | |
| 50 | 50 | } |
| 51 | 51 |
| 41 | 41 | */ |
| 42 | 42 | public StatusEvent( final String message ) { |
| 43 | assert message != null; | |
| 44 | mMessage = message; | |
| 45 | mProblem = null; | |
| 43 | this( message, null ); | |
| 46 | 44 | } |
| 47 | 45 | |
| 48 | 46 | public StatusEvent( final Throwable problem ) { |
| 49 | 47 | this( "", problem ); |
| 50 | 48 | } |
| 51 | 49 | |
| 50 | /** | |
| 51 | * @param message The human-readable message text. | |
| 52 | * @param problem May be {@code null} if no exception was thrown. | |
| 53 | */ | |
| 52 | 54 | public StatusEvent( final String message, final Throwable problem ) { |
| 53 | 55 | assert message != null; |
| 54 | assert problem != null; | |
| 55 | 56 | mMessage = message; |
| 56 | 57 | mProblem = problem; |
| ... | ||
| 81 | 82 | @Override |
| 82 | 83 | public String toString() { |
| 84 | // Not exactly sure how the message can be null, but it happened once! | |
| 85 | final var message = mMessage == null ? "UNKNOWN" : mMessage; | |
| 86 | ||
| 83 | 87 | return format( "%s%s%s", |
| 84 | mMessage, | |
| 85 | mMessage.isBlank() ? "" : " ", | |
| 88 | message, | |
| 89 | message.isBlank() ? "" : " ", | |
| 86 | 90 | mProblem == null ? "" : toEnglish( mProblem ) ); |
| 87 | 91 | } |
| ... | ||
| 134 | 138 | */ |
| 135 | 139 | public static void clue() { |
| 136 | fireStatusEvent( get( STATUS_BAR_OK, "OK" ) ); | |
| 140 | fire( get( STATUS_BAR_OK, "OK" ) ); | |
| 137 | 141 | } |
| 138 | 142 | |
| 139 | 143 | /** |
| 140 | 144 | * Notifies listeners of a series of messages. This is useful when providing |
| 141 | 145 | * users feedback of how third-party executables have failed. |
| 142 | 146 | * |
| 143 | 147 | * @param messages The lines of text to display. |
| 144 | 148 | */ |
| 145 | 149 | public static void clue( final List<String> messages ) { |
| 146 | messages.forEach( StatusEvent::fireStatusEvent ); | |
| 150 | messages.forEach( StatusEvent::fire ); | |
| 147 | 151 | } |
| 148 | 152 | |
| 149 | 153 | /** |
| 150 | 154 | * Notifies listeners of an error. |
| 151 | 155 | * |
| 152 | 156 | * @param key The message bundle key to look up. |
| 153 | 157 | * @param t The exception that caused the error. |
| 154 | 158 | */ |
| 155 | 159 | public static void clue( final String key, final Throwable t ) { |
| 156 | fireStatusEvent( get( key ), t ); | |
| 160 | fire( get( key ), t ); | |
| 157 | 161 | } |
| 158 | 162 | |
| 159 | 163 | /** |
| 160 | 164 | * Notifies listeners of a custom message. |
| 161 | 165 | * |
| 162 | 166 | * @param key The property key having a value to populate with arguments. |
| 163 | 167 | * @param args The placeholder values to substitute into the key's value. |
| 164 | 168 | */ |
| 165 | 169 | public static void clue( final String key, final Object... args ) { |
| 166 | fireStatusEvent( get( key, args ) ); | |
| 170 | fire( get( key, args ) ); | |
| 167 | 171 | } |
| 168 | 172 | |
| 169 | 173 | /** |
| 170 | 174 | * Notifies listeners of an exception occurs that warrants the user's |
| 171 | 175 | * attention. |
| 172 | 176 | * |
| 173 | 177 | * @param problem The exception with a message to display to the user. |
| 174 | 178 | */ |
| 175 | 179 | public static void clue( final Throwable problem ) { |
| 176 | fireStatusEvent( problem ); | |
| 180 | fire( problem ); | |
| 177 | 181 | } |
| 178 | 182 | |
| 179 | private static void fireStatusEvent( final String message ) { | |
| 180 | new StatusEvent( message ).fire(); | |
| 183 | private static void fire( final String message ) { | |
| 184 | new StatusEvent( message ).publish(); | |
| 181 | 185 | } |
| 182 | 186 | |
| 183 | private static void fireStatusEvent( final Throwable problem ) { | |
| 184 | new StatusEvent( problem ).fire(); | |
| 187 | private static void fire( final Throwable problem ) { | |
| 188 | new StatusEvent( problem ).publish(); | |
| 185 | 189 | } |
| 186 | 190 | |
| 187 | private static void fireStatusEvent( | |
| 191 | private static void fire( | |
| 188 | 192 | final String message, final Throwable problem ) { |
| 189 | new StatusEvent( message, problem ).fire(); | |
| 193 | new StatusEvent( message, problem ).publish(); | |
| 190 | 194 | } |
| 191 | 195 | } |
| 15 | 15 | * @param editor The instance of editor that has gained input focus. |
| 16 | 16 | */ |
| 17 | public static void fireTextDefinitionFocus( final TextDefinition editor ) { | |
| 18 | new TextDefinitionFocusEvent( editor ).fire(); | |
| 17 | public static void fire( final TextDefinition editor ) { | |
| 18 | new TextDefinitionFocusEvent( editor ).publish(); | |
| 19 | 19 | } |
| 20 | 20 | } |
| 16 | 16 | */ |
| 17 | 17 | public static void fireTextEditorFocus( final TextEditor editor ) { |
| 18 | new TextEditorFocusEvent( editor ).fire(); | |
| 18 | new TextEditorFocusEvent( editor ).publish(); | |
| 19 | 19 | } |
| 20 | 20 | } |
| 20 | 20 | * @param count The approximate number of words in the document. |
| 21 | 21 | */ |
| 22 | public static void fireWordCountEvent( final int count ) { | |
| 23 | new WordCountEvent( count ).fire(); | |
| 22 | public static void fire( final int count ) { | |
| 23 | new WordCountEvent( count ).publish(); | |
| 24 | 24 | } |
| 25 | 25 |
| 47 | 47 | |
| 48 | 48 | for( final var extension : EXTENSIONS ) { |
| 49 | if( isExecutable( Path.of( p.toString() + extension ) ) ) { | |
| 49 | if( isExecutable( Path.of( p + extension ) ) ) { | |
| 50 | 50 | return true; |
| 51 | 51 | } |
| 3 | 3 | |
| 4 | 4 | import com.keenwrite.constants.Constants; |
| 5 | import com.keenwrite.sigils.Tokens; | |
| 5 | import com.keenwrite.sigils.Sigils; | |
| 6 | 6 | import javafx.application.Platform; |
| 7 | 7 | import javafx.beans.property.*; |
| ... | ||
| 98 | 98 | entry( KEY_UI_RECENT_DOCUMENT, asFileProperty( DOCUMENT_DEFAULT ) ), |
| 99 | 99 | entry( KEY_UI_RECENT_DEFINITION, asFileProperty( DEFINITION_DEFAULT ) ), |
| 100 | entry( KEY_UI_RECENT_EXPORT, asFileProperty( PDF_DEFAULT ) ), | |
| 100 | 101 | |
| 101 | 102 | //@formatter:off |
| ... | ||
| 300 | 301 | } |
| 301 | 302 | |
| 302 | public Tokens toTokens( final Key began, final Key ended ) { | |
| 303 | public Sigils toSigils( final Key began, final Key ended ) { | |
| 303 | 304 | assert began != null; |
| 304 | 305 | assert ended != null; |
| 305 | return new Tokens( stringProperty( began ), stringProperty( ended ) ); | |
| 306 | return new Sigils( stringProperty( began ), stringProperty( ended ) ); | |
| 306 | 307 | } |
| 307 | 308 | |
| 55 | 55 | public static final Key KEY_UI_RECENT_DOCUMENT = key( KEY_UI_RECENT, "document" ); |
| 56 | 56 | public static final Key KEY_UI_RECENT_DEFINITION = key( KEY_UI_RECENT, "definition" ); |
| 57 | public static final Key KEY_UI_RECENT_EXPORT = key( KEY_UI_RECENT, "export" ); | |
| 57 | 58 | |
| 58 | 59 | public static final Key KEY_UI_FILES = key( KEY_UI, "files" ); |
| 2 | 2 | package com.keenwrite.preview; |
| 3 | 3 | |
| 4 | import com.keenwrite.events.FileOpenEvent; | |
| 5 | import com.keenwrite.events.HyperlinkOpenEvent; | |
| 4 | 6 | import com.keenwrite.ui.adapters.DocumentAdapter; |
| 5 | 7 | import javafx.beans.property.BooleanProperty; |
| ... | ||
| 18 | 20 | import java.net.URI; |
| 19 | 21 | |
| 20 | import static com.keenwrite.events.FileOpenEvent.fireFileOpenEvent; | |
| 21 | import static com.keenwrite.events.HyperlinkOpenEvent.fireHyperlinkOpenEvent; | |
| 22 | 22 | import static com.keenwrite.events.StatusEvent.clue; |
| 23 | 23 | import static com.keenwrite.util.ProtocolScheme.getProtocol; |
| ... | ||
| 75 | 75 | |
| 76 | 76 | switch( getProtocol( uri ) ) { |
| 77 | case HTTP -> fireHyperlinkOpenEvent( uri ); | |
| 78 | case FILE -> fireFileOpenEvent( uri ); | |
| 77 | case HTTP -> HyperlinkOpenEvent.fire( uri ); | |
| 78 | case FILE -> FileOpenEvent.fire( uri ); | |
| 79 | 79 | } |
| 80 | 80 | } catch( final Exception ex ) { |
| 3 | 3 | |
| 4 | 4 | import com.keenwrite.dom.DocumentConverter; |
| 5 | import com.keenwrite.events.DocumentChangedEvent; | |
| 5 | 6 | import com.keenwrite.events.ScrollLockEvent; |
| 6 | 7 | import com.keenwrite.preferences.LocaleProperty; |
| ... | ||
| 19 | 20 | import java.util.Locale; |
| 20 | 21 | |
| 21 | import static com.keenwrite.Messages.get; | |
| 22 | 22 | import static com.keenwrite.constants.Constants.*; |
| 23 | 23 | import static com.keenwrite.events.Bus.register; |
| 24 | import static com.keenwrite.events.DocumentChangedEvent.fireDocumentChangedEvent; | |
| 25 | 24 | import static com.keenwrite.events.ScrollLockEvent.fireScrollLockEvent; |
| 26 | 25 | import static com.keenwrite.events.StatusEvent.clue; |
| ... | ||
| 162 | 161 | invokeLater( () -> mPreview.render( doc, uri ) ); |
| 163 | 162 | |
| 164 | fireDocumentChangedEvent( html ); | |
| 163 | DocumentChangedEvent.fire( html ); | |
| 165 | 164 | } |
| 166 | 165 | |
| ... | ||
| 276 | 275 | private static URL toUrl( final Locale locale ) { |
| 277 | 276 | return toUrl( |
| 278 | get( | |
| 277 | String.format( | |
| 279 | 278 | sSettings.getSetting( STYLESHEET_PREVIEW_LOCALE, "" ), |
| 280 | 279 | locale.getLanguage(), |
| 15 | 15 | extends ExecutorProcessor<String> implements Function<String, String> { |
| 16 | 16 | |
| 17 | private final Map<String, String> mDefinitions; | |
| 17 | private final ProcessorContext mContext; | |
| 18 | 18 | |
| 19 | 19 | /** |
| ... | ||
| 27 | 27 | final ProcessorContext context ) { |
| 28 | 28 | super( successor ); |
| 29 | mDefinitions = context.getResolvedMap(); | |
| 29 | mContext = context; | |
| 30 | 30 | } |
| 31 | 31 | |
| ... | ||
| 48 | 48 | */ |
| 49 | 49 | protected Map<String, String> getDefinitions() { |
| 50 | return mDefinitions; | |
| 50 | return mContext.getResolvedMap(); | |
| 51 | 51 | } |
| 52 | 52 | } |
| 37 | 37 | assert html != null; |
| 38 | 38 | |
| 39 | getHtmlPreviewPane().render( html ); | |
| 39 | sHtmlPreviewPane.render( html ); | |
| 40 | 40 | return html; |
| 41 | } | |
| 42 | ||
| 43 | private HtmlPreview getHtmlPreviewPane() { | |
| 44 | return sHtmlPreviewPane; | |
| 45 | 41 | } |
| 46 | 42 | } |
| 37 | 37 | final var document = TEXT_XML.createTemporaryFile( APP_TITLE_LOWERCASE ); |
| 38 | 38 | final var pathInput = writeString( document, xhtml ); |
| 39 | final var pathOutput = mContext.getExportPath(); | |
| 39 | final var pathOutput = mContext.getOutputPath(); | |
| 40 | 40 | final var typesetter = new Typesetter( mContext.getWorkspace() ); |
| 41 | 41 |
| 5 | 5 | import com.keenwrite.ExportFormat; |
| 6 | 6 | import com.keenwrite.constants.Constants; |
| 7 | import com.keenwrite.editors.TextDefinition; | |
| 7 | 8 | import com.keenwrite.io.FileType; |
| 8 | 9 | import com.keenwrite.preferences.Workspace; |
| 9 | 10 | import com.keenwrite.preview.HtmlPreview; |
| 11 | import com.keenwrite.util.GenericBuilder; | |
| 12 | import javafx.beans.property.ObjectProperty; | |
| 10 | 13 | |
| 14 | import java.io.File; | |
| 11 | 15 | import java.nio.file.Path; |
| 12 | 16 | import java.util.Map; |
| ... | ||
| 19 | 23 | */ |
| 20 | 24 | public final class ProcessorContext { |
| 21 | private final HtmlPreview mHtmlPreview; | |
| 22 | private final Map<String, String> mResolvedMap; | |
| 23 | private final Path mDocumentPath; | |
| 24 | private final Path mExportPath; | |
| 25 | private final Caret mCaret; | |
| 26 | private final ExportFormat mExportFormat; | |
| 27 | private final Workspace mWorkspace; | |
| 25 | ||
| 26 | private final Mutator mMutator; | |
| 28 | 27 | |
| 29 | 28 | /** |
| 30 | 29 | * Creates a new context for use by the {@link ProcessorFactory} when |
| 31 | 30 | * instantiating new {@link Processor} instances. Although all the |
| 32 | 31 | * parameters are required, not all {@link Processor} instances will use |
| 33 | 32 | * all parameters. |
| 34 | * | |
| 35 | * @param htmlPreview Where to display the final (HTML) output. | |
| 36 | * @param resolvedMap Fully expanded interpolated strings. | |
| 37 | * @param documentPath Path to the document to process. | |
| 38 | * @param exportPath Fully qualified filename to use when exporting. | |
| 39 | * @param exportFormat Indicate configuration options for export format. | |
| 40 | * @param workspace Persistent user preferences settings. | |
| 41 | * @param caret Location of the caret in the edited document, which is | |
| 42 | * used to synchronize the scrollbars. | |
| 43 | 33 | */ |
| 44 | public ProcessorContext( | |
| 45 | final HtmlPreview htmlPreview, | |
| 46 | final Map<String, String> resolvedMap, | |
| 47 | final Path documentPath, | |
| 48 | final Path exportPath, | |
| 49 | final ExportFormat exportFormat, | |
| 34 | private ProcessorContext( final Mutator mutator ) { | |
| 35 | assert mutator != null; | |
| 36 | ||
| 37 | mMutator = mutator; | |
| 38 | } | |
| 39 | ||
| 40 | public static class Mutator { | |
| 41 | private HtmlPreview mHtmlPreview; | |
| 42 | private ObjectProperty<TextDefinition> mTextDefinition; | |
| 43 | private Path mInputPath; | |
| 44 | private Path mOutputPath; | |
| 45 | private Caret mCaret; | |
| 46 | private ExportFormat mExportFormat; | |
| 47 | private Workspace mWorkspace; | |
| 48 | ||
| 49 | public void setHtmlPreview( final HtmlPreview htmlPreview ) { | |
| 50 | mHtmlPreview = htmlPreview; | |
| 51 | } | |
| 52 | ||
| 53 | public void setTextDefinition( | |
| 54 | final ObjectProperty<TextDefinition> textDefinition ) { | |
| 55 | mTextDefinition = textDefinition; | |
| 56 | } | |
| 57 | ||
| 58 | public void setInputPath( final Path inputPath ) { | |
| 59 | mInputPath = inputPath; | |
| 60 | } | |
| 61 | ||
| 62 | public void setInputPath( final File inputPath ) { | |
| 63 | setInputPath( inputPath.toPath() ); | |
| 64 | } | |
| 65 | ||
| 66 | public void setOutputPath( final Path outputPath ) { | |
| 67 | mOutputPath = outputPath; | |
| 68 | } | |
| 69 | ||
| 70 | public void setOutputPath( final File outputPath ) { | |
| 71 | setOutputPath( outputPath.toPath() ); | |
| 72 | } | |
| 73 | ||
| 74 | public void setCaret( final Caret caret ) { | |
| 75 | mCaret = caret; | |
| 76 | } | |
| 77 | ||
| 78 | public void setExportFormat( final ExportFormat exportFormat ) { | |
| 79 | mExportFormat = exportFormat; | |
| 80 | } | |
| 81 | ||
| 82 | public void setWorkspace( final Workspace workspace ) { | |
| 83 | mWorkspace = workspace; | |
| 84 | } | |
| 85 | } | |
| 86 | ||
| 87 | public static GenericBuilder<Mutator, ProcessorContext> builder() { | |
| 88 | return GenericBuilder.of( | |
| 89 | Mutator::new, | |
| 90 | ProcessorContext::new | |
| 91 | ); | |
| 92 | } | |
| 93 | ||
| 94 | /** | |
| 95 | * @param inputPath Path to the document to process. | |
| 96 | * @param outputPath Fully qualified filename to use when exporting. | |
| 97 | * @param format Indicate configuration options for export format. | |
| 98 | * @param preview Where to display the final (HTML) output. | |
| 99 | * @param textDefinition Source for fully expanded interpolated strings. | |
| 100 | * @param workspace Persistent user preferences settings. | |
| 101 | * @param caret Location of the caret in the edited document, | |
| 102 | * which is used to synchronize the scrollbars. | |
| 103 | * @return A context that may be used for processing documents. | |
| 104 | */ | |
| 105 | public static ProcessorContext create( | |
| 106 | final Path inputPath, | |
| 107 | final Path outputPath, | |
| 108 | final ExportFormat format, | |
| 109 | final HtmlPreview preview, | |
| 110 | final ObjectProperty<TextDefinition> textDefinition, | |
| 50 | 111 | final Workspace workspace, |
| 51 | 112 | final Caret caret ) { |
| 52 | assert htmlPreview != null; | |
| 53 | assert resolvedMap != null; | |
| 54 | assert documentPath != null; | |
| 55 | assert exportFormat != null; | |
| 56 | assert workspace != null; | |
| 57 | assert caret != null; | |
| 113 | return builder() | |
| 114 | .with( Mutator::setInputPath, inputPath ) | |
| 115 | .with( Mutator::setOutputPath, outputPath ) | |
| 116 | .with( Mutator::setExportFormat, format ) | |
| 117 | .with( Mutator::setHtmlPreview, preview ) | |
| 118 | .with( Mutator::setTextDefinition, textDefinition ) | |
| 119 | .with( Mutator::setWorkspace, workspace ) | |
| 120 | .with( Mutator::setCaret, caret ) | |
| 121 | .build(); | |
| 122 | } | |
| 58 | 123 | |
| 59 | mHtmlPreview = htmlPreview; | |
| 60 | mResolvedMap = resolvedMap; | |
| 61 | mDocumentPath = documentPath; | |
| 62 | mCaret = caret; | |
| 63 | mExportPath = exportPath; | |
| 64 | mExportFormat = exportFormat; | |
| 65 | mWorkspace = workspace; | |
| 124 | /** | |
| 125 | * @param inputPath Path to the document to process. | |
| 126 | * @param format Indicate configuration options for export format. | |
| 127 | * @return A context that may be used for processing documents. | |
| 128 | */ | |
| 129 | public static ProcessorContext create( | |
| 130 | final Path inputPath, | |
| 131 | final ExportFormat format ) { | |
| 132 | return builder() | |
| 133 | .with( Mutator::setInputPath, inputPath ) | |
| 134 | .with( Mutator::setExportFormat, format ) | |
| 135 | .build(); | |
| 136 | } | |
| 137 | ||
| 138 | /** | |
| 139 | * @param inputPath Path to the document to process. | |
| 140 | * @param outputPath Fully qualified filename to use when exporting. | |
| 141 | * @param format Indicate configuration options for export format. | |
| 142 | * @return A context that may be used for processing documents. | |
| 143 | */ | |
| 144 | public static ProcessorContext create( | |
| 145 | final Path inputPath, final Path outputPath, final ExportFormat format ) { | |
| 146 | return builder() | |
| 147 | .with( Mutator::setInputPath, inputPath ) | |
| 148 | .with( Mutator::setOutputPath, outputPath ) | |
| 149 | .with( Mutator::setExportFormat, format ) | |
| 150 | .build(); | |
| 66 | 151 | } |
| 67 | 152 | |
| 68 | 153 | public boolean isExportFormat( final ExportFormat format ) { |
| 69 | return mExportFormat == format; | |
| 154 | return mMutator.mExportFormat == format; | |
| 70 | 155 | } |
| 71 | 156 | |
| 72 | 157 | HtmlPreview getPreview() { |
| 73 | return mHtmlPreview; | |
| 158 | return mMutator.mHtmlPreview; | |
| 74 | 159 | } |
| 75 | 160 | |
| 76 | 161 | /** |
| 77 | 162 | * Returns the variable map of interpolated definitions. |
| 78 | 163 | * |
| 79 | 164 | * @return A map to help dereference variables. |
| 80 | 165 | */ |
| 81 | 166 | Map<String, String> getResolvedMap() { |
| 82 | return mResolvedMap; | |
| 167 | return mMutator.mTextDefinition.get().getDefinitions(); | |
| 83 | 168 | } |
| 84 | 169 | |
| 85 | 170 | /** |
| 86 | 171 | * Fully qualified file name to use when exporting (e.g., document.pdf). |
| 87 | 172 | * |
| 88 | 173 | * @return Full path to a file name. |
| 89 | 174 | */ |
| 90 | public Path getExportPath() { | |
| 91 | return mExportPath; | |
| 175 | public Path getOutputPath() { | |
| 176 | return mMutator.mOutputPath; | |
| 92 | 177 | } |
| 93 | 178 | |
| 94 | 179 | public ExportFormat getExportFormat() { |
| 95 | return mExportFormat; | |
| 180 | return mMutator.mExportFormat; | |
| 96 | 181 | } |
| 97 | 182 | |
| 98 | 183 | /** |
| 99 | 184 | * Returns the current caret position in the document being edited and is |
| 100 | 185 | * always up-to-date. |
| 101 | 186 | * |
| 102 | 187 | * @return Caret position in the document. |
| 103 | 188 | */ |
| 104 | 189 | public Caret getCaret() { |
| 105 | return mCaret; | |
| 190 | return mMutator.mCaret; | |
| 106 | 191 | } |
| 107 | 192 | |
| 108 | 193 | /** |
| 109 | * Returns the directory that contains the file being edited. | |
| 110 | * When {@link Constants#DOCUMENT_DEFAULT} is created, the parent path is | |
| 194 | * Returns the directory that contains the file being edited. When | |
| 195 | * {@link Constants#DOCUMENT_DEFAULT} is created, the parent path is | |
| 111 | 196 | * {@code null}. This will get absolute path to the file before trying to |
| 112 | 197 | * get te parent path, which should always be a valid path. In the unlikely |
| ... | ||
| 124 | 209 | |
| 125 | 210 | public Path getDocumentPath() { |
| 126 | return mDocumentPath; | |
| 211 | return mMutator.mInputPath; | |
| 127 | 212 | } |
| 128 | 213 | |
| 129 | 214 | FileType getFileType() { |
| 130 | 215 | return lookup( getDocumentPath() ); |
| 131 | 216 | } |
| 132 | 217 | |
| 133 | 218 | public Workspace getWorkspace() { |
| 134 | return mWorkspace; | |
| 219 | return mMutator.mWorkspace; | |
| 135 | 220 | } |
| 136 | 221 | } |
| 1 | 1 | package com.keenwrite.processors.markdown.extensions; |
| 2 | 2 | |
| 3 | import com.keenwrite.events.ParseHeadingEvent; | |
| 3 | 4 | import com.keenwrite.processors.Processor; |
| 4 | 5 | import com.vladsch.flexmark.ast.Heading; |
| ... | ||
| 15 | 16 | import java.util.regex.Pattern; |
| 16 | 17 | |
| 17 | import static com.keenwrite.events.ParseHeadingEvent.fireNewHeadingEvent; | |
| 18 | 18 | import static com.keenwrite.events.ParseHeadingEvent.fireNewOutlineEvent; |
| 19 | 19 | |
| ... | ||
| 51 | 51 | final var text = heading.substring( level ); |
| 52 | 52 | final var offset = node.getStartOffset(); |
| 53 | fireNewHeadingEvent( level, text, offset ); | |
| 53 | ParseHeadingEvent.fire( level, text, offset ); | |
| 54 | 54 | } |
| 55 | 55 | } |
| 10 | 10 | import com.keenwrite.processors.markdown.extensions.HtmlRendererAdapter; |
| 11 | 11 | import com.vladsch.flexmark.ast.FencedCodeBlock; |
| 12 | import com.vladsch.flexmark.html.HtmlRendererOptions; | |
| 13 | import com.vladsch.flexmark.html.HtmlWriter; | |
| 12 | 14 | import com.vladsch.flexmark.html.renderer.DelegatingNodeRendererFactory; |
| 13 | 15 | import com.vladsch.flexmark.html.renderer.NodeRenderer; |
| 16 | import com.vladsch.flexmark.html.renderer.NodeRendererContext; | |
| 14 | 17 | import com.vladsch.flexmark.html.renderer.NodeRenderingHandler; |
| 15 | 18 | import com.vladsch.flexmark.util.data.DataHolder; |
| ... | ||
| 22 | 25 | import static com.keenwrite.preferences.WorkspaceKeys.KEY_IMAGES_SERVER; |
| 23 | 26 | import static com.vladsch.flexmark.html.HtmlRenderer.Builder; |
| 27 | import static com.vladsch.flexmark.html.renderer.CoreNodeRenderer.CODE_CONTENT; | |
| 24 | 28 | import static com.vladsch.flexmark.html.renderer.LinkType.LINK; |
| 25 | 29 | |
| ... | ||
| 112 | 116 | } |
| 113 | 117 | else { |
| 114 | context.delegateRender(); | |
| 118 | // TODO: Revert to using context.delegateRender() after flexmark | |
| 119 | // is updated to no longer trim blank lines up to the EOL. | |
| 120 | render( node, context, html ); | |
| 115 | 121 | } |
| 116 | 122 | } ) ); |
| 117 | 123 | |
| 118 | 124 | return set; |
| 125 | } | |
| 126 | ||
| 127 | /** | |
| 128 | * This method is a stop-gap because blank lines that contain only | |
| 129 | * whitespace are collapsed into lines without any spaces. Consequently, | |
| 130 | * the typesetting software does not honour the blank lines, which | |
| 131 | * then would otherwise discard blank lines entirely. | |
| 132 | * <p> | |
| 133 | * Given the following: | |
| 134 | * | |
| 135 | * <pre> | |
| 136 | * if( bool ) { | |
| 137 | * | |
| 138 | * | |
| 139 | * } | |
| 140 | * </pre> | |
| 141 | * <p> | |
| 142 | * The typesetter would otherwise render this incorrectly as: | |
| 143 | * | |
| 144 | * <pre> | |
| 145 | * if( bool ) { | |
| 146 | * } | |
| 147 | * </pre> | |
| 148 | * <p> | |
| 149 | */ | |
| 150 | private void render( | |
| 151 | final FencedCodeBlock node, | |
| 152 | final NodeRendererContext context, | |
| 153 | final HtmlWriter html ) { | |
| 154 | assert node != null; | |
| 155 | assert context != null; | |
| 156 | assert html != null; | |
| 157 | ||
| 158 | html.line(); | |
| 159 | html.srcPosWithTrailingEOL( node.getChars() ) | |
| 160 | .withAttr() | |
| 161 | .tag( "pre" ) | |
| 162 | .openPre(); | |
| 163 | ||
| 164 | final var options = context.getHtmlOptions(); | |
| 165 | final var languageClass = lookupLanguageClass( node, options ); | |
| 166 | ||
| 167 | if( !languageClass.isBlank() ) { | |
| 168 | html.attr( "class", languageClass ); | |
| 169 | } | |
| 170 | ||
| 171 | html.srcPosWithEOL( node.getContentChars() ) | |
| 172 | .withAttr( CODE_CONTENT ) | |
| 173 | .tag( "code" ); | |
| 174 | ||
| 175 | final var lines = node.getContentLines(); | |
| 176 | ||
| 177 | for( final var line : lines ) { | |
| 178 | if( line.isBlank() ) { | |
| 179 | html.text( " " ); | |
| 180 | } | |
| 181 | ||
| 182 | html.text( line ); | |
| 183 | } | |
| 184 | ||
| 185 | html.tag( "/code" ); | |
| 186 | html.tag( "/pre" ) | |
| 187 | .closePre(); | |
| 188 | html.lineIf( options.htmlBlockCloseTagEol ); | |
| 189 | } | |
| 190 | ||
| 191 | private String lookupLanguageClass( | |
| 192 | final FencedCodeBlock node, | |
| 193 | final HtmlRendererOptions options ) { | |
| 194 | assert node != null; | |
| 195 | assert options != null; | |
| 196 | ||
| 197 | final var info = node.getInfo(); | |
| 198 | ||
| 199 | if( info.isNotNull() && !info.isBlank() ) { | |
| 200 | final var lang = node | |
| 201 | .getInfoDelimitedByAny( options.languageDelimiterSet ) | |
| 202 | .unescape(); | |
| 203 | return options | |
| 204 | .languageClassMap | |
| 205 | .getOrDefault( lang, options.languageClassPrefix + lang ); | |
| 206 | } | |
| 207 | ||
| 208 | return options.noLanguageClass; | |
| 119 | 209 | } |
| 120 | 210 | } |
| 80 | 80 | int start = 0; |
| 81 | 81 | |
| 82 | // Replace up to 32 occurrences before the string reallocates its buffer. | |
| 82 | // Replace up to 32 occurrences before reallocating the internal buffer. | |
| 83 | 83 | final var sb = new StringBuilder( length + 32 ); |
| 84 | 84 | |
| ... | ||
| 93 | 93 | |
| 94 | 94 | private SigilOperator createSigilOperator( final Workspace workspace ) { |
| 95 | final var tokens = workspace.toTokens( | |
| 95 | final var tokens = workspace.toSigils( | |
| 96 | 96 | KEY_R_DELIM_BEGAN, KEY_R_DELIM_ENDED ); |
| 97 | 97 | final var antecedent = createDefinitionOperator( workspace ); |
| 98 | 98 | return new RSigilOperator( tokens, antecedent ); |
| 99 | 99 | } |
| 100 | 100 | |
| 101 | 101 | private SigilOperator createDefinitionOperator( final Workspace workspace ) { |
| 102 | final var tokens = workspace.toTokens( | |
| 102 | final var sigils = workspace.toSigils( | |
| 103 | 103 | KEY_DEF_DELIM_BEGAN, KEY_DEF_DELIM_ENDED ); |
| 104 | return new YamlSigilOperator( tokens ); | |
| 104 | return new YamlSigilOperator( sigils ); | |
| 105 | 105 | } |
| 106 | 106 | } |
| 22 | 22 | private static final char VALUE_SEPARATOR = ','; |
| 23 | 23 | |
| 24 | private final PropertiesConfiguration mProperties = createProperties(); | |
| 24 | private final PropertiesConfiguration mProperties = loadProperties(); | |
| 25 | 25 | |
| 26 | 26 | public DefaultSettings() { |
| ... | ||
| 86 | 86 | } |
| 87 | 87 | |
| 88 | private PropertiesConfiguration createProperties() { | |
| 88 | private PropertiesConfiguration loadProperties() { | |
| 89 | 89 | final var url = getPropertySource(); |
| 90 | 90 | final var configuration = new PropertiesConfiguration(); |
| ... | ||
| 103 | 103 | } |
| 104 | 104 | |
| 105 | protected Charset getDefaultEncoding() { | |
| 105 | private Charset getDefaultEncoding() { | |
| 106 | 106 | return Charset.defaultCharset(); |
| 107 | 107 | } |
| 108 | 108 | |
| 109 | protected ListDelimiterHandler createListDelimiterHandler() { | |
| 109 | private ListDelimiterHandler createListDelimiterHandler() { | |
| 110 | 110 | return new DefaultListDelimiterHandler( VALUE_SEPARATOR ); |
| 111 | 111 | } |
| 2 | 2 | package com.keenwrite.sigils; |
| 3 | 3 | |
| 4 | import static com.keenwrite.sigils.YamlSigilOperator.KEY_SEPARATOR_DEF; | |
| 5 | ||
| 6 | 4 | /** |
| 7 | 5 | * Brackets variable names between {@link #PREFIX} and {@link #SUFFIX} sigils. |
| 8 | 6 | */ |
| 9 | 7 | public final class RSigilOperator extends SigilOperator { |
| 10 | private static final char KEY_SEPARATOR_R = '$'; | |
| 11 | ||
| 12 | 8 | public static final String PREFIX = "`r#"; |
| 13 | 9 | public static final char SUFFIX = '`'; |
| 10 | ||
| 11 | private static final char KEY_SEPARATOR_DEF = '.'; | |
| 12 | private static final char KEY_SEPARATOR_R = '$'; | |
| 14 | 13 | |
| 15 | 14 | /** |
| ... | ||
| 23 | 22 | * variable names (keys). |
| 24 | 23 | * |
| 25 | * @param tokens The starting and ending tokens. | |
| 24 | * @param sigils The starting and ending tokens. | |
| 26 | 25 | * @param antecedent The operator to use to undo any previous entokenizing. |
| 27 | 26 | */ |
| 28 | public RSigilOperator( final Tokens tokens, final SigilOperator antecedent ) { | |
| 29 | super( tokens ); | |
| 27 | public RSigilOperator( final Sigils sigils, final SigilOperator antecedent ) { | |
| 28 | super( sigils ); | |
| 30 | 29 | |
| 31 | 30 | mAntecedent = antecedent; |
| 2 | 2 | package com.keenwrite.sigils; |
| 3 | 3 | |
| 4 | import javafx.beans.property.SimpleStringProperty; | |
| 5 | ||
| 4 | 6 | import java.util.function.UnaryOperator; |
| 5 | 7 | |
| 6 | 8 | /** |
| 7 | 9 | * Responsible for updating definition keys to use a machine-readable format |
| 8 | 10 | * corresponding to the type of file being edited. This changes a definition |
| 9 | 11 | * key name based on some criteria determined by the factory that creates |
| 10 | 12 | * implementations of this interface. |
| 11 | 13 | */ |
| 12 | public abstract class SigilOperator implements UnaryOperator<String> { | |
| 13 | private final Tokens mTokens; | |
| 14 | public class SigilOperator implements UnaryOperator<String> { | |
| 15 | private final Sigils mSigils; | |
| 14 | 16 | |
| 15 | SigilOperator( final Tokens tokens ) { | |
| 16 | mTokens = tokens; | |
| 17 | /** | |
| 18 | * Defines a new {@link SigilOperator} with the given sigils. | |
| 19 | * | |
| 20 | * @param began The sigil that denotes the start of a variable name. | |
| 21 | * @param ended The sigil that denotes the end of a variable name. | |
| 22 | */ | |
| 23 | public SigilOperator( final String began, final String ended ) { | |
| 24 | this( new Sigils( | |
| 25 | new SimpleStringProperty( began ), | |
| 26 | new SimpleStringProperty( ended ) | |
| 27 | ) ); | |
| 28 | } | |
| 29 | ||
| 30 | SigilOperator( final Sigils sigils ) { | |
| 31 | mSigils = sigils; | |
| 32 | } | |
| 33 | ||
| 34 | /** | |
| 35 | * Returns the given {@link String} verbatim. Different implementations | |
| 36 | * can override to inject custom behaviours. | |
| 37 | * | |
| 38 | * @param key Returned verbatim. | |
| 39 | */ | |
| 40 | @Override | |
| 41 | public String apply( final String key ) { | |
| 42 | return key; | |
| 43 | } | |
| 44 | ||
| 45 | /** | |
| 46 | * Wraps the given key in the began and ended tokens. This may perform any | |
| 47 | * preprocessing necessary to ensure the transformation happens. | |
| 48 | * | |
| 49 | * @param key The variable name to transform. | |
| 50 | * @return The given key with before/after sigils to delimit the key name. | |
| 51 | */ | |
| 52 | public String entoken( final String key ) { | |
| 53 | assert key != null; | |
| 54 | return getBegan() + key + getEnded(); | |
| 17 | 55 | } |
| 18 | 56 | |
| ... | ||
| 25 | 63 | * @return The given key with the delimiters removed. |
| 26 | 64 | */ |
| 27 | String detoken( final String key ) { | |
| 65 | public String detoken( final String key ) { | |
| 28 | 66 | return key; |
| 67 | } | |
| 68 | ||
| 69 | public Sigils getSigils() { | |
| 70 | return mSigils; | |
| 29 | 71 | } |
| 30 | 72 | |
| 31 | 73 | String getBegan() { |
| 32 | return mTokens.getBegan(); | |
| 74 | return mSigils.getBegan(); | |
| 33 | 75 | } |
| 34 | 76 | |
| 35 | 77 | String getEnded() { |
| 36 | return mTokens.getEnded(); | |
| 78 | return mSigils.getEnded(); | |
| 37 | 79 | } |
| 38 | ||
| 39 | /** | |
| 40 | * Wraps the given key in the began and ended tokens. This may perform any | |
| 41 | * preprocessing necessary to ensure the transformation happens. | |
| 42 | * | |
| 43 | * @param key The variable name to transform. | |
| 44 | * @return The given key with tokens to delimit it (from the edited text). | |
| 45 | */ | |
| 46 | public abstract String entoken( final String key ); | |
| 47 | 80 | } |
| 48 | 81 | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.sigils; | |
| 3 | ||
| 4 | import javafx.beans.property.StringProperty; | |
| 5 | ||
| 6 | import java.util.AbstractMap.SimpleImmutableEntry; | |
| 7 | ||
| 8 | /** | |
| 9 | * Convenience class for pairing a start and an end sigil together. | |
| 10 | */ | |
| 11 | public final class Sigils | |
| 12 | extends SimpleImmutableEntry<StringProperty, StringProperty> { | |
| 13 | ||
| 14 | /** | |
| 15 | * Associates a new key-value pair. | |
| 16 | * | |
| 17 | * @param began The starting sigil. | |
| 18 | * @param ended The ending sigil. | |
| 19 | */ | |
| 20 | public Sigils( final StringProperty began, final StringProperty ended ) { | |
| 21 | super( began, ended ); | |
| 22 | } | |
| 23 | ||
| 24 | /** | |
| 25 | * @return The opening sigil token. | |
| 26 | */ | |
| 27 | public String getBegan() { | |
| 28 | return getKey().get(); | |
| 29 | } | |
| 30 | ||
| 31 | /** | |
| 32 | * @return The closing sigil token, or the empty string if none set. | |
| 33 | */ | |
| 34 | public String getEnded() { | |
| 35 | return getValue().get(); | |
| 36 | } | |
| 37 | } | |
| 1 | 38 |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.sigils; | |
| 3 | ||
| 4 | import javafx.beans.property.StringProperty; | |
| 5 | ||
| 6 | import java.util.AbstractMap.SimpleImmutableEntry; | |
| 7 | ||
| 8 | /** | |
| 9 | * Convenience class for pairing a start and an end sigil together. | |
| 10 | */ | |
| 11 | public final class Tokens | |
| 12 | extends SimpleImmutableEntry<StringProperty, StringProperty> { | |
| 13 | ||
| 14 | /** | |
| 15 | * Associates a new key-value pair. | |
| 16 | * | |
| 17 | * @param began The starting sigil. | |
| 18 | * @param ended The ending sigil. | |
| 19 | */ | |
| 20 | public Tokens( final StringProperty began, final StringProperty ended ) { | |
| 21 | super( began, ended ); | |
| 22 | } | |
| 23 | ||
| 24 | /** | |
| 25 | * @return The opening sigil token. | |
| 26 | */ | |
| 27 | public String getBegan() { | |
| 28 | return getKey().get(); | |
| 29 | } | |
| 30 | ||
| 31 | /** | |
| 32 | * @return The closing sigil token, or the empty string if none set. | |
| 33 | */ | |
| 34 | public String getEnded() { | |
| 35 | return getValue().get(); | |
| 36 | } | |
| 37 | } | |
| 38 | 1 |
| 3 | 3 | |
| 4 | 4 | /** |
| 5 | * Brackets definition keys with token delimiters. | |
| 5 | * Responsible for bracketing definition keys with token delimiters. | |
| 6 | 6 | */ |
| 7 | 7 | public final class YamlSigilOperator extends SigilOperator { |
| 8 | public static final char KEY_SEPARATOR_DEF = '.'; | |
| 9 | ||
| 10 | public YamlSigilOperator( final Tokens tokens ) { | |
| 11 | super( tokens ); | |
| 12 | } | |
| 13 | ||
| 14 | /** | |
| 15 | * Returns the given {@link String} verbatim because variables in YAML | |
| 16 | * documents and plain Markdown documents already have the appropriate | |
| 17 | * tokenizable syntax wrapped around the text. | |
| 18 | * | |
| 19 | * @param key Returned verbatim. | |
| 20 | */ | |
| 21 | @Override | |
| 22 | public String apply( final String key ) { | |
| 23 | return key; | |
| 24 | } | |
| 25 | ||
| 26 | /** | |
| 27 | * Adds delimiters to the given key. | |
| 28 | * | |
| 29 | * @param key The key to adorn with start and stop definition tokens. | |
| 30 | * @return The given key bracketed by definition token symbols. | |
| 31 | */ | |
| 32 | public String entoken( final String key ) { | |
| 33 | assert key != null; | |
| 34 | return getBegan() + key + getEnded(); | |
| 8 | public YamlSigilOperator( final Sigils sigils ) { | |
| 9 | super( sigils ); | |
| 35 | 10 | } |
| 36 | 11 |
| 154 | 154 | |
| 155 | 155 | while( (line = reader.readLine()) != null ) { |
| 156 | final String[] tokens = line.split( "\\t" ); | |
| 156 | final var tokens = line.split( "\\t" ); | |
| 157 | 157 | map.put( tokens[ 0 ], parseLong( tokens[ 1 ] ) ); |
| 158 | 158 | } |
| 54 | 54 | * successful. |
| 55 | 55 | * |
| 56 | * @param in The input document to typeset. | |
| 57 | * @param out Path to the finished typeset document. | |
| 56 | * @param inputPath The input document to typeset. | |
| 57 | * @param outputPath Path to the finished typeset document. | |
| 58 | 58 | * @throws IOException If the process could not be started. |
| 59 | 59 | * @throws InterruptedException If the process was killed. |
| 60 | 60 | * @throws TypesetterNotFoundException When no typesetter is along the PATH. |
| 61 | 61 | */ |
| 62 | public void typeset( final Path in, final Path out ) | |
| 62 | public void typeset( final Path inputPath, final Path outputPath ) | |
| 63 | 63 | throws IOException, InterruptedException, TypesetterNotFoundException { |
| 64 | 64 | if( TYPESETTER.canRun() ) { |
| 65 | clue( "Main.status.typeset.began", out ); | |
| 66 | final var task = new TypesetTask( in, out ); | |
| 65 | clue( "Main.status.typeset.began", outputPath ); | |
| 66 | final var task = new TypesetTask( inputPath, outputPath ); | |
| 67 | 67 | final var time = currentTimeMillis(); |
| 68 | 68 | final var success = task.typeset(); |
| 69 | 69 | |
| 70 | 70 | clue( "Main.status.typeset.ended." + (success ? "success" : "failure"), |
| 71 | out, since( time ) | |
| 71 | outputPath, since( time ) | |
| 72 | 72 | ); |
| 73 | 73 | } |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.ui.actions; | |
| 3 | ||
| 4 | import com.keenwrite.ExportFormat; | |
| 5 | import com.keenwrite.MainPane; | |
| 6 | import com.keenwrite.MainScene; | |
| 7 | import com.keenwrite.editors.TextDefinition; | |
| 8 | import com.keenwrite.editors.TextEditor; | |
| 9 | import com.keenwrite.editors.markdown.HyperlinkModel; | |
| 10 | import com.keenwrite.editors.markdown.LinkVisitor; | |
| 11 | import com.keenwrite.events.ExportFailedEvent; | |
| 12 | import com.keenwrite.preferences.PreferencesController; | |
| 13 | import com.keenwrite.preferences.Workspace; | |
| 14 | import com.keenwrite.processors.markdown.MarkdownProcessor; | |
| 15 | import com.keenwrite.search.SearchModel; | |
| 16 | import com.keenwrite.typesetting.Typesetter; | |
| 17 | import com.keenwrite.ui.controls.SearchBar; | |
| 18 | import com.keenwrite.ui.dialogs.ImageDialog; | |
| 19 | import com.keenwrite.ui.dialogs.LinkDialog; | |
| 20 | import com.keenwrite.ui.dialogs.ThemePicker; | |
| 21 | import com.keenwrite.ui.explorer.FilePicker; | |
| 22 | import com.keenwrite.ui.explorer.FilePickerFactory; | |
| 23 | import com.keenwrite.ui.logging.LogView; | |
| 24 | import com.keenwrite.util.AlphanumComparator; | |
| 25 | import com.vladsch.flexmark.ast.Link; | |
| 26 | import javafx.concurrent.Task; | |
| 27 | import javafx.scene.control.Alert; | |
| 28 | import javafx.scene.control.Dialog; | |
| 29 | import javafx.stage.Window; | |
| 30 | import javafx.stage.WindowEvent; | |
| 31 | ||
| 32 | import java.io.File; | |
| 33 | import java.io.IOException; | |
| 34 | import java.nio.file.Path; | |
| 35 | import java.util.ArrayList; | |
| 36 | import java.util.List; | |
| 37 | import java.util.Optional; | |
| 38 | import java.util.concurrent.ExecutorService; | |
| 39 | ||
| 40 | import static com.keenwrite.Bootstrap.*; | |
| 41 | import static com.keenwrite.ExportFormat.*; | |
| 42 | import static com.keenwrite.Messages.get; | |
| 43 | import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG_NODE; | |
| 44 | import static com.keenwrite.events.StatusEvent.clue; | |
| 45 | import static com.keenwrite.preferences.WorkspaceKeys.KEY_TYPESET_CONTEXT_THEMES_PATH; | |
| 46 | import static com.keenwrite.preferences.WorkspaceKeys.KEY_TYPESET_CONTEXT_THEME_SELECTION; | |
| 47 | import static com.keenwrite.processors.ProcessorFactory.createProcessors; | |
| 48 | import static com.keenwrite.ui.explorer.FilePickerFactory.Options; | |
| 49 | import static com.keenwrite.ui.explorer.FilePickerFactory.Options.*; | |
| 50 | import static com.keenwrite.util.FileWalker.walk; | |
| 51 | import static java.nio.file.Files.readString; | |
| 52 | import static java.nio.file.Files.writeString; | |
| 53 | import static java.util.concurrent.Executors.newFixedThreadPool; | |
| 54 | import static javafx.application.Platform.runLater; | |
| 55 | import static javafx.event.Event.fireEvent; | |
| 56 | import static javafx.scene.control.Alert.AlertType.INFORMATION; | |
| 57 | import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST; | |
| 58 | import static org.apache.commons.io.FilenameUtils.getExtension; | |
| 59 | ||
| 60 | /** | |
| 61 | * Responsible for abstracting how functionality is mapped to the application. | |
| 62 | * This allows users to customize accelerator keys and will provide pluggable | |
| 63 | * functionality so that different text markup languages can change documents | |
| 64 | * using their respective syntax. | |
| 65 | */ | |
| 66 | public final class ApplicationActions { | |
| 67 | private static final ExecutorService sExecutor = newFixedThreadPool( 1 ); | |
| 68 | ||
| 69 | private static final String STYLE_SEARCH = "search"; | |
| 70 | ||
| 71 | /** | |
| 72 | * Sci-fi genres, which are can be longer than other genres, typically fall | |
| 73 | * below 150,000 words at 6 chars per word. This reduces re-allocations of | |
| 74 | * memory when concatenating files together when exporting novels. | |
| 75 | */ | |
| 76 | private static final int DOCUMENT_LENGTH = 150_000 * 6; | |
| 77 | ||
| 78 | /** | |
| 79 | * When an action is executed, this is one of the recipients. | |
| 80 | */ | |
| 81 | private final MainPane mMainPane; | |
| 82 | ||
| 83 | private final MainScene mMainScene; | |
| 84 | ||
| 85 | private final LogView mLogView; | |
| 86 | ||
| 87 | /** | |
| 88 | * Tracks finding text in the active document. | |
| 89 | */ | |
| 90 | private final SearchModel mSearchModel; | |
| 91 | ||
| 92 | public ApplicationActions( final MainScene scene, final MainPane pane ) { | |
| 93 | mMainScene = scene; | |
| 94 | mMainPane = pane; | |
| 95 | mLogView = new LogView(); | |
| 96 | mSearchModel = new SearchModel(); | |
| 97 | mSearchModel.matchOffsetProperty().addListener( ( c, o, n ) -> { | |
| 98 | final var editor = getActiveTextEditor(); | |
| 99 | ||
| 100 | // Clear highlighted areas before highlighting a new region. | |
| 101 | if( o != null ) { | |
| 102 | editor.unstylize( STYLE_SEARCH ); | |
| 103 | } | |
| 104 | ||
| 105 | if( n != null ) { | |
| 106 | editor.moveTo( n.getStart() ); | |
| 107 | editor.stylize( n, STYLE_SEARCH ); | |
| 108 | } | |
| 109 | } ); | |
| 110 | ||
| 111 | // When the active text editor changes, update the haystack. | |
| 112 | mMainPane.activeTextEditorProperty().addListener( | |
| 113 | ( c, o, n ) -> mSearchModel.search( getActiveTextEditor().getText() ) | |
| 114 | ); | |
| 115 | } | |
| 116 | ||
| 117 | public void file_new() { | |
| 118 | getMainPane().newTextEditor(); | |
| 119 | } | |
| 120 | ||
| 121 | public void file_open() { | |
| 122 | pickFiles( FILE_OPEN_MULTIPLE ).ifPresent( l -> getMainPane().open( l ) ); | |
| 123 | } | |
| 124 | ||
| 125 | public void file_close() { | |
| 126 | getMainPane().close(); | |
| 127 | } | |
| 128 | ||
| 129 | public void file_close_all() { | |
| 130 | getMainPane().closeAll(); | |
| 131 | } | |
| 132 | ||
| 133 | public void file_save() { | |
| 134 | getMainPane().save(); | |
| 135 | } | |
| 136 | ||
| 137 | public void file_save_as() { | |
| 138 | pickFiles( FILE_SAVE_AS ).ifPresent( l -> getMainPane().saveAs( l ) ); | |
| 139 | } | |
| 140 | ||
| 141 | public void file_save_all() { | |
| 142 | getMainPane().saveAll(); | |
| 143 | } | |
| 144 | ||
| 145 | /** | |
| 146 | * Converts the actively edited file in the given file format. | |
| 147 | * | |
| 148 | * @param format The destination file format. | |
| 149 | */ | |
| 150 | private void file_export( final ExportFormat format ) { | |
| 151 | file_export( format, false ); | |
| 152 | } | |
| 153 | ||
| 154 | /** | |
| 155 | * Converts one or more files into the given file format. If {@code dir} | |
| 156 | * is set to true, this will first append all files in the same directory | |
| 157 | * as the actively edited file. | |
| 158 | * | |
| 159 | * @param format The destination file format. | |
| 160 | * @param dir Export all files in the actively edited file's directory. | |
| 161 | */ | |
| 162 | private void file_export( final ExportFormat format, final boolean dir ) { | |
| 163 | final var main = getMainPane(); | |
| 164 | final var editor = main.getActiveTextEditor(); | |
| 165 | final var filename = format.toExportFilename( editor.getPath() ); | |
| 166 | final var selection = pickFiles( filename, FILE_EXPORT ); | |
| 167 | ||
| 168 | selection.ifPresent( ( files ) -> { | |
| 169 | final var file = files.get( 0 ); | |
| 170 | final var path = file.toPath(); | |
| 171 | final var document = dir ? append( editor ) : editor.getText(); | |
| 172 | final var context = main.createProcessorContext( path, format ); | |
| 173 | ||
| 174 | final var task = new Task<Path>() { | |
| 175 | @Override | |
| 176 | protected Path call() throws Exception { | |
| 177 | final var chain = createProcessors( context ); | |
| 178 | final var export = chain.apply( document ); | |
| 179 | ||
| 180 | // Processors can export binary files. In such cases, processors | |
| 181 | // return null to prevent further processing. | |
| 182 | return export == null ? null : writeString( path, export ); | |
| 183 | } | |
| 184 | }; | |
| 185 | ||
| 186 | task.setOnSucceeded( | |
| 187 | e -> { | |
| 188 | final var result = task.getValue(); | |
| 189 | ||
| 190 | // Binary formats must notify users of success independently. | |
| 191 | if( result != null ) { | |
| 192 | clue( "Main.status.export.success", result ); | |
| 193 | } | |
| 194 | } | |
| 195 | ); | |
| 196 | ||
| 197 | task.setOnFailed( e -> { | |
| 198 | final var ex = task.getException(); | |
| 199 | clue( ex ); | |
| 200 | ||
| 201 | if( ex instanceof TypeNotPresentException ) { | |
| 202 | fireExportFailedEvent(); | |
| 203 | } | |
| 204 | } ); | |
| 205 | ||
| 206 | sExecutor.execute( task ); | |
| 207 | } ); | |
| 208 | } | |
| 209 | ||
| 210 | /** | |
| 211 | * @param dir {@code true} means to export all files in the active file | |
| 212 | * editor's directory; {@code false} means to export only the | |
| 213 | * actively edited file. | |
| 214 | */ | |
| 215 | private void file_export_pdf( final boolean dir ) { | |
| 216 | final var workspace = getWorkspace(); | |
| 217 | final var themes = workspace.toFile( KEY_TYPESET_CONTEXT_THEMES_PATH ); | |
| 218 | final var theme = workspace.stringProperty( | |
| 219 | KEY_TYPESET_CONTEXT_THEME_SELECTION ); | |
| 220 | ||
| 221 | if( Typesetter.canRun() ) { | |
| 222 | // If the typesetter is installed, allow the user to select a theme. If | |
| 223 | // the themes aren't installed, a status message will appear. | |
| 224 | if( ThemePicker.choose( themes, theme ) ) { | |
| 225 | file_export( APPLICATION_PDF, dir ); | |
| 226 | } | |
| 227 | } | |
| 228 | else { | |
| 229 | fireExportFailedEvent(); | |
| 230 | } | |
| 231 | } | |
| 232 | ||
| 233 | public void file_export_pdf() { | |
| 234 | file_export_pdf( false ); | |
| 235 | } | |
| 236 | ||
| 237 | public void file_export_pdf_dir() { | |
| 238 | file_export_pdf( true ); | |
| 239 | } | |
| 240 | ||
| 241 | public void file_export_html_svg() { | |
| 242 | file_export( HTML_TEX_SVG ); | |
| 243 | } | |
| 244 | ||
| 245 | public void file_export_html_tex() { | |
| 246 | file_export( HTML_TEX_DELIMITED ); | |
| 247 | } | |
| 248 | ||
| 249 | public void file_export_xhtml_tex() { | |
| 250 | file_export( XHTML_TEX ); | |
| 251 | } | |
| 252 | ||
| 253 | public void file_export_markdown() { | |
| 254 | file_export( MARKDOWN_PLAIN ); | |
| 255 | } | |
| 256 | ||
| 257 | private void fireExportFailedEvent() { | |
| 258 | runLater( ExportFailedEvent::fireExportFailedEvent ); | |
| 259 | } | |
| 260 | ||
| 261 | public void file_exit() { | |
| 262 | final var window = getWindow(); | |
| 263 | fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) ); | |
| 264 | } | |
| 265 | ||
| 266 | public void edit_undo() { | |
| 267 | getActiveTextEditor().undo(); | |
| 268 | } | |
| 269 | ||
| 270 | public void edit_redo() { | |
| 271 | getActiveTextEditor().redo(); | |
| 272 | } | |
| 273 | ||
| 274 | public void edit_cut() { | |
| 275 | getActiveTextEditor().cut(); | |
| 276 | } | |
| 277 | ||
| 278 | public void edit_copy() { | |
| 279 | getActiveTextEditor().copy(); | |
| 280 | } | |
| 281 | ||
| 282 | public void edit_paste() { | |
| 283 | getActiveTextEditor().paste(); | |
| 284 | } | |
| 285 | ||
| 286 | public void edit_select_all() { | |
| 287 | getActiveTextEditor().selectAll(); | |
| 288 | } | |
| 289 | ||
| 290 | public void edit_find() { | |
| 291 | final var nodes = getMainScene().getStatusBar().getLeftItems(); | |
| 292 | ||
| 293 | if( nodes.isEmpty() ) { | |
| 294 | final var searchBar = new SearchBar(); | |
| 295 | ||
| 296 | searchBar.matchIndexProperty().bind( mSearchModel.matchIndexProperty() ); | |
| 297 | searchBar.matchCountProperty().bind( mSearchModel.matchCountProperty() ); | |
| 298 | ||
| 299 | searchBar.setOnCancelAction( ( event ) -> { | |
| 300 | final var editor = getActiveTextEditor(); | |
| 301 | nodes.remove( searchBar ); | |
| 302 | editor.unstylize( STYLE_SEARCH ); | |
| 303 | editor.getNode().requestFocus(); | |
| 304 | } ); | |
| 305 | ||
| 306 | searchBar.addInputListener( ( c, o, n ) -> { | |
| 307 | if( n != null && !n.isEmpty() ) { | |
| 308 | mSearchModel.search( n, getActiveTextEditor().getText() ); | |
| 309 | } | |
| 310 | } ); | |
| 311 | ||
| 312 | searchBar.setOnNextAction( ( event ) -> edit_find_next() ); | |
| 313 | searchBar.setOnPrevAction( ( event ) -> edit_find_prev() ); | |
| 314 | ||
| 315 | nodes.add( searchBar ); | |
| 316 | searchBar.requestFocus(); | |
| 317 | } | |
| 318 | else { | |
| 319 | nodes.clear(); | |
| 320 | } | |
| 321 | } | |
| 322 | ||
| 323 | public void edit_find_next() { | |
| 324 | mSearchModel.advance(); | |
| 325 | } | |
| 326 | ||
| 327 | public void edit_find_prev() { | |
| 328 | mSearchModel.retreat(); | |
| 329 | } | |
| 330 | ||
| 331 | public void edit_preferences() { | |
| 332 | try { | |
| 333 | new PreferencesController( getWorkspace() ).show(); | |
| 334 | } catch( final Exception ex ) { | |
| 335 | clue( ex ); | |
| 336 | } | |
| 337 | } | |
| 338 | ||
| 339 | public void format_bold() { | |
| 340 | getActiveTextEditor().bold(); | |
| 341 | } | |
| 342 | ||
| 343 | public void format_italic() { | |
| 344 | getActiveTextEditor().italic(); | |
| 345 | } | |
| 346 | ||
| 347 | public void format_monospace() { | |
| 348 | getActiveTextEditor().monospace(); | |
| 349 | } | |
| 350 | ||
| 351 | public void format_superscript() { | |
| 352 | getActiveTextEditor().superscript(); | |
| 353 | } | |
| 354 | ||
| 355 | public void format_subscript() { | |
| 356 | getActiveTextEditor().subscript(); | |
| 357 | } | |
| 358 | ||
| 359 | public void format_strikethrough() { | |
| 360 | getActiveTextEditor().strikethrough(); | |
| 361 | } | |
| 362 | ||
| 363 | public void insert_blockquote() { | |
| 364 | getActiveTextEditor().blockquote(); | |
| 365 | } | |
| 366 | ||
| 367 | public void insert_code() { | |
| 368 | getActiveTextEditor().code(); | |
| 369 | } | |
| 370 | ||
| 371 | public void insert_fenced_code_block() { | |
| 372 | getActiveTextEditor().fencedCodeBlock(); | |
| 373 | } | |
| 374 | ||
| 375 | public void insert_link() { | |
| 376 | insertObject( createLinkDialog() ); | |
| 377 | } | |
| 378 | ||
| 379 | public void insert_image() { | |
| 380 | insertObject( createImageDialog() ); | |
| 381 | } | |
| 382 | ||
| 383 | private void insertObject( final Dialog<String> dialog ) { | |
| 384 | final var textArea = getActiveTextEditor().getTextArea(); | |
| 385 | dialog.showAndWait().ifPresent( textArea::replaceSelection ); | |
| 386 | } | |
| 387 | ||
| 388 | private Dialog<String> createLinkDialog() { | |
| 389 | return new LinkDialog( getWindow(), createHyperlinkModel() ); | |
| 390 | } | |
| 391 | ||
| 392 | private Dialog<String> createImageDialog() { | |
| 393 | final var path = getActiveTextEditor().getPath(); | |
| 394 | final var parentDir = path.getParent(); | |
| 395 | return new ImageDialog( getWindow(), parentDir ); | |
| 396 | } | |
| 397 | ||
| 398 | /** | |
| 399 | * Returns one of: selected text, word under cursor, or parsed hyperlink from | |
| 400 | * the Markdown AST. | |
| 401 | * | |
| 402 | * @return An instance containing the link URL and display text. | |
| 403 | */ | |
| 404 | private HyperlinkModel createHyperlinkModel() { | |
| 405 | final var context = getMainPane().createProcessorContext(); | |
| 406 | final var editor = getActiveTextEditor(); | |
| 407 | final var textArea = editor.getTextArea(); | |
| 408 | final var selectedText = textArea.getSelectedText(); | |
| 409 | ||
| 410 | // Convert current paragraph to Markdown nodes. | |
| 411 | final var mp = MarkdownProcessor.create( context ); | |
| 412 | final var p = textArea.getCurrentParagraph(); | |
| 413 | final var paragraph = textArea.getText( p ); | |
| 414 | final var node = mp.toNode( paragraph ); | |
| 415 | final var visitor = new LinkVisitor( textArea.getCaretColumn() ); | |
| 416 | final var link = visitor.process( node ); | |
| 417 | ||
| 418 | if( link != null ) { | |
| 419 | textArea.selectRange( p, link.getStartOffset(), p, link.getEndOffset() ); | |
| 420 | } | |
| 421 | ||
| 422 | return createHyperlinkModel( link, selectedText ); | |
| 423 | } | |
| 424 | ||
| 425 | private HyperlinkModel createHyperlinkModel( | |
| 426 | final Link link, final String selection ) { | |
| 427 | ||
| 428 | return link == null | |
| 429 | ? new HyperlinkModel( selection, "https://localhost" ) | |
| 430 | : new HyperlinkModel( link ); | |
| 431 | } | |
| 432 | ||
| 433 | public void insert_heading_1() { | |
| 434 | insert_heading( 1 ); | |
| 435 | } | |
| 436 | ||
| 437 | public void insert_heading_2() { | |
| 438 | insert_heading( 2 ); | |
| 439 | } | |
| 440 | ||
| 441 | public void insert_heading_3() { | |
| 442 | insert_heading( 3 ); | |
| 443 | } | |
| 444 | ||
| 445 | private void insert_heading( final int level ) { | |
| 446 | getActiveTextEditor().heading( level ); | |
| 447 | } | |
| 448 | ||
| 449 | public void insert_unordered_list() { | |
| 450 | getActiveTextEditor().unorderedList(); | |
| 451 | } | |
| 452 | ||
| 453 | public void insert_ordered_list() { | |
| 454 | getActiveTextEditor().orderedList(); | |
| 455 | } | |
| 456 | ||
| 457 | public void insert_horizontal_rule() { | |
| 458 | getActiveTextEditor().horizontalRule(); | |
| 459 | } | |
| 460 | ||
| 461 | public void definition_create() { | |
| 462 | getActiveTextDefinition().createDefinition(); | |
| 463 | } | |
| 464 | ||
| 465 | public void definition_rename() { | |
| 466 | getActiveTextDefinition().renameDefinition(); | |
| 467 | } | |
| 468 | ||
| 469 | public void definition_delete() { | |
| 470 | getActiveTextDefinition().deleteDefinitions(); | |
| 471 | } | |
| 472 | ||
| 473 | public void definition_autoinsert() { | |
| 474 | getMainPane().autoinsert(); | |
| 475 | } | |
| 476 | ||
| 477 | public void view_refresh() { | |
| 478 | getMainPane().viewRefresh(); | |
| 479 | } | |
| 480 | ||
| 481 | public void view_preview() { | |
| 482 | getMainPane().viewPreview(); | |
| 483 | } | |
| 484 | ||
| 485 | public void view_outline() { | |
| 486 | getMainPane().viewOutline(); | |
| 487 | } | |
| 488 | ||
| 489 | public void view_files() { getMainPane().viewFiles(); } | |
| 490 | ||
| 491 | public void view_statistics() { | |
| 492 | getMainPane().viewStatistics(); | |
| 493 | } | |
| 494 | ||
| 495 | public void view_menubar() { | |
| 496 | getMainScene().toggleMenuBar(); | |
| 497 | } | |
| 498 | ||
| 499 | public void view_toolbar() { | |
| 500 | getMainScene().toggleToolBar(); | |
| 501 | } | |
| 502 | ||
| 503 | public void view_statusbar() { | |
| 504 | getMainScene().toggleStatusBar(); | |
| 505 | } | |
| 506 | ||
| 507 | public void view_log() { | |
| 508 | mLogView.view(); | |
| 509 | } | |
| 510 | ||
| 511 | public void help_about() { | |
| 512 | final var alert = new Alert( INFORMATION ); | |
| 513 | final var prefix = "Dialog.about."; | |
| 514 | alert.setTitle( get( prefix + "title", APP_TITLE ) ); | |
| 515 | alert.setHeaderText( get( prefix + "header", APP_TITLE ) ); | |
| 516 | alert.setContentText( get( prefix + "content", APP_YEAR, APP_VERSION ) ); | |
| 517 | alert.setGraphic( ICON_DIALOG_NODE ); | |
| 518 | alert.initOwner( getWindow() ); | |
| 519 | alert.showAndWait(); | |
| 520 | } | |
| 521 | ||
| 522 | /** | |
| 523 | * Concatenates all the files in the same directory as the given file into | |
| 524 | * a string. The extension is determined by the given file name pattern; the | |
| 525 | * order files are concatenated is based on their numeric sort order (this | |
| 526 | * avoids lexicographic sorting). | |
| 527 | * <p> | |
| 528 | * If the parent path to the file being edited in the text editor cannot | |
| 529 | * be found then this will return the editor's text, without iterating through | |
| 530 | * the parent directory. (Should never happen, but who knows?) | |
| 531 | * </p> | |
| 532 | * <p> | |
| 533 | * New lines are automatically appended to separate each file. | |
| 534 | * </p> | |
| 535 | * | |
| 536 | * @param editor The text editor containing | |
| 537 | * @return All files in the same directory as the file being edited | |
| 538 | * concatenated into a single string. | |
| 539 | */ | |
| 540 | private String append( final TextEditor editor ) { | |
| 541 | final var pattern = editor.getPath(); | |
| 542 | final var parent = pattern.getParent(); | |
| 543 | ||
| 544 | // Short-circuit because nothing else can be done. | |
| 545 | if( parent == null ) { | |
| 546 | clue( "Main.status.export.concat.parent", pattern ); | |
| 547 | return editor.getText(); | |
| 548 | } | |
| 549 | ||
| 550 | final var filename = pattern.getFileName().toString(); | |
| 551 | final var extension = getExtension( filename ); | |
| 552 | ||
| 553 | if( extension == null || extension.isBlank() ) { | |
| 554 | clue( "Main.status.export.concat.extension", filename ); | |
| 555 | return editor.getText(); | |
| 556 | } | |
| 557 | ||
| 558 | try { | |
| 559 | final var glob = "**/*." + extension; | |
| 560 | final ArrayList<Path> files = new ArrayList<>(); | |
| 561 | walk( parent, glob, files::add ); | |
| 562 | files.sort( new AlphanumComparator<>() ); | |
| 563 | ||
| 564 | final var text = new StringBuilder( DOCUMENT_LENGTH ); | |
| 565 | ||
| 566 | files.forEach( ( file ) -> { | |
| 567 | try { | |
| 568 | clue( "Main.status.export.concat", file ); | |
| 569 | text.append( readString( file ) ); | |
| 570 | } catch( final IOException ex ) { | |
| 571 | clue( "Main.status.export.concat.io", file ); | |
| 572 | } | |
| 573 | } ); | |
| 574 | ||
| 575 | return text.toString(); | |
| 576 | } catch( final Throwable t ) { | |
| 577 | clue( t ); | |
| 578 | return editor.getText(); | |
| 579 | } | |
| 580 | } | |
| 581 | ||
| 582 | private Optional<List<File>> pickFiles( final Options... options ) { | |
| 583 | return createPicker( options ).choose(); | |
| 584 | } | |
| 585 | ||
| 586 | private Optional<List<File>> pickFiles( | |
| 587 | final File filename, final Options... options ) { | |
| 588 | final var picker = createPicker( options ); | |
| 589 | picker.setInitialFilename( filename ); | |
| 590 | return picker.choose(); | |
| 591 | } | |
| 592 | ||
| 593 | private FilePicker createPicker( final Options... options ) { | |
| 594 | final var factory = new FilePickerFactory( getWorkspace() ); | |
| 595 | return factory.createModal( getWindow(), options ); | |
| 596 | } | |
| 597 | ||
| 598 | private TextEditor getActiveTextEditor() { | |
| 599 | return getMainPane().getActiveTextEditor(); | |
| 600 | } | |
| 601 | ||
| 602 | private TextDefinition getActiveTextDefinition() { | |
| 603 | return getMainPane().getActiveTextDefinition(); | |
| 604 | } | |
| 605 | ||
| 606 | private MainScene getMainScene() { | |
| 607 | return mMainScene; | |
| 608 | } | |
| 609 | ||
| 610 | private MainPane getMainPane() { | |
| 611 | return mMainPane; | |
| 612 | } | |
| 613 | ||
| 614 | private Workspace getWorkspace() { | |
| 615 | return mMainPane.getWorkspace(); | |
| 616 | } | |
| 617 | ||
| 618 | private Window getWindow() { | |
| 619 | return getMainPane().getWindow(); | |
| 620 | } | |
| 621 | } | |
| 622 | 1 |
| 34 | 34 | * Creates the main application affordances. |
| 35 | 35 | * |
| 36 | * @param actions The {@link ApplicationActions} that map user interface | |
| 36 | * @param actions The {@link GuiCommands} that map user interface | |
| 37 | 37 | * selections to executable code. |
| 38 | 38 | * @return An instance of {@link MenuBar} that contains the menu. |
| 39 | 39 | */ |
| 40 | public static MenuBar createMenuBar( final ApplicationActions actions ) { | |
| 40 | public static MenuBar createMenuBar( final GuiCommands actions ) { | |
| 41 | 41 | final var SEPARATOR_ACTION = new SeparatorAction(); |
| 42 | 42 |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.ui.actions; | |
| 3 | ||
| 4 | import com.keenwrite.ExportFormat; | |
| 5 | import com.keenwrite.MainPane; | |
| 6 | import com.keenwrite.MainScene; | |
| 7 | import com.keenwrite.constants.Constants; | |
| 8 | import com.keenwrite.editors.TextDefinition; | |
| 9 | import com.keenwrite.editors.TextEditor; | |
| 10 | import com.keenwrite.editors.markdown.HyperlinkModel; | |
| 11 | import com.keenwrite.editors.markdown.LinkVisitor; | |
| 12 | import com.keenwrite.events.ExportFailedEvent; | |
| 13 | import com.keenwrite.preferences.PreferencesController; | |
| 14 | import com.keenwrite.preferences.Workspace; | |
| 15 | import com.keenwrite.processors.markdown.MarkdownProcessor; | |
| 16 | import com.keenwrite.search.SearchModel; | |
| 17 | import com.keenwrite.typesetting.Typesetter; | |
| 18 | import com.keenwrite.ui.controls.SearchBar; | |
| 19 | import com.keenwrite.ui.dialogs.ImageDialog; | |
| 20 | import com.keenwrite.ui.dialogs.LinkDialog; | |
| 21 | import com.keenwrite.ui.dialogs.ThemePicker; | |
| 22 | import com.keenwrite.ui.explorer.FilePicker; | |
| 23 | import com.keenwrite.ui.explorer.FilePickerFactory; | |
| 24 | import com.keenwrite.ui.logging.LogView; | |
| 25 | import com.keenwrite.util.AlphanumComparator; | |
| 26 | import com.vladsch.flexmark.ast.Link; | |
| 27 | import javafx.concurrent.Task; | |
| 28 | import javafx.scene.control.Alert; | |
| 29 | import javafx.scene.control.Dialog; | |
| 30 | import javafx.stage.Window; | |
| 31 | import javafx.stage.WindowEvent; | |
| 32 | ||
| 33 | import java.io.File; | |
| 34 | import java.io.IOException; | |
| 35 | import java.nio.file.Path; | |
| 36 | import java.util.ArrayList; | |
| 37 | import java.util.List; | |
| 38 | import java.util.Optional; | |
| 39 | import java.util.concurrent.ExecutorService; | |
| 40 | ||
| 41 | import static com.keenwrite.Bootstrap.*; | |
| 42 | import static com.keenwrite.ExportFormat.*; | |
| 43 | import static com.keenwrite.Messages.get; | |
| 44 | import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG_NODE; | |
| 45 | import static com.keenwrite.events.StatusEvent.clue; | |
| 46 | import static com.keenwrite.preferences.WorkspaceKeys.*; | |
| 47 | import static com.keenwrite.processors.ProcessorFactory.createProcessors; | |
| 48 | import static com.keenwrite.ui.explorer.FilePickerFactory.Options; | |
| 49 | import static com.keenwrite.ui.explorer.FilePickerFactory.Options.*; | |
| 50 | import static com.keenwrite.util.FileWalker.walk; | |
| 51 | import static java.nio.file.Files.readString; | |
| 52 | import static java.nio.file.Files.writeString; | |
| 53 | import static java.util.concurrent.Executors.newFixedThreadPool; | |
| 54 | import static javafx.application.Platform.runLater; | |
| 55 | import static javafx.event.Event.fireEvent; | |
| 56 | import static javafx.scene.control.Alert.AlertType.INFORMATION; | |
| 57 | import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST; | |
| 58 | import static org.apache.commons.io.FilenameUtils.getExtension; | |
| 59 | ||
| 60 | /** | |
| 61 | * Responsible for abstracting how functionality is mapped to the application. | |
| 62 | * This allows users to customize accelerator keys and will provide pluggable | |
| 63 | * functionality so that different text markup languages can change documents | |
| 64 | * using their respective syntax. | |
| 65 | */ | |
| 66 | public final class GuiCommands { | |
| 67 | private static final ExecutorService sExecutor = newFixedThreadPool( 1 ); | |
| 68 | ||
| 69 | private static final String STYLE_SEARCH = "search"; | |
| 70 | ||
| 71 | /** | |
| 72 | * Sci-fi genres, which are can be longer than other genres, typically fall | |
| 73 | * below 150,000 words at 6 chars per word. This reduces re-allocations of | |
| 74 | * memory when concatenating files together when exporting novels. | |
| 75 | */ | |
| 76 | private static final int DOCUMENT_LENGTH = 150_000 * 6; | |
| 77 | ||
| 78 | /** | |
| 79 | * When an action is executed, this is one of the recipients. | |
| 80 | */ | |
| 81 | private final MainPane mMainPane; | |
| 82 | ||
| 83 | private final MainScene mMainScene; | |
| 84 | ||
| 85 | private final LogView mLogView; | |
| 86 | ||
| 87 | /** | |
| 88 | * Tracks finding text in the active document. | |
| 89 | */ | |
| 90 | private final SearchModel mSearchModel; | |
| 91 | ||
| 92 | public GuiCommands( final MainScene scene, final MainPane pane ) { | |
| 93 | mMainScene = scene; | |
| 94 | mMainPane = pane; | |
| 95 | mLogView = new LogView(); | |
| 96 | mSearchModel = new SearchModel(); | |
| 97 | mSearchModel.matchOffsetProperty().addListener( ( c, o, n ) -> { | |
| 98 | final var editor = getActiveTextEditor(); | |
| 99 | ||
| 100 | // Clear highlighted areas before highlighting a new region. | |
| 101 | if( o != null ) { | |
| 102 | editor.unstylize( STYLE_SEARCH ); | |
| 103 | } | |
| 104 | ||
| 105 | if( n != null ) { | |
| 106 | editor.moveTo( n.getStart() ); | |
| 107 | editor.stylize( n, STYLE_SEARCH ); | |
| 108 | } | |
| 109 | } ); | |
| 110 | ||
| 111 | // When the active text editor changes, update the haystack. | |
| 112 | mMainPane.activeTextEditorProperty().addListener( | |
| 113 | ( c, o, n ) -> mSearchModel.search( getActiveTextEditor().getText() ) | |
| 114 | ); | |
| 115 | } | |
| 116 | ||
| 117 | public void file_new() { | |
| 118 | getMainPane().newTextEditor(); | |
| 119 | } | |
| 120 | ||
| 121 | public void file_open() { | |
| 122 | pickFiles( FILE_OPEN_MULTIPLE ).ifPresent( l -> getMainPane().open( l ) ); | |
| 123 | } | |
| 124 | ||
| 125 | public void file_close() { | |
| 126 | getMainPane().close(); | |
| 127 | } | |
| 128 | ||
| 129 | public void file_close_all() { | |
| 130 | getMainPane().closeAll(); | |
| 131 | } | |
| 132 | ||
| 133 | public void file_save() { | |
| 134 | getMainPane().save(); | |
| 135 | } | |
| 136 | ||
| 137 | public void file_save_as() { | |
| 138 | pickFiles( FILE_SAVE_AS ).ifPresent( l -> getMainPane().saveAs( l ) ); | |
| 139 | } | |
| 140 | ||
| 141 | public void file_save_all() { | |
| 142 | getMainPane().saveAll(); | |
| 143 | } | |
| 144 | ||
| 145 | /** | |
| 146 | * Converts the actively edited file in the given file format. | |
| 147 | * | |
| 148 | * @param format The destination file format. | |
| 149 | */ | |
| 150 | private void file_export( final ExportFormat format ) { | |
| 151 | file_export( format, false ); | |
| 152 | } | |
| 153 | ||
| 154 | /** | |
| 155 | * Converts one or more files into the given file format. If {@code dir} | |
| 156 | * is set to true, this will first append all files in the same directory | |
| 157 | * as the actively edited file. | |
| 158 | * | |
| 159 | * @param format The destination file format. | |
| 160 | * @param dir Export all files in the actively edited file's directory. | |
| 161 | */ | |
| 162 | private void file_export( final ExportFormat format, final boolean dir ) { | |
| 163 | final var main = getMainPane(); | |
| 164 | final var editor = main.getActiveTextEditor(); | |
| 165 | final var exported = getWorkspace().fileProperty( KEY_UI_RECENT_EXPORT ); | |
| 166 | final var filename = format.toExportFilename( editor.getPath() ); | |
| 167 | final var selection = pickFiles( | |
| 168 | Constants.PDF_DEFAULT.getName().equals( exported.get().getName() ) | |
| 169 | ? filename | |
| 170 | : exported.get(), FILE_EXPORT | |
| 171 | ); | |
| 172 | ||
| 173 | selection.ifPresent( ( files ) -> { | |
| 174 | editor.save(); | |
| 175 | ||
| 176 | final var file = files.get( 0 ); | |
| 177 | final var path = file.toPath(); | |
| 178 | final var document = dir ? append( editor ) : editor.getText(); | |
| 179 | final var context = main.createProcessorContext( path, format ); | |
| 180 | ||
| 181 | final var task = new Task<Path>() { | |
| 182 | @Override | |
| 183 | protected Path call() throws Exception { | |
| 184 | final var chain = createProcessors( context ); | |
| 185 | final var export = chain.apply( document ); | |
| 186 | ||
| 187 | // Processors can export binary files. In such cases, processors | |
| 188 | // return null to prevent further processing. | |
| 189 | return export == null ? null : writeString( path, export ); | |
| 190 | } | |
| 191 | }; | |
| 192 | ||
| 193 | task.setOnSucceeded( | |
| 194 | e -> { | |
| 195 | // Remember the exported file name for next time. | |
| 196 | exported.setValue( file ); | |
| 197 | ||
| 198 | final var result = task.getValue(); | |
| 199 | ||
| 200 | // Binary formats must notify users of success independently. | |
| 201 | if( result != null ) { | |
| 202 | clue( "Main.status.export.success", result ); | |
| 203 | } | |
| 204 | } | |
| 205 | ); | |
| 206 | ||
| 207 | task.setOnFailed( e -> { | |
| 208 | final var ex = task.getException(); | |
| 209 | clue( ex ); | |
| 210 | ||
| 211 | if( ex instanceof TypeNotPresentException ) { | |
| 212 | fireExportFailedEvent(); | |
| 213 | } | |
| 214 | } ); | |
| 215 | ||
| 216 | sExecutor.execute( task ); | |
| 217 | } ); | |
| 218 | } | |
| 219 | ||
| 220 | /** | |
| 221 | * @param dir {@code true} means to export all files in the active file | |
| 222 | * editor's directory; {@code false} means to export only the | |
| 223 | * actively edited file. | |
| 224 | */ | |
| 225 | private void file_export_pdf( final boolean dir ) { | |
| 226 | final var workspace = getWorkspace(); | |
| 227 | final var themes = workspace.toFile( KEY_TYPESET_CONTEXT_THEMES_PATH ); | |
| 228 | final var theme = workspace.stringProperty( | |
| 229 | KEY_TYPESET_CONTEXT_THEME_SELECTION ); | |
| 230 | ||
| 231 | if( Typesetter.canRun() ) { | |
| 232 | // If the typesetter is installed, allow the user to select a theme. If | |
| 233 | // the themes aren't installed, a status message will appear. | |
| 234 | if( ThemePicker.choose( themes, theme ) ) { | |
| 235 | file_export( APPLICATION_PDF, dir ); | |
| 236 | } | |
| 237 | } | |
| 238 | else { | |
| 239 | fireExportFailedEvent(); | |
| 240 | } | |
| 241 | } | |
| 242 | ||
| 243 | public void file_export_pdf() { | |
| 244 | file_export_pdf( false ); | |
| 245 | } | |
| 246 | ||
| 247 | public void file_export_pdf_dir() { | |
| 248 | file_export_pdf( true ); | |
| 249 | } | |
| 250 | ||
| 251 | public void file_export_html_svg() { | |
| 252 | file_export( HTML_TEX_SVG ); | |
| 253 | } | |
| 254 | ||
| 255 | public void file_export_html_tex() { | |
| 256 | file_export( HTML_TEX_DELIMITED ); | |
| 257 | } | |
| 258 | ||
| 259 | public void file_export_xhtml_tex() { | |
| 260 | file_export( XHTML_TEX ); | |
| 261 | } | |
| 262 | ||
| 263 | public void file_export_markdown() { | |
| 264 | file_export( MARKDOWN_PLAIN ); | |
| 265 | } | |
| 266 | ||
| 267 | private void fireExportFailedEvent() { | |
| 268 | runLater( ExportFailedEvent::fire ); | |
| 269 | } | |
| 270 | ||
| 271 | public void file_exit() { | |
| 272 | final var window = getWindow(); | |
| 273 | fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) ); | |
| 274 | } | |
| 275 | ||
| 276 | public void edit_undo() { | |
| 277 | getActiveTextEditor().undo(); | |
| 278 | } | |
| 279 | ||
| 280 | public void edit_redo() { | |
| 281 | getActiveTextEditor().redo(); | |
| 282 | } | |
| 283 | ||
| 284 | public void edit_cut() { | |
| 285 | getActiveTextEditor().cut(); | |
| 286 | } | |
| 287 | ||
| 288 | public void edit_copy() { | |
| 289 | getActiveTextEditor().copy(); | |
| 290 | } | |
| 291 | ||
| 292 | public void edit_paste() { | |
| 293 | getActiveTextEditor().paste(); | |
| 294 | } | |
| 295 | ||
| 296 | public void edit_select_all() { | |
| 297 | getActiveTextEditor().selectAll(); | |
| 298 | } | |
| 299 | ||
| 300 | public void edit_find() { | |
| 301 | final var nodes = getMainScene().getStatusBar().getLeftItems(); | |
| 302 | ||
| 303 | if( nodes.isEmpty() ) { | |
| 304 | final var searchBar = new SearchBar(); | |
| 305 | ||
| 306 | searchBar.matchIndexProperty().bind( mSearchModel.matchIndexProperty() ); | |
| 307 | searchBar.matchCountProperty().bind( mSearchModel.matchCountProperty() ); | |
| 308 | ||
| 309 | searchBar.setOnCancelAction( ( event ) -> { | |
| 310 | final var editor = getActiveTextEditor(); | |
| 311 | nodes.remove( searchBar ); | |
| 312 | editor.unstylize( STYLE_SEARCH ); | |
| 313 | editor.getNode().requestFocus(); | |
| 314 | } ); | |
| 315 | ||
| 316 | searchBar.addInputListener( ( c, o, n ) -> { | |
| 317 | if( n != null && !n.isEmpty() ) { | |
| 318 | mSearchModel.search( n, getActiveTextEditor().getText() ); | |
| 319 | } | |
| 320 | } ); | |
| 321 | ||
| 322 | searchBar.setOnNextAction( ( event ) -> edit_find_next() ); | |
| 323 | searchBar.setOnPrevAction( ( event ) -> edit_find_prev() ); | |
| 324 | ||
| 325 | nodes.add( searchBar ); | |
| 326 | searchBar.requestFocus(); | |
| 327 | } | |
| 328 | else { | |
| 329 | nodes.clear(); | |
| 330 | } | |
| 331 | } | |
| 332 | ||
| 333 | public void edit_find_next() { | |
| 334 | mSearchModel.advance(); | |
| 335 | } | |
| 336 | ||
| 337 | public void edit_find_prev() { | |
| 338 | mSearchModel.retreat(); | |
| 339 | } | |
| 340 | ||
| 341 | public void edit_preferences() { | |
| 342 | try { | |
| 343 | new PreferencesController( getWorkspace() ).show(); | |
| 344 | } catch( final Exception ex ) { | |
| 345 | clue( ex ); | |
| 346 | } | |
| 347 | } | |
| 348 | ||
| 349 | public void format_bold() { | |
| 350 | getActiveTextEditor().bold(); | |
| 351 | } | |
| 352 | ||
| 353 | public void format_italic() { | |
| 354 | getActiveTextEditor().italic(); | |
| 355 | } | |
| 356 | ||
| 357 | public void format_monospace() { | |
| 358 | getActiveTextEditor().monospace(); | |
| 359 | } | |
| 360 | ||
| 361 | public void format_superscript() { | |
| 362 | getActiveTextEditor().superscript(); | |
| 363 | } | |
| 364 | ||
| 365 | public void format_subscript() { | |
| 366 | getActiveTextEditor().subscript(); | |
| 367 | } | |
| 368 | ||
| 369 | public void format_strikethrough() { | |
| 370 | getActiveTextEditor().strikethrough(); | |
| 371 | } | |
| 372 | ||
| 373 | public void insert_blockquote() { | |
| 374 | getActiveTextEditor().blockquote(); | |
| 375 | } | |
| 376 | ||
| 377 | public void insert_code() { | |
| 378 | getActiveTextEditor().code(); | |
| 379 | } | |
| 380 | ||
| 381 | public void insert_fenced_code_block() { | |
| 382 | getActiveTextEditor().fencedCodeBlock(); | |
| 383 | } | |
| 384 | ||
| 385 | public void insert_link() { | |
| 386 | insertObject( createLinkDialog() ); | |
| 387 | } | |
| 388 | ||
| 389 | public void insert_image() { | |
| 390 | insertObject( createImageDialog() ); | |
| 391 | } | |
| 392 | ||
| 393 | private void insertObject( final Dialog<String> dialog ) { | |
| 394 | final var textArea = getActiveTextEditor().getTextArea(); | |
| 395 | dialog.showAndWait().ifPresent( textArea::replaceSelection ); | |
| 396 | } | |
| 397 | ||
| 398 | private Dialog<String> createLinkDialog() { | |
| 399 | return new LinkDialog( getWindow(), createHyperlinkModel() ); | |
| 400 | } | |
| 401 | ||
| 402 | private Dialog<String> createImageDialog() { | |
| 403 | final var path = getActiveTextEditor().getPath(); | |
| 404 | final var parentDir = path.getParent(); | |
| 405 | return new ImageDialog( getWindow(), parentDir ); | |
| 406 | } | |
| 407 | ||
| 408 | /** | |
| 409 | * Returns one of: selected text, word under cursor, or parsed hyperlink from | |
| 410 | * the Markdown AST. | |
| 411 | * | |
| 412 | * @return An instance containing the link URL and display text. | |
| 413 | */ | |
| 414 | private HyperlinkModel createHyperlinkModel() { | |
| 415 | final var context = getMainPane().createProcessorContext(); | |
| 416 | final var editor = getActiveTextEditor(); | |
| 417 | final var textArea = editor.getTextArea(); | |
| 418 | final var selectedText = textArea.getSelectedText(); | |
| 419 | ||
| 420 | // Convert current paragraph to Markdown nodes. | |
| 421 | final var mp = MarkdownProcessor.create( context ); | |
| 422 | final var p = textArea.getCurrentParagraph(); | |
| 423 | final var paragraph = textArea.getText( p ); | |
| 424 | final var node = mp.toNode( paragraph ); | |
| 425 | final var visitor = new LinkVisitor( textArea.getCaretColumn() ); | |
| 426 | final var link = visitor.process( node ); | |
| 427 | ||
| 428 | if( link != null ) { | |
| 429 | textArea.selectRange( p, link.getStartOffset(), p, link.getEndOffset() ); | |
| 430 | } | |
| 431 | ||
| 432 | return createHyperlinkModel( link, selectedText ); | |
| 433 | } | |
| 434 | ||
| 435 | private HyperlinkModel createHyperlinkModel( | |
| 436 | final Link link, final String selection ) { | |
| 437 | ||
| 438 | return link == null | |
| 439 | ? new HyperlinkModel( selection, "https://localhost" ) | |
| 440 | : new HyperlinkModel( link ); | |
| 441 | } | |
| 442 | ||
| 443 | public void insert_heading_1() { | |
| 444 | insert_heading( 1 ); | |
| 445 | } | |
| 446 | ||
| 447 | public void insert_heading_2() { | |
| 448 | insert_heading( 2 ); | |
| 449 | } | |
| 450 | ||
| 451 | public void insert_heading_3() { | |
| 452 | insert_heading( 3 ); | |
| 453 | } | |
| 454 | ||
| 455 | private void insert_heading( final int level ) { | |
| 456 | getActiveTextEditor().heading( level ); | |
| 457 | } | |
| 458 | ||
| 459 | public void insert_unordered_list() { | |
| 460 | getActiveTextEditor().unorderedList(); | |
| 461 | } | |
| 462 | ||
| 463 | public void insert_ordered_list() { | |
| 464 | getActiveTextEditor().orderedList(); | |
| 465 | } | |
| 466 | ||
| 467 | public void insert_horizontal_rule() { | |
| 468 | getActiveTextEditor().horizontalRule(); | |
| 469 | } | |
| 470 | ||
| 471 | public void definition_create() { | |
| 472 | getActiveTextDefinition().createDefinition(); | |
| 473 | } | |
| 474 | ||
| 475 | public void definition_rename() { | |
| 476 | getActiveTextDefinition().renameDefinition(); | |
| 477 | } | |
| 478 | ||
| 479 | public void definition_delete() { | |
| 480 | getActiveTextDefinition().deleteDefinitions(); | |
| 481 | } | |
| 482 | ||
| 483 | public void definition_autoinsert() { | |
| 484 | getMainPane().autoinsert(); | |
| 485 | } | |
| 486 | ||
| 487 | public void view_refresh() { | |
| 488 | getMainPane().viewRefresh(); | |
| 489 | } | |
| 490 | ||
| 491 | public void view_preview() { | |
| 492 | getMainPane().viewPreview(); | |
| 493 | } | |
| 494 | ||
| 495 | public void view_outline() { | |
| 496 | getMainPane().viewOutline(); | |
| 497 | } | |
| 498 | ||
| 499 | public void view_files() {getMainPane().viewFiles();} | |
| 500 | ||
| 501 | public void view_statistics() { | |
| 502 | getMainPane().viewStatistics(); | |
| 503 | } | |
| 504 | ||
| 505 | public void view_menubar() { | |
| 506 | getMainScene().toggleMenuBar(); | |
| 507 | } | |
| 508 | ||
| 509 | public void view_toolbar() { | |
| 510 | getMainScene().toggleToolBar(); | |
| 511 | } | |
| 512 | ||
| 513 | public void view_statusbar() { | |
| 514 | getMainScene().toggleStatusBar(); | |
| 515 | } | |
| 516 | ||
| 517 | public void view_log() { | |
| 518 | mLogView.view(); | |
| 519 | } | |
| 520 | ||
| 521 | public void help_about() { | |
| 522 | final var alert = new Alert( INFORMATION ); | |
| 523 | final var prefix = "Dialog.about."; | |
| 524 | alert.setTitle( get( prefix + "title", APP_TITLE ) ); | |
| 525 | alert.setHeaderText( get( prefix + "header", APP_TITLE ) ); | |
| 526 | alert.setContentText( get( prefix + "content", APP_YEAR, APP_VERSION ) ); | |
| 527 | alert.setGraphic( ICON_DIALOG_NODE ); | |
| 528 | alert.initOwner( getWindow() ); | |
| 529 | alert.showAndWait(); | |
| 530 | } | |
| 531 | ||
| 532 | /** | |
| 533 | * Concatenates all the files in the same directory as the given file into | |
| 534 | * a string. The extension is determined by the given file name pattern; the | |
| 535 | * order files are concatenated is based on their numeric sort order (this | |
| 536 | * avoids lexicographic sorting). | |
| 537 | * <p> | |
| 538 | * If the parent path to the file being edited in the text editor cannot | |
| 539 | * be found then this will return the editor's text, without iterating through | |
| 540 | * the parent directory. (Should never happen, but who knows?) | |
| 541 | * </p> | |
| 542 | * <p> | |
| 543 | * New lines are automatically appended to separate each file. | |
| 544 | * </p> | |
| 545 | * | |
| 546 | * @param editor The text editor containing | |
| 547 | * @return All files in the same directory as the file being edited | |
| 548 | * concatenated into a single string. | |
| 549 | */ | |
| 550 | private String append( final TextEditor editor ) { | |
| 551 | final var pattern = editor.getPath(); | |
| 552 | final var parent = pattern.getParent(); | |
| 553 | ||
| 554 | // Short-circuit because nothing else can be done. | |
| 555 | if( parent == null ) { | |
| 556 | clue( "Main.status.export.concat.parent", pattern ); | |
| 557 | return editor.getText(); | |
| 558 | } | |
| 559 | ||
| 560 | final var filename = pattern.getFileName().toString(); | |
| 561 | final var extension = getExtension( filename ); | |
| 562 | ||
| 563 | if( extension.isBlank() ) { | |
| 564 | clue( "Main.status.export.concat.extension", filename ); | |
| 565 | return editor.getText(); | |
| 566 | } | |
| 567 | ||
| 568 | try { | |
| 569 | final var glob = "**/*." + extension; | |
| 570 | final ArrayList<Path> files = new ArrayList<>(); | |
| 571 | walk( parent, glob, files::add ); | |
| 572 | files.sort( new AlphanumComparator<>() ); | |
| 573 | ||
| 574 | final var text = new StringBuilder( DOCUMENT_LENGTH ); | |
| 575 | ||
| 576 | files.forEach( ( file ) -> { | |
| 577 | try { | |
| 578 | clue( "Main.status.export.concat", file ); | |
| 579 | text.append( readString( file ) ); | |
| 580 | } catch( final IOException ex ) { | |
| 581 | clue( "Main.status.export.concat.io", file ); | |
| 582 | } | |
| 583 | } ); | |
| 584 | ||
| 585 | return text.toString(); | |
| 586 | } catch( final Throwable t ) { | |
| 587 | clue( t ); | |
| 588 | return editor.getText(); | |
| 589 | } | |
| 590 | } | |
| 591 | ||
| 592 | private Optional<List<File>> pickFiles( final Options... options ) { | |
| 593 | return createPicker( options ).choose(); | |
| 594 | } | |
| 595 | ||
| 596 | private Optional<List<File>> pickFiles( | |
| 597 | final File filename, final Options... options ) { | |
| 598 | final var picker = createPicker( options ); | |
| 599 | picker.setInitialFilename( filename ); | |
| 600 | return picker.choose(); | |
| 601 | } | |
| 602 | ||
| 603 | private FilePicker createPicker( final Options... options ) { | |
| 604 | final var factory = new FilePickerFactory( getWorkspace() ); | |
| 605 | return factory.createModal( getWindow(), options ); | |
| 606 | } | |
| 607 | ||
| 608 | private TextEditor getActiveTextEditor() { | |
| 609 | return getMainPane().getActiveTextEditor(); | |
| 610 | } | |
| 611 | ||
| 612 | private TextDefinition getActiveTextDefinition() { | |
| 613 | return getMainPane().getActiveTextDefinition(); | |
| 614 | } | |
| 615 | ||
| 616 | private MainScene getMainScene() { | |
| 617 | return mMainScene; | |
| 618 | } | |
| 619 | ||
| 620 | private MainPane getMainPane() { | |
| 621 | return mMainPane; | |
| 622 | } | |
| 623 | ||
| 624 | private Workspace getWorkspace() { | |
| 625 | return mMainPane.getWorkspace(); | |
| 626 | } | |
| 627 | ||
| 628 | private Window getWindow() { | |
| 629 | return getMainPane().getWindow(); | |
| 630 | } | |
| 631 | } | |
| 1 | 632 |
| 101 | 101 | private boolean pick() { |
| 102 | 102 | try { |
| 103 | // List themes in alphabetical order (human readable by directory name). | |
| 103 | // List themes in alphabetical order (human-readable by directory name). | |
| 104 | 104 | final var choices = new TreeMap<String, String>(); |
| 105 | 105 | final String[] selection = new String[]{""}; |
| 19 | 19 | * to select a file. |
| 20 | 20 | */ |
| 21 | default void setInitialFilename( File file ) {} | |
| 21 | void setInitialFilename( File file ); | |
| 22 | 22 | |
| 23 | 23 | /** |
| 3 | 3 | |
| 4 | 4 | import com.io7m.jwheatsheaf.ui.JWFileChoosers; |
| 5 | import com.keenwrite.Messages; | |
| 5 | 6 | import com.keenwrite.preferences.Workspace; |
| 6 | 7 | import javafx.beans.property.ObjectProperty; |
| ... | ||
| 96 | 97 | } |
| 97 | 98 | |
| 98 | //mBuilder.setTitle( get( title ) ); | |
| 99 | mBuilder.setTitle( Messages.get( title ) ); | |
| 99 | 100 | mBuilder.setAction( action ); |
| 101 | } | |
| 102 | ||
| 103 | @Override | |
| 104 | public void setInitialFilename( final File file ) { | |
| 105 | mBuilder.setInitialFileName( file.getName() ); | |
| 100 | 106 | } |
| 101 | 107 | |
| 2 | 2 | package com.keenwrite.ui.explorer; |
| 3 | 3 | |
| 4 | import com.keenwrite.events.FileOpenEvent; | |
| 4 | 5 | import com.keenwrite.ui.controls.BrowseButton; |
| 5 | 6 | import javafx.beans.property.*; |
| ... | ||
| 20 | 21 | import java.util.List; |
| 21 | 22 | import java.util.Locale; |
| 23 | import java.util.Objects; | |
| 22 | 24 | import java.util.Optional; |
| 23 | 25 | |
| 24 | 26 | import static com.keenwrite.constants.Constants.UI_CONTROL_SPACING; |
| 25 | import static com.keenwrite.events.FileOpenEvent.fireFileOpenEvent; | |
| 26 | 27 | import static com.keenwrite.events.StatusEvent.clue; |
| 27 | 28 | import static com.keenwrite.ui.fonts.IconFactory.createFileIcon; |
| ... | ||
| 87 | 88 | mDirectory.addListener( ( c, o, n ) -> updateListing( n ) ); |
| 88 | 89 | updateListing( mDirectory.get() ); |
| 90 | } | |
| 91 | ||
| 92 | @Override | |
| 93 | public void setInitialFilename( final File file ) { | |
| 89 | 94 | } |
| 90 | 95 | |
| ... | ||
| 104 | 109 | } |
| 105 | 110 | |
| 106 | for( final var f : directory.list() ) { | |
| 111 | for( final var f : Objects.requireNonNull( directory.list() ) ) { | |
| 107 | 112 | if( !f.startsWith( "." ) ) { |
| 108 | 113 | mItems.add( pathEntry( Paths.get( directory.toString(), f ) ) ); |
| ... | ||
| 128 | 133 | |
| 129 | 134 | mDirectory.addListener( ( c, o, n ) -> { |
| 130 | if( n != null ) { field.setText( n.getAbsolutePath() ); } | |
| 135 | if( n != null ) {field.setText( n.getAbsolutePath() );} | |
| 131 | 136 | } ); |
| 132 | 137 | |
| ... | ||
| 165 | 170 | |
| 166 | 171 | if( file.isFile() ) { |
| 167 | fireFileOpenEvent( path.toUri() ); | |
| 172 | FileOpenEvent.fire( path.toUri() ); | |
| 168 | 173 | } |
| 169 | 174 | else if( file.isDirectory() ) { |
| ... | ||
| 258 | 263 | private final StringProperty mTime; |
| 259 | 264 | |
| 260 | protected PathEntry( final Path path ) throws IOException { | |
| 265 | private PathEntry( final Path path ) throws IOException { | |
| 261 | 266 | this( |
| 262 | 267 | path, |
| 3 | 3 | |
| 4 | 4 | import com.keenwrite.events.DocumentChangedEvent; |
| 5 | import com.keenwrite.events.WordCountEvent; | |
| 5 | 6 | import com.keenwrite.preferences.Workspace; |
| 6 | 7 | import com.whitemagicsoftware.wordcount.TokenizerException; |
| ... | ||
| 17 | 18 | import static com.keenwrite.events.Bus.register; |
| 18 | 19 | import static com.keenwrite.events.StatusEvent.clue; |
| 19 | import static com.keenwrite.events.WordCountEvent.fireWordCountEvent; | |
| 20 | 20 | import static com.keenwrite.preferences.WorkspaceKeys.KEY_LANGUAGE_LOCALE; |
| 21 | 21 | import static com.keenwrite.preferences.WorkspaceKeys.KEY_UI_FONT_EDITOR_NAME; |
| ... | ||
| 90 | 90 | ); |
| 91 | 91 | |
| 92 | fireWordCountEvent( wordCount ); | |
| 92 | WordCountEvent.fire( wordCount ); | |
| 93 | 93 | } ); |
| 94 | 94 | } catch( final TokenizerException ex ) { |
| 2 | 2 | |
| 3 | 3 | import com.keenwrite.events.Bus; |
| 4 | import com.keenwrite.events.CaretNavigationEvent; | |
| 4 | 5 | import com.keenwrite.events.ParseHeadingEvent; |
| 5 | 6 | import javafx.scene.Node; |
| 6 | 7 | import javafx.scene.control.TreeCell; |
| 7 | 8 | import javafx.scene.control.TreeItem; |
| 8 | 9 | import javafx.scene.control.TreeView; |
| 9 | 10 | import javafx.util.Callback; |
| 10 | 11 | import org.greenrobot.eventbus.Subscribe; |
| 11 | 12 | |
| 12 | 13 | import static com.keenwrite.events.Bus.register; |
| 13 | import static com.keenwrite.events.CaretNavigationEvent.fireCaretNavigationEvent; | |
| 14 | 14 | import static com.keenwrite.ui.fonts.IconFactory.createGraphic; |
| 15 | 15 | import static javafx.application.Platform.runLater; |
| ... | ||
| 46 | 46 | cell.addEventFilter( MOUSE_PRESSED, event -> { |
| 47 | 47 | if( event.getButton() == PRIMARY && event.getClickCount() % 2 == 0 ) { |
| 48 | fireCaretNavigationEvent( cell.getItem().getOffset() ); | |
| 48 | CaretNavigationEvent.fire( cell.getItem().getOffset() ); | |
| 49 | 49 | event.consume(); |
| 50 | 50 | } |
| 1 | package com.keenwrite.util; | |
| 2 | ||
| 3 | import com.keenwrite.sigils.SigilOperator; | |
| 4 | import com.keenwrite.sigils.Sigils; | |
| 5 | ||
| 6 | import java.util.HashMap; | |
| 7 | import java.util.Map; | |
| 8 | import java.util.concurrent.ConcurrentHashMap; | |
| 9 | import java.util.regex.Pattern; | |
| 10 | ||
| 11 | import static java.lang.String.format; | |
| 12 | import static java.util.regex.Pattern.compile; | |
| 13 | import static java.util.regex.Pattern.quote; | |
| 14 | ||
| 15 | public class InterpolatingMap extends ConcurrentHashMap<String, String> { | |
| 16 | private static final int GROUP_DELIMITED = 1; | |
| 17 | ||
| 18 | /** | |
| 19 | * Used to override the default initial capacity in {@link HashMap}. | |
| 20 | */ | |
| 21 | private static final int INITIAL_CAPACITY = 1 << 8; | |
| 22 | ||
| 23 | public InterpolatingMap() { | |
| 24 | super( INITIAL_CAPACITY ); | |
| 25 | } | |
| 26 | ||
| 27 | /** | |
| 28 | * Interpolates all values in the map that reference other values by way | |
| 29 | * of key names. Performs a non-greedy match of key names delimited by | |
| 30 | * definition tokens. This operation modifies the map directly. | |
| 31 | * | |
| 32 | * @param operator Contains the opening and closing sigils that mark | |
| 33 | * where variable names begin and end. | |
| 34 | * @return {@code this} | |
| 35 | */ | |
| 36 | public Map<String, String> interpolate( final SigilOperator operator ) { | |
| 37 | sigilize( operator ); | |
| 38 | interpolate( operator.getSigils() ); | |
| 39 | return this; | |
| 40 | } | |
| 41 | ||
| 42 | /** | |
| 43 | * Wraps each key in this map with the starting and ending sigils provided | |
| 44 | * by the given {@link SigilOperator}. This operation modifies the map | |
| 45 | * directly. | |
| 46 | * | |
| 47 | * @param operator Container for starting and ending sigils. | |
| 48 | */ | |
| 49 | private void sigilize( final SigilOperator operator ) { | |
| 50 | forEach( ( k, v ) -> put( operator.entoken( k ), v ) ); | |
| 51 | } | |
| 52 | ||
| 53 | /** | |
| 54 | * Interpolates all values in the map that reference other values by way | |
| 55 | * of key names. Performs a non-greedy match of key names delimited by | |
| 56 | * definition tokens. This operation modifies the map directly. | |
| 57 | * | |
| 58 | * @param sigils Contains the opening and closing sigils that mark | |
| 59 | * where variable names begin and end. | |
| 60 | */ | |
| 61 | private void interpolate( final Sigils sigils ) { | |
| 62 | final var pattern = compile( | |
| 63 | format( | |
| 64 | "(%s.*?%s)", quote( sigils.getBegan() ), quote( sigils.getEnded() ) | |
| 65 | ) | |
| 66 | ); | |
| 67 | ||
| 68 | replaceAll( ( k, v ) -> resolve( v, pattern ) ); | |
| 69 | } | |
| 70 | ||
| 71 | /** | |
| 72 | * Given a value with zero or more key references, this will resolve all | |
| 73 | * the values, recursively. If a key cannot be de-referenced, the value will | |
| 74 | * contain the key name. | |
| 75 | * | |
| 76 | * @param value Value containing zero or more key references. | |
| 77 | * @param pattern The regular expression pattern to match variable key names. | |
| 78 | * @return The given value with all embedded key references interpolated. | |
| 79 | */ | |
| 80 | private String resolve( String value, final Pattern pattern ) { | |
| 81 | final var matcher = pattern.matcher( value ); | |
| 82 | ||
| 83 | while( matcher.find() ) { | |
| 84 | final var keyName = matcher.group( GROUP_DELIMITED ); | |
| 85 | final var mapValue = get( keyName ); | |
| 86 | final var keyValue = mapValue == null | |
| 87 | ? keyName | |
| 88 | : resolve( mapValue, pattern ); | |
| 89 | ||
| 90 | value = value.replace( keyName, keyValue ); | |
| 91 | } | |
| 92 | ||
| 93 | return value; | |
| 94 | } | |
| 95 | } | |
| 1 | 96 |
| 50 | 50 | file.default.definition=variables.yaml |
| 51 | 51 | |
| 52 | # Default file name to be replaced by the most | |
| 53 | # recently exported file name. | |
| 54 | file.default.pdf=untitled.pdf | |
| 55 | ||
| 52 | 56 | # ######################################################################## |
| 53 | 57 | # File name Extensions |
| 7 | 7 | import com.keenwrite.preferences.Workspace; |
| 8 | 8 | import com.keenwrite.preview.HtmlPreview; |
| 9 | import com.keenwrite.sigils.Sigils; | |
| 10 | import com.keenwrite.sigils.YamlSigilOperator; | |
| 9 | 11 | import com.panemu.tiwulfx.control.dock.DetachableTabPane; |
| 10 | 12 | import javafx.application.Application; |
| 11 | 13 | import javafx.beans.property.SimpleObjectProperty; |
| 14 | import javafx.beans.property.SimpleStringProperty; | |
| 12 | 15 | import javafx.event.Event; |
| 13 | 16 | import javafx.event.EventHandler; |
| ... | ||
| 21 | 24 | import org.testfx.framework.junit5.Start; |
| 22 | 25 | |
| 26 | import static com.keenwrite.constants.Constants.DEF_DELIM_BEGAN_DEFAULT; | |
| 27 | import static com.keenwrite.constants.Constants.DEF_DELIM_ENDED_DEFAULT; | |
| 23 | 28 | import static com.keenwrite.util.FontLoader.initFonts; |
| 24 | 29 | |
| ... | ||
| 48 | 53 | final var mainPane = new SplitPane(); |
| 49 | 54 | |
| 55 | final var began = new SimpleStringProperty( DEF_DELIM_BEGAN_DEFAULT ); | |
| 56 | final var ended = new SimpleStringProperty( DEF_DELIM_ENDED_DEFAULT ); | |
| 57 | final var sigils = new Sigils( began, ended ); | |
| 58 | final var operator = new YamlSigilOperator( sigils ); | |
| 50 | 59 | final var transformer = new YamlTreeTransformer(); |
| 51 | final var editor = new DefinitionEditor( transformer ); | |
| 60 | final var editor = new DefinitionEditor( transformer, operator ); | |
| 52 | 61 | |
| 53 | 62 | final var tabPane1 = new DetachableTabPane(); |
| 11 | 11 | import com.vladsch.flexmark.html.HtmlRenderer; |
| 12 | 12 | import com.vladsch.flexmark.parser.Parser; |
| 13 | import javafx.beans.property.SimpleObjectProperty; | |
| 13 | 14 | import javafx.stage.Stage; |
| 14 | 15 | import org.junit.jupiter.api.Test; |
| ... | ||
| 26 | 27 | import java.util.Map; |
| 27 | 28 | |
| 28 | import static com.keenwrite.constants.Constants.DOCUMENT_DEFAULT; | |
| 29 | 29 | import static com.keenwrite.ExportFormat.NONE; |
| 30 | import static com.keenwrite.constants.Constants.DOCUMENT_DEFAULT; | |
| 30 | 31 | import static java.lang.String.format; |
| 31 | 32 | import static javafx.application.Platform.runLater; |
| ... | ||
| 149 | 150 | return new ProcessorContext( |
| 150 | 151 | mPreview, |
| 151 | new HashMap<>(), | |
| 152 | new SimpleObjectProperty<>(), | |
| 152 | 153 | documentPath, |
| 153 | 154 | null, |
| 35 | 35 | } |
| 36 | 36 | |
| 37 | private StringProperty createToken( final String token ) { | |
| 37 | private StringProperty createSigil( final String token ) { | |
| 38 | 38 | return new SimpleStringProperty( token ); |
| 39 | 39 | } |
| 40 | 40 | |
| 41 | private Tokens createRTokens() { | |
| 42 | return createTokens( "x(", ")" ); | |
| 41 | private Sigils createRSigils() { | |
| 42 | return createSigils( "x(", ")" ); | |
| 43 | 43 | } |
| 44 | 44 | |
| 45 | private Tokens createYamlTokens() { | |
| 46 | return createTokens( "{{", "}}" ); | |
| 45 | private Sigils createYamlSigils() { | |
| 46 | return createSigils( "{{", "}}" ); | |
| 47 | 47 | } |
| 48 | 48 | |
| 49 | private Tokens createTokens( final String began, final String ended ) { | |
| 50 | return new Tokens( createToken( began ), createToken( ended ) ); | |
| 49 | private Sigils createSigils( final String began, final String ended ) { | |
| 50 | return new Sigils( createSigil( began ), createSigil( ended ) ); | |
| 51 | 51 | } |
| 52 | 52 | |
| 53 | 53 | private YamlSigilOperator createYamlSigilOperator() { |
| 54 | return new YamlSigilOperator( createYamlTokens() ); | |
| 54 | return new YamlSigilOperator( createYamlSigils() ); | |
| 55 | 55 | } |
| 56 | 56 | |
| 57 | 57 | private RSigilOperator createRSigilOperator() { |
| 58 | return new RSigilOperator( createRTokens(), createYamlSigilOperator() ); | |
| 58 | return new RSigilOperator( createRSigils(), createYamlSigilOperator() ); | |
| 59 | 59 | } |
| 60 | 60 | } |