Dave Jarvis' Repositories

git clone https://repo.autonoma.ca/repo/keenwrite.git
M README.md
8484
8585
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).
8787
8888
M build.gradle
6666
6767
  // 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}"
7172
7273
  // Markdown
...
123124
  //noinspection GradlePackageUpdate
124125
  implementation 'commons-beanutils:commons-beanutils:1.9.4'
126
127
  // Command-line parsing
128
  implementation 'info.picocli:picocli:4.6.2'
125129
126130
  // Spelling, TeX, Docking, KeenQuotes
M keenwrite.sh
1414
  --add-exports=javafx.graphics/com.sun.javafx.scene.text=ALL-UNNAMED \
1515
  --add-exports=javafx.graphics/com.sun.javafx.scene.traversal=ALL-UNNAMED \
16
  -jar keenwrite.jar
16
  -jar keenwrite.jar $@
1717
1818
A libs/jwheatsheaf/com.io7m.jaffirm.core-3.0.5-SNAPSHOT.jar
Binary file
A libs/jwheatsheaf/com.io7m.junreachable.core-3.0.1-SNAPSHOT.jar
Binary file
A libs/jwheatsheaf/com.io7m.jwheatsheaf.api-3.0.0-SNAPSHOT.jar
Binary file
A libs/jwheatsheaf/com.io7m.jwheatsheaf.oxygen-3.0.0-SNAPSHOT.jar
Binary file
A libs/jwheatsheaf/com.io7m.jwheatsheaf.ui-3.0.0-SNAPSHOT.jar
Binary file
M libs/tokenize.jar
Binary file
A src/main/java/com/keenwrite/AppCommands.java
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
}
1176
M src/main/java/com/keenwrite/Caret.java
1010
import java.util.Collection;
1111
12
import static com.keenwrite.constants.Constants.STATUS_BAR_LINE;
1312
import static com.keenwrite.Messages.get;
13
import static com.keenwrite.constants.Constants.STATUS_BAR_LINE;
1414
1515
/**
M src/main/java/com/keenwrite/ExportFormat.java
55
import java.nio.file.Path;
66
7
import static java.lang.String.format;
78
import static org.apache.commons.io.FilenameUtils.removeExtension;
89
...
5354
  ExportFormat( final String extension ) {
5455
    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
    };
5585
  }
5686
...
75105
  public File toExportFilename( final Path path ) {
76106
    return toExportFilename( path.toFile() );
107
  }
108
109
  public Path toExportPath( final Path path ) {
110
    return toExportFilename( path ).toPath();
77111
  }
78112
}
M src/main/java/com/keenwrite/Launcher.java
22
package com.keenwrite;
33
4
import com.keenwrite.cmdline.Arguments;
5
import com.keenwrite.cmdline.ColourScheme;
6
import com.keenwrite.cmdline.HeadlessApp;
7
import picocli.CommandLine;
8
49
import java.io.IOException;
510
import java.io.InputStream;
611
import java.util.Properties;
12
import java.util.function.Consumer;
13
import java.util.logging.LogManager;
714
815
import static com.keenwrite.Bootstrap.*;
...
1825
 * </p>
1926
 */
20
public final class Launcher {
27
public final class Launcher implements Consumer<Arguments> {
28
2129
  /**
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.
2338
   *
2439
   * @param args Command-line arguments.
2540
   */
2641
  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
2762
    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
      }
3187
    } catch( final Throwable t ) {
3288
      log( t );
3389
    }
3490
  }
3591
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
    }
41106
  }
42107
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" );
45120
  }
46121
...
91166
92167
    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 );
94173
    }
174
  }
95175
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 );
97185
  }
98186
}
M src/main/java/com/keenwrite/MainApp.java
44
import com.keenwrite.events.HyperlinkOpenEvent;
55
import com.keenwrite.preferences.Workspace;
6
import com.keenwrite.util.ArrayScanner;
76
import javafx.application.Application;
87
import javafx.event.Event;
98
import javafx.event.EventType;
109
import javafx.scene.input.KeyCode;
1110
import javafx.scene.input.KeyEvent;
1211
import javafx.stage.Stage;
1312
import org.greenrobot.eventbus.Subscribe;
1413
1514
import java.util.function.BooleanSupplier;
16
import java.util.logging.LogManager;
1715
1816
import static com.keenwrite.Bootstrap.APP_TITLE;
...
4240
   */
4341
  public static void main( final String[] args ) {
44
    if( !ArrayScanner.contains( args, "--debug" ) ) {
45
      disableLogging();
46
    }
47
4842
    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();
5743
  }
5844
M src/main/java/com/keenwrite/MainPane.java
2121
import com.keenwrite.sigils.RSigilOperator;
2222
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 ) );
11261110
  }
11271111
}
M src/main/java/com/keenwrite/MainScene.java
55
import com.keenwrite.io.FileWatchService;
66
import com.keenwrite.preferences.Workspace;
7
import com.keenwrite.ui.actions.ApplicationActions;
7
import com.keenwrite.ui.actions.GuiCommands;
88
import com.keenwrite.ui.listeners.CaretListener;
99
import javafx.scene.Node;
...
1616
1717
import java.io.File;
18
import java.text.MessageFormat;
1819
19
import static com.keenwrite.Messages.get;
2020
import static com.keenwrite.constants.Constants.*;
2121
import static com.keenwrite.events.ScrollLockEvent.fireScrollLockEvent;
...
134134
135135
  private String getStylesheet( final String filename ) {
136
    return get( STYLESHEET_APPLICATION_SKIN, filename );
136
    return MessageFormat.format( STYLESHEET_APPLICATION_SKIN, filename );
137137
  }
138138
...
166166
  }
167167
168
  private ApplicationActions createApplicationActions(
168
  private GuiCommands createApplicationActions(
169169
    final MainPane mainPane ) {
170
    return new ApplicationActions( this, mainPane );
170
    return new GuiCommands( this, mainPane );
171171
  }
172172
M src/main/java/com/keenwrite/Messages.java
33
44
import com.keenwrite.preferences.Key;
5
import com.keenwrite.sigils.SigilOperator;
6
import com.keenwrite.util.InterpolatingMap;
57
68
import java.text.MessageFormat;
7
import java.util.Enumeration;
89
import java.util.ResourceBundle;
9
import java.util.Stack;
1010
1111
import static com.keenwrite.constants.Constants.APP_BUNDLE_NAME;
...
1818
public final class Messages {
1919
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();
2222
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 );
2430
  }
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
    }
8431
85
    return sb.toString();
32
  private Messages() {
8633
  }
8734
8835
  /**
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.
9038
   *
9139
   * @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.
9341
   */
9442
  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;
10045
  }
10146
...
10853
  public static String get( final Key key ) {
10954
    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 );
11855
  }
11956
12057
  /**
12158
   * Returns the value for a key from the message bundle with the arguments
122
   * replacing <code>{#}</code> place holders.
59
   * replacing <code>{#}</code> placeholders.
12360
   *
12461
   * @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.
12663
   * @return The value for the key.
12764
   */
...
13875
   */
13976
  public static boolean containsKey( final String key ) {
140
    return RESOURCE_BUNDLE.containsKey( key );
77
    return MAP.containsKey( OPERATOR.entoken( key ) );
14178
  }
14279
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( "${", "}" );
15182
  }
15283
}
A src/main/java/com/keenwrite/cmdline/Arguments.java
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
}
1163
A src/main/java/com/keenwrite/cmdline/ColourScheme.java
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
}
122
A src/main/java/com/keenwrite/cmdline/HeadlessApp.java
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
}
119
M src/main/java/com/keenwrite/constants/Constants.java
4545
  public static final File DOCUMENT_DEFAULT = getFile( "document" );
4646
  public static final File DEFINITION_DEFAULT = getFile( "definition" );
47
  public static final File PDF_DEFAULT = getFile( "pdf" );
4748
4849
  public static final String APP_BUNDLE_NAME = get( "application.messages" );
...
9091
  public static final String STATUS_DEFINITION_MISSING =
9192
    "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;
9793
9894
  /**
M src/main/java/com/keenwrite/dom/DocumentConverter.java
22
package com.keenwrite.dom;
33
4
import org.jetbrains.annotations.NotNull;
45
import org.jsoup.helper.W3CDom;
56
import org.jsoup.nodes.Node;
...
3940
  private static final NodeVisitor LIGATURE_VISITOR = new NodeVisitor() {
4041
    @Override
41
    public void head( final Node node, final int depth ) {
42
    public void head( final @NotNull Node node, final int depth ) {
4243
      if( node instanceof final TextNode textNode ) {
4344
        final var parent = node.parentNode();
...
5758
5859
    @Override
59
    public void tail( final Node node, final int depth ) {
60
    public void tail( final @NotNull Node node, final int depth ) {
6061
    }
6162
  };
6263
6364
  @Override
64
  public Document fromJsoup( final org.jsoup.nodes.Document in ) {
65
  public @NotNull Document fromJsoup( final org.jsoup.nodes.Document in ) {
6566
    assert in != null;
6667
M src/main/java/com/keenwrite/editors/TextDefinition.java
55
import com.keenwrite.editors.definition.DefinitionTreeItem;
66
import com.keenwrite.editors.markdown.MarkdownEditor;
7
import com.keenwrite.sigils.Tokens;
87
import javafx.scene.control.TreeItem;
98
109
import java.util.Map;
1110
1211
/**
1312
 * Differentiates an instance of {@link TextResource} from an instance of
1413
 * {@link DefinitionEditor} or {@link MarkdownEditor}.
1514
 */
1615
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();
2316
2417
  /**
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.
2819
   *
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.
3121
   */
32
  Map<String, String> interpolate( Map<String, String> map, Tokens tokens );
22
  Map<String, String> getDefinitions();
3323
3424
  /**
M src/main/java/com/keenwrite/editors/definition/DefinitionEditor.java
44
import com.keenwrite.constants.Constants;
55
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() {
537514
    getTreeView().requestFocus();
538515
  }
M src/main/java/com/keenwrite/editors/definition/RootTreeItem.java
2323
   *
2424
   * @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
2626
   * class is used.
2727
   */
M src/main/java/com/keenwrite/editors/definition/TreeItemMapper.java
33
44
import com.fasterxml.jackson.databind.JsonNode;
5
import com.keenwrite.util.InterpolatingMap;
56
import javafx.scene.control.TreeItem;
6
import javafx.scene.control.TreeView;
77
8
import java.util.HashMap;
98
import java.util.Iterator;
10
import java.util.Map;
119
import java.util.Stack;
12
13
import static com.keenwrite.constants.Constants.MAP_SIZE_DEFAULT;
1410
1511
/**
1612
 * 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
 *
1915
 * <ol>
2016
 *   <li>Load YAML file into {@link JsonNode} hierarchy.</li>
2117
 *   <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>
2419
 * </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>
3220
 */
3321
public final class TreeItemMapper {
3422
  /**
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}).
3624
   */
3725
  public static final String SEPARATOR = ".";
3826
3927
  /**
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.
4331
   */
4432
  public static final int DEFAULT_KEY_LENGTH = 64;
...
6553
    @Override
6654
    public TreeItem<String> next() {
67
      final TreeItem<String> next = mStack.pop();
55
      final var next = mStack.pop();
6856
      next.getChildren().forEach( mStack::push );
6957
7058
      return next;
7159
    }
72
  }
73
74
  public TreeItemMapper() {
7560
  }
7661
7762
  /**
7863
   * 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.
8067
   */
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();
8470
85
    iterator.forEachRemaining( item -> {
71
    new TreeIterator( root ).forEachRemaining( item -> {
8672
      if( item.isLeaf() ) {
8773
        map.put( toPath( item.getParent() ), item.getValue() );
...
10086
   * @return The string representation of the node's unique key.
10187
   */
102
  public <T> String toPath( TreeItem<T> node ) {
88
  public static <T> String toPath( TreeItem<T> node ) {
10389
    assert node != null;
10490
M src/main/java/com/keenwrite/editors/markdown/MarkdownEditor.java
2929
import java.nio.charset.Charset;
3030
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(
777777
      sSettings.getSetting( STYLESHEET_MARKDOWN_LOCALE, "" ),
778778
      locale.getLanguage(),
M src/main/java/com/keenwrite/events/AppEvent.java
1212
   * Submits this event to the {@link Bus}.
1313
   */
14
  default void fire() {
14
  default void publish() {
1515
    post( this );
1616
  }
M src/main/java/com/keenwrite/events/CaretNavigationEvent.java
2323
   * @param offset Move the caret to this document offset.
2424
   */
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();
2727
  }
2828
M src/main/java/com/keenwrite/events/DocumentChangedEvent.java
3232
   * @param html The document that may have changed.
3333
   */
34
  public static void fireDocumentChangedEvent( final String html ) {
34
  public static void fire( final String html ) {
3535
    // Hashing the document text ignores caret position changes.
3636
    final var hash = html.hashCode();
3737
3838
    if( hash != sHash ) {
3939
      sHash = hash;
40
      new DocumentChangedEvent( html ).fire();
40
      new DocumentChangedEvent( html ).publish();
4141
    }
4242
  }
M src/main/java/com/keenwrite/events/ExportFailedEvent.java
88
 */
99
public class ExportFailedEvent implements AppEvent {
10
  public static void fireExportFailedEvent() {
11
    new ExportFailedEvent().fire();
10
  public static void fire() {
11
    new ExportFailedEvent().publish();
1212
  }
1313
}
M src/main/java/com/keenwrite/events/FileOpenEvent.java
2121
   * @param uri The instance of {@link URI} to open as a file in a text editor.
2222
   */
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();
2525
  }
2626
M src/main/java/com/keenwrite/events/HyperlinkOpenEvent.java
2222
   * @param uri The location to open.
2323
   */
24
  public static void fireHyperlinkOpenEvent( final URI uri )
24
  public static void fire( final URI uri )
2525
    throws IOException {
26
    new HyperlinkOpenEvent( uri ).fire();
26
    new HyperlinkOpenEvent( uri ).publish();
2727
  }
2828
2929
  /**
3030
   * Requests to open the default browser at the given location.
3131
   *
3232
   * @param uri The location to open.
3333
   */
34
  public static void fireHyperlinkOpenEvent( final String uri ) {
34
  public static void fire( final String uri ) {
3535
    try {
36
      fireHyperlinkOpenEvent( new URI( uri ) );
36
      fire( new URI( uri ) );
3737
    } catch( final Exception ex ) {
3838
      clue( ex );
M src/main/java/com/keenwrite/events/ParseHeadingEvent.java
3838
   */
3939
  public static void fireNewOutlineEvent() {
40
    new ParseHeadingEvent( NEW_OUTLINE_LEVEL, "Document", 0 ).fire();
40
    new ParseHeadingEvent( NEW_OUTLINE_LEVEL, "Document", 0 ).publish();
4141
  }
4242
4343
  /**
4444
   * Call to indicate that a new heading must be added to the document outline.
4545
   *
4646
   * @param text   The heading text (parsed and processed).
4747
   * @param level  A value between 1 and 6.
4848
   * @param offset Absolute offset into document where heading is found.
4949
   */
50
  public static void fireNewHeadingEvent(
50
  public static void fire(
5151
    final int level, final String text, final int offset ) {
5252
    assert text != null;
5353
    assert 1 <= level && level <= 6;
5454
    assert 0 <= offset;
55
    new ParseHeadingEvent( level, text, offset ).fire();
55
    new ParseHeadingEvent( level, text, offset ).publish();
5656
  }
5757
M src/main/java/com/keenwrite/events/ScrollLockEvent.java
4747
4848
  private static void fire( final boolean locked ) {
49
    new ScrollLockEvent( locked ).fire();
49
    new ScrollLockEvent( locked ).publish();
5050
  }
5151
M src/main/java/com/keenwrite/events/StatusEvent.java
4141
   */
4242
  public StatusEvent( final String message ) {
43
    assert message != null;
44
    mMessage = message;
45
    mProblem = null;
43
    this( message, null );
4644
  }
4745
4846
  public StatusEvent( final Throwable problem ) {
4947
    this( "", problem );
5048
  }
5149
50
  /**
51
   * @param message The human-readable message text.
52
   * @param problem May be {@code null} if no exception was thrown.
53
   */
5254
  public StatusEvent( final String message, final Throwable problem ) {
5355
    assert message != null;
54
    assert problem != null;
5556
    mMessage = message;
5657
    mProblem = problem;
...
8182
  @Override
8283
  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
8387
    return format( "%s%s%s",
84
                   mMessage,
85
                   mMessage.isBlank() ? "" : " ",
88
                   message,
89
                   message.isBlank() ? "" : " ",
8690
                   mProblem == null ? "" : toEnglish( mProblem ) );
8791
  }
...
134138
   */
135139
  public static void clue() {
136
    fireStatusEvent( get( STATUS_BAR_OK, "OK" ) );
140
    fire( get( STATUS_BAR_OK, "OK" ) );
137141
  }
138142
139143
  /**
140144
   * Notifies listeners of a series of messages. This is useful when providing
141145
   * users feedback of how third-party executables have failed.
142146
   *
143147
   * @param messages The lines of text to display.
144148
   */
145149
  public static void clue( final List<String> messages ) {
146
    messages.forEach( StatusEvent::fireStatusEvent );
150
    messages.forEach( StatusEvent::fire );
147151
  }
148152
149153
  /**
150154
   * Notifies listeners of an error.
151155
   *
152156
   * @param key The message bundle key to look up.
153157
   * @param t   The exception that caused the error.
154158
   */
155159
  public static void clue( final String key, final Throwable t ) {
156
    fireStatusEvent( get( key ), t );
160
    fire( get( key ), t );
157161
  }
158162
159163
  /**
160164
   * Notifies listeners of a custom message.
161165
   *
162166
   * @param key  The property key having a value to populate with arguments.
163167
   * @param args The placeholder values to substitute into the key's value.
164168
   */
165169
  public static void clue( final String key, final Object... args ) {
166
    fireStatusEvent( get( key, args ) );
170
    fire( get( key, args ) );
167171
  }
168172
169173
  /**
170174
   * Notifies listeners of an exception occurs that warrants the user's
171175
   * attention.
172176
   *
173177
   * @param problem The exception with a message to display to the user.
174178
   */
175179
  public static void clue( final Throwable problem ) {
176
    fireStatusEvent( problem );
180
    fire( problem );
177181
  }
178182
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();
181185
  }
182186
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();
185189
  }
186190
187
  private static void fireStatusEvent(
191
  private static void fire(
188192
    final String message, final Throwable problem ) {
189
    new StatusEvent( message, problem ).fire();
193
    new StatusEvent( message, problem ).publish();
190194
  }
191195
}
M src/main/java/com/keenwrite/events/TextDefinitionFocusEvent.java
1515
   * @param editor The instance of editor that has gained input focus.
1616
   */
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();
1919
  }
2020
}
M src/main/java/com/keenwrite/events/TextEditorFocusEvent.java
1616
   */
1717
  public static void fireTextEditorFocus( final TextEditor editor ) {
18
    new TextEditorFocusEvent( editor ).fire();
18
    new TextEditorFocusEvent( editor ).publish();
1919
  }
2020
}
M src/main/java/com/keenwrite/events/WordCountEvent.java
2020
   * @param count The approximate number of words in the document.
2121
   */
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();
2424
  }
2525
M src/main/java/com/keenwrite/io/SysFile.java
4747
4848
        for( final var extension : EXTENSIONS ) {
49
          if( isExecutable( Path.of( p.toString() + extension ) ) ) {
49
          if( isExecutable( Path.of( p + extension ) ) ) {
5050
            return true;
5151
          }
M src/main/java/com/keenwrite/preferences/Workspace.java
33
44
import com.keenwrite.constants.Constants;
5
import com.keenwrite.sigils.Tokens;
5
import com.keenwrite.sigils.Sigils;
66
import javafx.application.Platform;
77
import javafx.beans.property.*;
...
9898
    entry( KEY_UI_RECENT_DOCUMENT, asFileProperty( DOCUMENT_DEFAULT ) ),
9999
    entry( KEY_UI_RECENT_DEFINITION, asFileProperty( DEFINITION_DEFAULT ) ),
100
    entry( KEY_UI_RECENT_EXPORT, asFileProperty( PDF_DEFAULT ) ),
100101
101102
    //@formatter:off
...
300301
  }
301302
302
  public Tokens toTokens( final Key began, final Key ended ) {
303
  public Sigils toSigils( final Key began, final Key ended ) {
303304
    assert began != null;
304305
    assert ended != null;
305
    return new Tokens( stringProperty( began ), stringProperty( ended ) );
306
    return new Sigils( stringProperty( began ), stringProperty( ended ) );
306307
  }
307308
M src/main/java/com/keenwrite/preferences/WorkspaceKeys.java
5555
  public static final Key KEY_UI_RECENT_DOCUMENT = key( KEY_UI_RECENT, "document" );
5656
  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" );
5758
5859
  public static final Key KEY_UI_FILES = key( KEY_UI, "files" );
M src/main/java/com/keenwrite/preview/FlyingSaucerPanel.java
22
package com.keenwrite.preview;
33
4
import com.keenwrite.events.FileOpenEvent;
5
import com.keenwrite.events.HyperlinkOpenEvent;
46
import com.keenwrite.ui.adapters.DocumentAdapter;
57
import javafx.beans.property.BooleanProperty;
...
1820
import java.net.URI;
1921
20
import static com.keenwrite.events.FileOpenEvent.fireFileOpenEvent;
21
import static com.keenwrite.events.HyperlinkOpenEvent.fireHyperlinkOpenEvent;
2222
import static com.keenwrite.events.StatusEvent.clue;
2323
import static com.keenwrite.util.ProtocolScheme.getProtocol;
...
7575
7676
        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 );
7979
        }
8080
      } catch( final Exception ex ) {
M src/main/java/com/keenwrite/preview/HtmlPreview.java
33
44
import com.keenwrite.dom.DocumentConverter;
5
import com.keenwrite.events.DocumentChangedEvent;
56
import com.keenwrite.events.ScrollLockEvent;
67
import com.keenwrite.preferences.LocaleProperty;
...
1920
import java.util.Locale;
2021
21
import static com.keenwrite.Messages.get;
2222
import static com.keenwrite.constants.Constants.*;
2323
import static com.keenwrite.events.Bus.register;
24
import static com.keenwrite.events.DocumentChangedEvent.fireDocumentChangedEvent;
2524
import static com.keenwrite.events.ScrollLockEvent.fireScrollLockEvent;
2625
import static com.keenwrite.events.StatusEvent.clue;
...
162161
    invokeLater( () -> mPreview.render( doc, uri ) );
163162
164
    fireDocumentChangedEvent( html );
163
    DocumentChangedEvent.fire( html );
165164
  }
166165
...
276275
  private static URL toUrl( final Locale locale ) {
277276
    return toUrl(
278
      get(
277
      String.format(
279278
        sSettings.getSetting( STYLESHEET_PREVIEW_LOCALE, "" ),
280279
        locale.getLanguage(),
M src/main/java/com/keenwrite/processors/DefinitionProcessor.java
1515
  extends ExecutorProcessor<String> implements Function<String, String> {
1616
17
  private final Map<String, String> mDefinitions;
17
  private final ProcessorContext mContext;
1818
1919
  /**
...
2727
      final ProcessorContext context ) {
2828
    super( successor );
29
    mDefinitions = context.getResolvedMap();
29
    mContext = context;
3030
  }
3131
...
4848
   */
4949
  protected Map<String, String> getDefinitions() {
50
    return mDefinitions;
50
    return mContext.getResolvedMap();
5151
  }
5252
}
M src/main/java/com/keenwrite/processors/HtmlPreviewProcessor.java
3737
    assert html != null;
3838
39
    getHtmlPreviewPane().render( html );
39
    sHtmlPreviewPane.render( html );
4040
    return html;
41
  }
42
43
  private HtmlPreview getHtmlPreviewPane() {
44
    return sHtmlPreviewPane;
4541
  }
4642
}
M src/main/java/com/keenwrite/processors/PdfProcessor.java
3737
      final var document = TEXT_XML.createTemporaryFile( APP_TITLE_LOWERCASE );
3838
      final var pathInput = writeString( document, xhtml );
39
      final var pathOutput = mContext.getExportPath();
39
      final var pathOutput = mContext.getOutputPath();
4040
      final var typesetter = new Typesetter( mContext.getWorkspace() );
4141
M src/main/java/com/keenwrite/processors/ProcessorContext.java
55
import com.keenwrite.ExportFormat;
66
import com.keenwrite.constants.Constants;
7
import com.keenwrite.editors.TextDefinition;
78
import com.keenwrite.io.FileType;
89
import com.keenwrite.preferences.Workspace;
910
import com.keenwrite.preview.HtmlPreview;
11
import com.keenwrite.util.GenericBuilder;
12
import javafx.beans.property.ObjectProperty;
1013
14
import java.io.File;
1115
import java.nio.file.Path;
1216
import java.util.Map;
...
1923
 */
2024
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;
2827
2928
  /**
3029
   * Creates a new context for use by the {@link ProcessorFactory} when
3130
   * instantiating new {@link Processor} instances. Although all the
3231
   * parameters are required, not all {@link Processor} instances will use
3332
   * 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.
4333
   */
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,
50111
    final Workspace workspace,
51112
    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
  }
58123
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();
66151
  }
67152
68153
  public boolean isExportFormat( final ExportFormat format ) {
69
    return mExportFormat == format;
154
    return mMutator.mExportFormat == format;
70155
  }
71156
72157
  HtmlPreview getPreview() {
73
    return mHtmlPreview;
158
    return mMutator.mHtmlPreview;
74159
  }
75160
76161
  /**
77162
   * Returns the variable map of interpolated definitions.
78163
   *
79164
   * @return A map to help dereference variables.
80165
   */
81166
  Map<String, String> getResolvedMap() {
82
    return mResolvedMap;
167
    return mMutator.mTextDefinition.get().getDefinitions();
83168
  }
84169
85170
  /**
86171
   * Fully qualified file name to use when exporting (e.g., document.pdf).
87172
   *
88173
   * @return Full path to a file name.
89174
   */
90
  public Path getExportPath() {
91
    return mExportPath;
175
  public Path getOutputPath() {
176
    return mMutator.mOutputPath;
92177
  }
93178
94179
  public ExportFormat getExportFormat() {
95
    return mExportFormat;
180
    return mMutator.mExportFormat;
96181
  }
97182
98183
  /**
99184
   * Returns the current caret position in the document being edited and is
100185
   * always up-to-date.
101186
   *
102187
   * @return Caret position in the document.
103188
   */
104189
  public Caret getCaret() {
105
    return mCaret;
190
    return mMutator.mCaret;
106191
  }
107192
108193
  /**
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
111196
   * {@code null}. This will get absolute path to the file before trying to
112197
   * get te parent path, which should always be a valid path. In the unlikely
...
124209
125210
  public Path getDocumentPath() {
126
    return mDocumentPath;
211
    return mMutator.mInputPath;
127212
  }
128213
129214
  FileType getFileType() {
130215
    return lookup( getDocumentPath() );
131216
  }
132217
133218
  public Workspace getWorkspace() {
134
    return mWorkspace;
219
    return mMutator.mWorkspace;
135220
  }
136221
}
M src/main/java/com/keenwrite/processors/markdown/extensions/DocumentOutlineExtension.java
11
package com.keenwrite.processors.markdown.extensions;
22
3
import com.keenwrite.events.ParseHeadingEvent;
34
import com.keenwrite.processors.Processor;
45
import com.vladsch.flexmark.ast.Heading;
...
1516
import java.util.regex.Pattern;
1617
17
import static com.keenwrite.events.ParseHeadingEvent.fireNewHeadingEvent;
1818
import static com.keenwrite.events.ParseHeadingEvent.fireNewOutlineEvent;
1919
...
5151
        final var text = heading.substring( level );
5252
        final var offset = node.getStartOffset();
53
        fireNewHeadingEvent( level, text, offset );
53
        ParseHeadingEvent.fire( level, text, offset );
5454
      }
5555
    }
M src/main/java/com/keenwrite/processors/markdown/extensions/fences/FencedBlockExtension.java
1010
import com.keenwrite.processors.markdown.extensions.HtmlRendererAdapter;
1111
import com.vladsch.flexmark.ast.FencedCodeBlock;
12
import com.vladsch.flexmark.html.HtmlRendererOptions;
13
import com.vladsch.flexmark.html.HtmlWriter;
1214
import com.vladsch.flexmark.html.renderer.DelegatingNodeRendererFactory;
1315
import com.vladsch.flexmark.html.renderer.NodeRenderer;
16
import com.vladsch.flexmark.html.renderer.NodeRendererContext;
1417
import com.vladsch.flexmark.html.renderer.NodeRenderingHandler;
1518
import com.vladsch.flexmark.util.data.DataHolder;
...
2225
import static com.keenwrite.preferences.WorkspaceKeys.KEY_IMAGES_SERVER;
2326
import static com.vladsch.flexmark.html.HtmlRenderer.Builder;
27
import static com.vladsch.flexmark.html.renderer.CoreNodeRenderer.CODE_CONTENT;
2428
import static com.vladsch.flexmark.html.renderer.LinkType.LINK;
2529
...
112116
        }
113117
        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 );
115121
        }
116122
      } ) );
117123
118124
      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;
119209
    }
120210
  }
M src/main/java/com/keenwrite/processors/r/RVariableProcessor.java
8080
    int start = 0;
8181
82
    // Replace up to 32 occurrences before the string reallocates its buffer.
82
    // Replace up to 32 occurrences before reallocating the internal buffer.
8383
    final var sb = new StringBuilder( length + 32 );
8484
...
9393
9494
  private SigilOperator createSigilOperator( final Workspace workspace ) {
95
    final var tokens = workspace.toTokens(
95
    final var tokens = workspace.toSigils(
9696
      KEY_R_DELIM_BEGAN, KEY_R_DELIM_ENDED );
9797
    final var antecedent = createDefinitionOperator( workspace );
9898
    return new RSigilOperator( tokens, antecedent );
9999
  }
100100
101101
  private SigilOperator createDefinitionOperator( final Workspace workspace ) {
102
    final var tokens = workspace.toTokens(
102
    final var sigils = workspace.toSigils(
103103
      KEY_DEF_DELIM_BEGAN, KEY_DEF_DELIM_ENDED );
104
    return new YamlSigilOperator( tokens );
104
    return new YamlSigilOperator( sigils );
105105
  }
106106
}
M src/main/java/com/keenwrite/service/impl/DefaultSettings.java
2222
  private static final char VALUE_SEPARATOR = ',';
2323
24
  private final PropertiesConfiguration mProperties = createProperties();
24
  private final PropertiesConfiguration mProperties = loadProperties();
2525
2626
  public DefaultSettings() {
...
8686
  }
8787
88
  private PropertiesConfiguration createProperties() {
88
  private PropertiesConfiguration loadProperties() {
8989
    final var url = getPropertySource();
9090
    final var configuration = new PropertiesConfiguration();
...
103103
  }
104104
105
  protected Charset getDefaultEncoding() {
105
  private Charset getDefaultEncoding() {
106106
    return Charset.defaultCharset();
107107
  }
108108
109
  protected ListDelimiterHandler createListDelimiterHandler() {
109
  private ListDelimiterHandler createListDelimiterHandler() {
110110
    return new DefaultListDelimiterHandler( VALUE_SEPARATOR );
111111
  }
M src/main/java/com/keenwrite/sigils/RSigilOperator.java
22
package com.keenwrite.sigils;
33
4
import static com.keenwrite.sigils.YamlSigilOperator.KEY_SEPARATOR_DEF;
5
64
/**
75
 * Brackets variable names between {@link #PREFIX} and {@link #SUFFIX} sigils.
86
 */
97
public final class RSigilOperator extends SigilOperator {
10
  private static final char KEY_SEPARATOR_R = '$';
11
128
  public static final String PREFIX = "`r#";
139
  public static final char SUFFIX = '`';
10
11
  private static final char KEY_SEPARATOR_DEF = '.';
12
  private static final char KEY_SEPARATOR_R = '$';
1413
1514
  /**
...
2322
   * variable names (keys).
2423
   *
25
   * @param tokens     The starting and ending tokens.
24
   * @param sigils     The starting and ending tokens.
2625
   * @param antecedent The operator to use to undo any previous entokenizing.
2726
   */
28
  public RSigilOperator( final Tokens tokens, final SigilOperator antecedent ) {
29
    super( tokens );
27
  public RSigilOperator( final Sigils sigils, final SigilOperator antecedent ) {
28
    super( sigils );
3029
3130
    mAntecedent = antecedent;
M src/main/java/com/keenwrite/sigils/SigilOperator.java
22
package com.keenwrite.sigils;
33
4
import javafx.beans.property.SimpleStringProperty;
5
46
import java.util.function.UnaryOperator;
57
68
/**
79
 * Responsible for updating definition keys to use a machine-readable format
810
 * corresponding to the type of file being edited. This changes a definition
911
 * key name based on some criteria determined by the factory that creates
1012
 * implementations of this interface.
1113
 */
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;
1416
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();
1755
  }
1856
...
2563
   * @return The given key with the delimiters removed.
2664
   */
27
  String detoken( final String key ) {
65
  public String detoken( final String key ) {
2866
    return key;
67
  }
68
69
  public Sigils getSigils() {
70
    return mSigils;
2971
  }
3072
3173
  String getBegan() {
32
    return mTokens.getBegan();
74
    return mSigils.getBegan();
3375
  }
3476
3577
  String getEnded() {
36
    return mTokens.getEnded();
78
    return mSigils.getEnded();
3779
  }
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 );
4780
}
4881
A src/main/java/com/keenwrite/sigils/Sigils.java
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
}
138
D src/main/java/com/keenwrite/sigils/Tokens.java
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
}
381
M src/main/java/com/keenwrite/sigils/YamlSigilOperator.java
33
44
/**
5
 * Brackets definition keys with token delimiters.
5
 * Responsible for bracketing definition keys with token delimiters.
66
 */
77
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 );
3510
  }
3611
M src/main/java/com/keenwrite/spelling/impl/SymSpellSpeller.java
154154
155155
        while( (line = reader.readLine()) != null ) {
156
          final String[] tokens = line.split( "\\t" );
156
          final var tokens = line.split( "\\t" );
157157
          map.put( tokens[ 0 ], parseLong( tokens[ 1 ] ) );
158158
        }
M src/main/java/com/keenwrite/typesetting/Typesetter.java
5454
   * successful.
5555
   *
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.
5858
   * @throws IOException                 If the process could not be started.
5959
   * @throws InterruptedException        If the process was killed.
6060
   * @throws TypesetterNotFoundException When no typesetter is along the PATH.
6161
   */
62
  public void typeset( final Path in, final Path out )
62
  public void typeset( final Path inputPath, final Path outputPath )
6363
    throws IOException, InterruptedException, TypesetterNotFoundException {
6464
    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 );
6767
      final var time = currentTimeMillis();
6868
      final var success = task.typeset();
6969
7070
      clue( "Main.status.typeset.ended." + (success ? "success" : "failure"),
71
            out, since( time )
71
            outputPath, since( time )
7272
      );
7373
    }
D src/main/java/com/keenwrite/ui/actions/ApplicationActions.java
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
}
6221
M src/main/java/com/keenwrite/ui/actions/ApplicationBars.java
3434
   * Creates the main application affordances.
3535
   *
36
   * @param actions The {@link ApplicationActions} that map user interface
36
   * @param actions The {@link GuiCommands} that map user interface
3737
   *                selections to executable code.
3838
   * @return An instance of {@link MenuBar} that contains the menu.
3939
   */
40
  public static MenuBar createMenuBar( final ApplicationActions actions ) {
40
  public static MenuBar createMenuBar( final GuiCommands actions ) {
4141
    final var SEPARATOR_ACTION = new SeparatorAction();
4242
A src/main/java/com/keenwrite/ui/actions/GuiCommands.java
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
}
1632
M src/main/java/com/keenwrite/ui/dialogs/ThemePicker.java
101101
  private boolean pick() {
102102
    try {
103
      // List themes in alphabetical order (human readable by directory name).
103
      // List themes in alphabetical order (human-readable by directory name).
104104
      final var choices = new TreeMap<String, String>();
105105
      final String[] selection = new String[]{""};
M src/main/java/com/keenwrite/ui/explorer/FilePicker.java
1919
   *             to select a file.
2020
   */
21
  default void setInitialFilename( File file ) {}
21
  void setInitialFilename( File file );
2222
2323
  /**
M src/main/java/com/keenwrite/ui/explorer/FilePickerFactory.java
33
44
import com.io7m.jwheatsheaf.ui.JWFileChoosers;
5
import com.keenwrite.Messages;
56
import com.keenwrite.preferences.Workspace;
67
import javafx.beans.property.ObjectProperty;
...
9697
      }
9798
98
      //mBuilder.setTitle( get( title ) );
99
      mBuilder.setTitle( Messages.get( title ) );
99100
      mBuilder.setAction( action );
101
    }
102
103
    @Override
104
    public void setInitialFilename( final File file ) {
105
      mBuilder.setInitialFileName( file.getName() );
100106
    }
101107
M src/main/java/com/keenwrite/ui/explorer/FilesView.java
22
package com.keenwrite.ui.explorer;
33
4
import com.keenwrite.events.FileOpenEvent;
45
import com.keenwrite.ui.controls.BrowseButton;
56
import javafx.beans.property.*;
...
2021
import java.util.List;
2122
import java.util.Locale;
23
import java.util.Objects;
2224
import java.util.Optional;
2325
2426
import static com.keenwrite.constants.Constants.UI_CONTROL_SPACING;
25
import static com.keenwrite.events.FileOpenEvent.fireFileOpenEvent;
2627
import static com.keenwrite.events.StatusEvent.clue;
2728
import static com.keenwrite.ui.fonts.IconFactory.createFileIcon;
...
8788
    mDirectory.addListener( ( c, o, n ) -> updateListing( n ) );
8889
    updateListing( mDirectory.get() );
90
  }
91
92
  @Override
93
  public void setInitialFilename( final File file ) {
8994
  }
9095
...
104109
        }
105110
106
        for( final var f : directory.list() ) {
111
        for( final var f : Objects.requireNonNull( directory.list() ) ) {
107112
          if( !f.startsWith( "." ) ) {
108113
            mItems.add( pathEntry( Paths.get( directory.toString(), f ) ) );
...
128133
129134
    mDirectory.addListener( ( c, o, n ) -> {
130
      if( n != null ) { field.setText( n.getAbsolutePath() ); }
135
      if( n != null ) {field.setText( n.getAbsolutePath() );}
131136
    } );
132137
...
165170
166171
          if( file.isFile() ) {
167
            fireFileOpenEvent( path.toUri() );
172
            FileOpenEvent.fire( path.toUri() );
168173
          }
169174
          else if( file.isDirectory() ) {
...
258263
    private final StringProperty mTime;
259264
260
    protected PathEntry( final Path path ) throws IOException {
265
    private PathEntry( final Path path ) throws IOException {
261266
      this(
262267
        path,
M src/main/java/com/keenwrite/ui/heuristics/DocumentStatistics.java
33
44
import com.keenwrite.events.DocumentChangedEvent;
5
import com.keenwrite.events.WordCountEvent;
56
import com.keenwrite.preferences.Workspace;
67
import com.whitemagicsoftware.wordcount.TokenizerException;
...
1718
import static com.keenwrite.events.Bus.register;
1819
import static com.keenwrite.events.StatusEvent.clue;
19
import static com.keenwrite.events.WordCountEvent.fireWordCountEvent;
2020
import static com.keenwrite.preferences.WorkspaceKeys.KEY_LANGUAGE_LOCALE;
2121
import static com.keenwrite.preferences.WorkspaceKeys.KEY_UI_FONT_EDITOR_NAME;
...
9090
        );
9191
92
        fireWordCountEvent( wordCount );
92
        WordCountEvent.fire( wordCount );
9393
      } );
9494
    } catch( final TokenizerException ex ) {
M src/main/java/com/keenwrite/ui/outline/DocumentOutline.java
22
33
import com.keenwrite.events.Bus;
4
import com.keenwrite.events.CaretNavigationEvent;
45
import com.keenwrite.events.ParseHeadingEvent;
56
import javafx.scene.Node;
67
import javafx.scene.control.TreeCell;
78
import javafx.scene.control.TreeItem;
89
import javafx.scene.control.TreeView;
910
import javafx.util.Callback;
1011
import org.greenrobot.eventbus.Subscribe;
1112
1213
import static com.keenwrite.events.Bus.register;
13
import static com.keenwrite.events.CaretNavigationEvent.fireCaretNavigationEvent;
1414
import static com.keenwrite.ui.fonts.IconFactory.createGraphic;
1515
import static javafx.application.Platform.runLater;
...
4646
        cell.addEventFilter( MOUSE_PRESSED, event -> {
4747
          if( event.getButton() == PRIMARY && event.getClickCount() % 2 == 0 ) {
48
            fireCaretNavigationEvent( cell.getItem().getOffset() );
48
            CaretNavigationEvent.fire( cell.getItem().getOffset() );
4949
            event.consume();
5050
          }
A src/main/java/com/keenwrite/util/InterpolatingMap.java
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
}
196
M src/main/resources/com/keenwrite/settings.properties
5050
file.default.definition=variables.yaml
5151
52
# Default file name to be replaced by the most
53
# recently exported file name.
54
file.default.pdf=untitled.pdf
55
5256
# ########################################################################
5357
# File name Extensions
M src/main/resources/lexicons/en.txt
Binary file
M src/test/java/com/keenwrite/definition/TreeViewTest.java
77
import com.keenwrite.preferences.Workspace;
88
import com.keenwrite.preview.HtmlPreview;
9
import com.keenwrite.sigils.Sigils;
10
import com.keenwrite.sigils.YamlSigilOperator;
911
import com.panemu.tiwulfx.control.dock.DetachableTabPane;
1012
import javafx.application.Application;
1113
import javafx.beans.property.SimpleObjectProperty;
14
import javafx.beans.property.SimpleStringProperty;
1215
import javafx.event.Event;
1316
import javafx.event.EventHandler;
...
2124
import org.testfx.framework.junit5.Start;
2225
26
import static com.keenwrite.constants.Constants.DEF_DELIM_BEGAN_DEFAULT;
27
import static com.keenwrite.constants.Constants.DEF_DELIM_ENDED_DEFAULT;
2328
import static com.keenwrite.util.FontLoader.initFonts;
2429
...
4853
    final var mainPane = new SplitPane();
4954
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 );
5059
    final var transformer = new YamlTreeTransformer();
51
    final var editor = new DefinitionEditor( transformer );
60
    final var editor = new DefinitionEditor( transformer, operator );
5261
5362
    final var tabPane1 = new DetachableTabPane();
M src/test/java/com/keenwrite/processors/markdown/ImageLinkExtensionTest.java
1111
import com.vladsch.flexmark.html.HtmlRenderer;
1212
import com.vladsch.flexmark.parser.Parser;
13
import javafx.beans.property.SimpleObjectProperty;
1314
import javafx.stage.Stage;
1415
import org.junit.jupiter.api.Test;
...
2627
import java.util.Map;
2728
28
import static com.keenwrite.constants.Constants.DOCUMENT_DEFAULT;
2929
import static com.keenwrite.ExportFormat.NONE;
30
import static com.keenwrite.constants.Constants.DOCUMENT_DEFAULT;
3031
import static java.lang.String.format;
3132
import static javafx.application.Platform.runLater;
...
149150
    return new ProcessorContext(
150151
      mPreview,
151
      new HashMap<>(),
152
      new SimpleObjectProperty<>(),
152153
      documentPath,
153154
      null,
M src/test/java/com/keenwrite/sigils/RSigilOperatorTest.java
3535
  }
3636
37
  private StringProperty createToken( final String token ) {
37
  private StringProperty createSigil( final String token ) {
3838
    return new SimpleStringProperty( token );
3939
  }
4040
41
  private Tokens createRTokens() {
42
    return createTokens( "x(", ")" );
41
  private Sigils createRSigils() {
42
    return createSigils( "x(", ")" );
4343
  }
4444
45
  private Tokens createYamlTokens() {
46
    return createTokens( "{{", "}}" );
45
  private Sigils createYamlSigils() {
46
    return createSigils( "{{", "}}" );
4747
  }
4848
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 ) );
5151
  }
5252
5353
  private YamlSigilOperator createYamlSigilOperator() {
54
    return new YamlSigilOperator( createYamlTokens() );
54
    return new YamlSigilOperator( createYamlSigils() );
5555
  }
5656
5757
  private RSigilOperator createRSigilOperator() {
58
    return new RSigilOperator( createRTokens(), createYamlSigilOperator() );
58
    return new RSigilOperator( createRSigils(), createYamlSigilOperator() );
5959
  }
6060
}