Dave Jarvis' Repositories

git clone https://repo.autonoma.ca/repo/keenwrite.git
M R/INSTALL.md
2929
    `r#pluralize( "mouse" )` - mice
3030
    `r#pluralize( "buzz" )` - buzzes
31
    `r#pluralize( "bus" )` - busses
31
    `r#pluralize( "bus" )` - buses
3232
3333
# possessive.R
M README.md
3636
### Other
3737
38
On other platforms, start the application as follows:
38
On other platforms, such as MacOS, start the application as follows:
3939
4040
1. Download the *Full version* of the Java Runtime Environment, [JRE 19](https://bell-sw.com/pages/downloads).
41
1. Install the JRE.
42
1. Open a terminal window.
41
1. Install the JRE (include JRE's `bin` directory in the `PATH` environment variable).
42
1. Open a new terminal.
4343
1. Verify the installation: `java -version`
44
1. Download [keenwrite.jar](https://github.com/DaveJarvis/keenwrite/releases/latest/download/keenwrite.jar).
4445
1. Download [keenwrite.sh](https://raw.githubusercontent.com/DaveJarvis/keenwrite/master/keenwrite.sh).
45
1. Make `keenwrite.sh` executable.
46
1. Place the `.jar` and `.sh` in the same directory.
47
1. Make `keenwrite.sh` executable: `chmod +x keenwrite.sh`
4648
1. Run: `./keenwrite.sh`
4749
M src/main/java/com/keenwrite/MainApp.java
55
import com.keenwrite.events.HyperlinkOpenEvent;
66
import com.keenwrite.preferences.Workspace;
7
import com.keenwrite.spelling.impl.Lexicon;
78
import javafx.application.Application;
89
import javafx.event.Event;
...
2526
import static javafx.scene.input.KeyEvent.KEY_PRESSED;
2627
import static javafx.scene.input.KeyEvent.KEY_RELEASED;
28
import static javafx.stage.WindowEvent.WINDOW_SHOWN;
2729
2830
/**
...
106108
    // because it interacts with GUI properties.
107109
    mWorkspace = new Workspace();
110
111
    // The locale was already loaded when the workspace was created. This
112
    // ensures that when the locale preference changes, a new spellchecker
113
    // instance will be loaded and applied.
114
    final var property = mWorkspace.localeProperty( KEY_LANGUAGE_LOCALE );
115
    property.addListener( ( c, o, n ) -> readLexicon() );
108116
109117
    initFonts();
110118
    initState( stage );
111119
    initStage( stage );
112120
    initIcons( stage );
113121
    initScene( stage );
114122
123
    // Load the lexicon and check all the documents after all files are open.
124
    stage.addEventFilter( WINDOW_SHOWN, event -> readLexicon() );
115125
    stage.show();
116126
...
182192
  public void handle( final HyperlinkOpenEvent event ) {
183193
    getHostServices().showDocument( event.getUri().toString() );
194
  }
195
196
  /**
197
   * This will load the lexicon for the user's preferred locale and fire
198
   * an event when the all entries in the lexicon have been loaded.
199
   */
200
  private void readLexicon() {
201
    Lexicon.read( mWorkspace.getLocale() );
184202
  }
185203
M src/main/java/com/keenwrite/MainPane.java
1212
import com.keenwrite.editors.markdown.MarkdownEditor;
1313
import com.keenwrite.events.*;
14
import com.keenwrite.io.MediaType;
15
import com.keenwrite.preferences.Workspace;
16
import com.keenwrite.preview.HtmlPreview;
17
import com.keenwrite.processors.HtmlPreviewProcessor;
18
import com.keenwrite.processors.Processor;
19
import com.keenwrite.processors.ProcessorContext;
20
import com.keenwrite.processors.ProcessorFactory;
21
import com.keenwrite.processors.r.Engine;
22
import com.keenwrite.processors.r.RBootstrapController;
23
import com.keenwrite.service.events.Notifier;
24
import com.keenwrite.ui.explorer.FilePickerFactory;
25
import com.keenwrite.ui.heuristics.DocumentStatistics;
26
import com.keenwrite.ui.outline.DocumentOutline;
27
import com.keenwrite.util.GenericBuilder;
28
import com.panemu.tiwulfx.control.dock.DetachableTab;
29
import com.panemu.tiwulfx.control.dock.DetachableTabPane;
30
import javafx.application.Platform;
31
import javafx.beans.property.*;
32
import javafx.collections.ListChangeListener;
33
import javafx.concurrent.Task;
34
import javafx.event.ActionEvent;
35
import javafx.event.Event;
36
import javafx.event.EventHandler;
37
import javafx.scene.Node;
38
import javafx.scene.Scene;
39
import javafx.scene.control.*;
40
import javafx.scene.control.TreeItem.TreeModificationEvent;
41
import javafx.scene.input.KeyEvent;
42
import javafx.scene.layout.FlowPane;
43
import javafx.stage.Stage;
44
import javafx.stage.Window;
45
import org.greenrobot.eventbus.Subscribe;
46
47
import java.io.File;
48
import java.io.FileNotFoundException;
49
import java.nio.file.Path;
50
import java.util.*;
51
import java.util.concurrent.ExecutorService;
52
import java.util.concurrent.ScheduledExecutorService;
53
import java.util.concurrent.ScheduledFuture;
54
import java.util.concurrent.atomic.AtomicBoolean;
55
import java.util.concurrent.atomic.AtomicReference;
56
import java.util.function.Function;
57
import java.util.stream.Collectors;
58
59
import static com.keenwrite.ExportFormat.NONE;
60
import static com.keenwrite.Launcher.terminate;
61
import static com.keenwrite.Messages.get;
62
import static com.keenwrite.constants.Constants.*;
63
import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG_NODE;
64
import static com.keenwrite.events.Bus.register;
65
import static com.keenwrite.events.StatusEvent.clue;
66
import static com.keenwrite.io.MediaType.*;
67
import static com.keenwrite.preferences.AppKeys.*;
68
import static com.keenwrite.processors.IdentityProcessor.IDENTITY;
69
import static com.keenwrite.processors.ProcessorContext.Mutator;
70
import static com.keenwrite.processors.ProcessorContext.builder;
71
import static com.keenwrite.processors.ProcessorFactory.createProcessors;
72
import static java.lang.String.format;
73
import static java.lang.System.getProperty;
74
import static java.util.concurrent.Executors.newFixedThreadPool;
75
import static java.util.concurrent.Executors.newScheduledThreadPool;
76
import static java.util.concurrent.TimeUnit.SECONDS;
77
import static java.util.stream.Collectors.groupingBy;
78
import static javafx.application.Platform.runLater;
79
import static javafx.scene.control.Alert.AlertType.ERROR;
80
import static javafx.scene.control.ButtonType.*;
81
import static javafx.scene.control.TabPane.TabClosingPolicy.ALL_TABS;
82
import static javafx.scene.input.KeyCode.SPACE;
83
import static javafx.scene.input.KeyCombination.CONTROL_DOWN;
84
import static javafx.util.Duration.millis;
85
import static javax.swing.SwingUtilities.invokeLater;
86
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
87
88
/**
89
 * Responsible for wiring together the main application components for a
90
 * particular {@link Workspace} (project). These include the definition views,
91
 * text editors, and preview pane along with any corresponding controllers.
92
 */
93
public final class MainPane extends SplitPane {
94
95
  private static final ExecutorService sExecutor = newFixedThreadPool( 1 );
96
  private static final Notifier sNotifier = Services.load( Notifier.class );
97
98
  /**
99
   * Used when opening files to determine how each file should be binned and
100
   * therefore what tab pane to be opened within.
101
   */
102
  private static final Set<MediaType> PLAIN_TEXT_FORMAT = Set.of(
103
    TEXT_MARKDOWN, TEXT_R_MARKDOWN, UNDEFINED
104
  );
105
106
  private final ScheduledExecutorService mSaver = newScheduledThreadPool( 1 );
107
  private final AtomicReference<ScheduledFuture<?>> mSaveTask =
108
    new AtomicReference<>();
109
110
  /**
111
   * Prevents re-instantiation of processing classes.
112
   */
113
  private final Map<TextResource, Processor<String>> mProcessors =
114
    new HashMap<>();
115
116
  private final Workspace mWorkspace;
117
118
  /**
119
   * Groups similar file type tabs together.
120
   */
121
  private final List<TabPane> mTabPanes = new ArrayList<>();
122
123
  /**
124
   * Renders the actively selected plain text editor tab.
125
   */
126
  private final HtmlPreview mPreview;
127
128
  /**
129
   * Provides an interactive document outline.
130
   */
131
  private final DocumentOutline mOutline = new DocumentOutline();
132
133
  /**
134
   * Changing the active editor fires the value changed event. This allows
135
   * refreshes to happen when external definitions are modified and need to
136
   * trigger the processing chain.
137
   */
138
  private final ObjectProperty<TextEditor> mTextEditor =
139
    createActiveTextEditor();
140
141
  /**
142
   * Changing the active definition editor fires the value changed event. This
143
   * allows refreshes to happen when external definitions are modified and need
144
   * to trigger the processing chain.
145
   */
146
  private final ObjectProperty<TextDefinition> mDefinitionEditor;
147
148
  /**
149
   * Called when the definition data is changed.
150
   */
151
  private final EventHandler<TreeModificationEvent<Event>> mTreeHandler =
152
    event -> {
153
      process( getTextEditor() );
154
      save( getTextDefinition() );
155
    };
156
157
  /**
158
   * Tracks the number of detached tab panels opened into their own windows,
159
   * which allows unique identification of subordinate windows by their title.
160
   * It is doubtful more than 128 windows, much less 256, will be created.
161
   */
162
  private byte mWindowCount;
163
164
  private final VariableNameInjector mVariableNameInjector;
165
166
  private final RBootstrapController mRBootstrapController;
167
168
  private final DocumentStatistics mStatistics;
169
170
  /**
171
   * Adds all content panels to the main user interface. This will load the
172
   * configuration settings from the workspace to reproduce the settings from
173
   * a previous session.
174
   */
175
  public MainPane( final Workspace workspace ) {
176
    mWorkspace = workspace;
177
    mPreview = new HtmlPreview( workspace );
178
    mStatistics = new DocumentStatistics( workspace );
179
    mTextEditor.set( new MarkdownEditor( workspace ) );
180
    mDefinitionEditor = createActiveDefinitionEditor( mTextEditor );
181
    mVariableNameInjector = new VariableNameInjector( mWorkspace );
182
    mRBootstrapController = new RBootstrapController(
183
      mWorkspace, this::getDefinitions );
184
185
    open( collect( 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: Open file names must be persisted before closing all.
193
      mWorkspace.save();
194
195
      if( closeAll() ) {
196
        Platform.exit();
197
        terminate( 0 );
198
      }
199
200
      event.consume();
201
    } ) );
202
203
    register( this );
204
    initAutosave( workspace );
205
206
    restoreSession();
207
    runLater( this::restoreFocus );
208
  }
209
210
  @Subscribe
211
  public void handle( final TextEditorFocusEvent event ) {
212
    mTextEditor.set( event.get() );
213
  }
214
215
  @Subscribe
216
  public void handle( final TextDefinitionFocusEvent event ) {
217
    mDefinitionEditor.set( event.get() );
218
  }
219
220
  /**
221
   * Typically called when a file name is clicked in the preview panel.
222
   *
223
   * @param event The event to process, must contain a valid file reference.
224
   */
225
  @Subscribe
226
  public void handle( final FileOpenEvent event ) {
227
    final File eventFile;
228
    final var eventUri = event.getUri();
229
230
    if( eventUri.isAbsolute() ) {
231
      eventFile = new File( eventUri.getPath() );
232
    }
233
    else {
234
      final var activeFile = getTextEditor().getFile();
235
      final var parent = activeFile.getParentFile();
236
237
      if( parent == null ) {
238
        clue( new FileNotFoundException( eventUri.getPath() ) );
239
        return;
240
      }
241
      else {
242
        final var parentPath = parent.getAbsolutePath();
243
        eventFile = Path.of( parentPath, eventUri.getPath() ).toFile();
244
      }
245
    }
246
247
    runLater( () -> open( eventFile ) );
248
  }
249
250
  @Subscribe
251
  public void handle( final CaretNavigationEvent event ) {
252
    runLater( () -> {
253
      final var textArea = getTextEditor();
254
      textArea.moveTo( event.getOffset() );
255
      textArea.requestFocus();
256
    } );
257
  }
258
259
  @Subscribe
260
  @SuppressWarnings( "unused" )
261
  public void handle( final ExportFailedEvent event ) {
262
    final var os = getProperty( "os.name" );
263
    final var arch = getProperty( "os.arch" ).toLowerCase();
264
    final var bits = getProperty( "sun.arch.data.model" );
265
266
    final var title = Messages.get( "Alert.typesetter.missing.title" );
267
    final var header = Messages.get( "Alert.typesetter.missing.header" );
268
    final var version = Messages.get(
269
      "Alert.typesetter.missing.version",
270
      os,
271
      arch
272
        .replaceAll( "amd.*|i.*|x86.*", "X86" )
273
        .replaceAll( "mips.*", "MIPS" )
274
        .replaceAll( "armv.*", "ARM" ),
275
      bits );
276
    final var text = Messages.get( "Alert.typesetter.missing.installer.text" );
277
278
    // Download and install ConTeXt for {0} {1} {2}-bit
279
    final var content = format( "%s %s", text, version );
280
    final var flowPane = new FlowPane();
281
    final var link = new Hyperlink( text );
282
    final var label = new Label( version );
283
    flowPane.getChildren().addAll( link, label );
284
285
    final var alert = new Alert( ERROR, content, OK );
286
    alert.setTitle( title );
287
    alert.setHeaderText( header );
288
    alert.getDialogPane().contentProperty().set( flowPane );
289
    alert.setGraphic( ICON_DIALOG_NODE );
290
291
    link.setOnAction( e -> {
292
      alert.close();
293
      final var url = Messages.get( "Alert.typesetter.missing.installer.url" );
294
      runLater( () -> HyperlinkOpenEvent.fire( url ) );
295
    } );
296
297
    alert.showAndWait();
298
  }
299
300
  @Subscribe
301
  public void handle( final InsertDefinitionEvent<String> event ) {
302
    final var leaf = event.getLeaf();
303
    final var editor = mTextEditor.get();
304
305
    mVariableNameInjector.insert( editor, leaf );
306
  }
307
308
  private void initAutosave( final Workspace workspace ) {
309
    final var rate = workspace.integerProperty( KEY_EDITOR_AUTOSAVE );
310
311
    rate.addListener(
312
      ( c, o, n ) -> {
313
        final var taskRef = mSaveTask.get();
314
315
        // Prevent multiple autosaves from running.
316
        if( taskRef != null ) {
317
          taskRef.cancel( false );
318
        }
319
320
        initAutosave( rate );
321
      }
322
    );
323
324
    // Start the save listener (avoids duplicating some code).
325
    initAutosave( rate );
326
  }
327
328
  private void initAutosave( final IntegerProperty rate ) {
329
    mSaveTask.set(
330
      mSaver.scheduleAtFixedRate(
331
        () -> {
332
          if( getTextEditor().isModified() ) {
333
            // Ensure the modified indicator is cleared by running on EDT.
334
            runLater( this::save );
335
          }
336
        }, 0, rate.intValue(), SECONDS
337
      )
338
    );
339
  }
340
341
  /**
342
   * TODO: Load divider positions from exported settings, see
343
   *   {@link #collect(SetProperty)} comment.
344
   */
345
  private double[] calculateDividerPositions() {
346
    final var ratio = 100f / getItems().size() / 100;
347
    final var positions = getDividerPositions();
348
349
    for( int i = 0; i < positions.length; i++ ) {
350
      positions[ i ] = ratio * i;
351
    }
352
353
    return positions;
354
  }
355
356
  /**
357
   * Opens all the files into the application, provided the paths are unique.
358
   * This may only be called for any type of files that a user can edit
359
   * (i.e., update and persist), such as definitions and text files.
360
   *
361
   * @param files The list of files to open.
362
   */
363
  public void open( final List<File> files ) {
364
    files.forEach( this::open );
365
  }
366
367
  /**
368
   * This opens the given file. Since the preview pane is not a file that
369
   * can be opened, it is safe to add a listener to the detachable pane.
370
   * This will exit early if the given file is not a regular file (i.e., a
371
   * directory).
372
   *
373
   * @param inputFile The file to open.
374
   */
375
  private void open( final File inputFile ) {
376
    // Prevent opening directories (a non-existent "untitled.md" is fine).
377
    if( !inputFile.isFile() && inputFile.exists() ) {
378
      return;
379
    }
380
381
    final var tab = createTab( inputFile );
382
    final var node = tab.getContent();
383
    final var mediaType = MediaType.valueFrom( inputFile );
384
    final var tabPane = obtainTabPane( mediaType );
385
386
    tab.setTooltip( createTooltip( inputFile ) );
387
    tabPane.setFocusTraversable( false );
388
    tabPane.setTabClosingPolicy( ALL_TABS );
389
    tabPane.getTabs().add( tab );
390
391
    // Attach the tab scene factory for new tab panes.
392
    if( !getItems().contains( tabPane ) ) {
393
      addTabPane(
394
        node instanceof TextDefinition ? 0 : getItems().size(), tabPane
395
      );
396
    }
397
398
    if( inputFile.isFile() ) {
399
      getRecentFiles().add( inputFile.getAbsolutePath() );
400
    }
401
  }
402
403
  /**
404
   * Gives focus to the most recently edited document and attempts to move
405
   * the caret to the most recently known offset into said document.
406
   */
407
  private void restoreSession() {
408
    final var workspace = getWorkspace();
409
    final var file = workspace.fileProperty( KEY_UI_RECENT_DOCUMENT );
410
    final var offset = workspace.integerProperty( KEY_UI_RECENT_OFFSET );
411
412
    for( final var pane : mTabPanes ) {
413
      for( final var tab : pane.getTabs() ) {
414
        final var tooltip = tab.getTooltip();
415
416
        if( tooltip != null ) {
417
          final var tabName = tooltip.getText();
418
          final var fileName = file.getValue().toString();
419
420
          if( tabName.equalsIgnoreCase( fileName ) ) {
421
            final var node = tab.getContent();
422
423
            pane.getSelectionModel().select( tab );
424
            node.requestFocus();
425
426
            if( node instanceof TextEditor editor ) {
427
              editor.moveTo( offset.getValue() );
428
            }
429
430
            break;
431
          }
432
        }
433
      }
434
    }
435
  }
436
437
  /**
438
   * Sets the focus to the middle pane, which contains the text editor tabs.
439
   */
440
  private void restoreFocus() {
441
    // Work around a bug where focusing directly on the middle pane results
442
    // in the R engine not loading variables properly.
443
    mTabPanes.get( 0 ).requestFocus();
444
445
    // This is the only line that should be required.
446
    mTabPanes.get( 1 ).requestFocus();
447
  }
448
449
  /**
450
   * Opens a new text editor document using the default document file name.
451
   */
452
  public void newTextEditor() {
453
    open( DOCUMENT_DEFAULT );
454
  }
455
456
  /**
457
   * Opens a new definition editor document using the default definition
458
   * file name.
459
   */
460
  public void newDefinitionEditor() {
461
    open( DEFINITION_DEFAULT );
462
  }
463
464
  /**
465
   * Iterates over all tab panes to find all {@link TextEditor}s and request
466
   * that they save themselves.
467
   */
468
  public void saveAll() {
469
    mTabPanes.forEach(
470
      tp -> tp.getTabs().forEach( tab -> {
471
        final var node = tab.getContent();
472
473
        if( node instanceof final TextEditor editor ) {
474
          save( editor );
475
        }
476
      } )
477
    );
478
  }
479
480
  /**
481
   * Requests that the active {@link TextEditor} saves itself. Don't bother
482
   * checking if modified first because if the user swaps external media from
483
   * an external source (e.g., USB thumb drive), save should not second-guess
484
   * the user: save always re-saves. Also, it's less code.
485
   */
486
  public void save() {
487
    save( getTextEditor() );
488
  }
489
490
  /**
491
   * Saves the active {@link TextEditor} under a new name.
492
   *
493
   * @param files The new active editor {@link File} reference, must contain
494
   *              at least one element.
495
   */
496
  public void saveAs( final List<File> files ) {
497
    assert files != null;
498
    assert !files.isEmpty();
499
    final var editor = getTextEditor();
500
    final var tab = getTab( editor );
501
    final var file = files.get( 0 );
502
503
    editor.rename( file );
504
    tab.ifPresent( t -> {
505
      t.setText( editor.getFilename() );
506
      t.setTooltip( createTooltip( file ) );
507
    } );
508
509
    save();
510
  }
511
512
  /**
513
   * Saves the given {@link TextResource} to a file. This is typically used
514
   * to save either an instance of {@link TextEditor} or {@link TextDefinition}.
515
   *
516
   * @param resource The resource to export.
517
   */
518
  private void save( final TextResource resource ) {
519
    try {
520
      resource.save();
521
    } catch( final Exception ex ) {
522
      clue( ex );
523
      sNotifier.alert(
524
        getWindow(), resource.getPath(), "TextResource.saveFailed", ex
525
      );
526
    }
527
  }
528
529
  /**
530
   * Closes all open {@link TextEditor}s; all {@link TextDefinition}s stay open.
531
   *
532
   * @return {@code true} when all editors, modified or otherwise, were
533
   * permitted to close; {@code false} when one or more editors were modified
534
   * and the user requested no closing.
535
   */
536
  public boolean closeAll() {
537
    var closable = true;
538
539
    for( final var tabPane : mTabPanes ) {
540
      final var tabIterator = tabPane.getTabs().iterator();
541
542
      while( tabIterator.hasNext() ) {
543
        final var tab = tabIterator.next();
544
        final var resource = tab.getContent();
545
546
        // The definition panes auto-save, so being specific here prevents
547
        // closing the definitions in the situation where the user wants to
548
        // continue editing (i.e., possibly save unsaved work).
549
        if( !(resource instanceof TextEditor) ) {
550
          continue;
551
        }
552
553
        if( canClose( (TextEditor) resource ) ) {
554
          tabIterator.remove();
555
          close( tab );
556
        }
557
        else {
558
          closable = false;
559
        }
560
      }
561
    }
562
563
    return closable;
564
  }
565
566
  /**
567
   * Calls the tab's {@link Tab#getOnClosed()} handler to carry out a close
568
   * event.
569
   *
570
   * @param tab The {@link Tab} that was closed.
571
   */
572
  private void close( final Tab tab ) {
573
    assert tab != null;
574
575
    final var handler = tab.getOnClosed();
576
577
    if( handler != null ) {
578
      handler.handle( new ActionEvent() );
579
    }
580
  }
581
582
  /**
583
   * Closes the active tab; delegates to {@link #canClose(TextResource)}.
584
   */
585
  public void close() {
586
    final var editor = getTextEditor();
587
588
    if( canClose( editor ) ) {
589
      close( editor );
590
    }
591
  }
592
593
  /**
594
   * Closes the given {@link TextResource}. This must not be called from within
595
   * a loop that iterates over the tab panes using {@code forEach}, lest a
596
   * concurrent modification exception be thrown.
597
   *
598
   * @param resource The {@link TextResource} to close, without confirming with
599
   *                 the user.
600
   */
601
  private void close( final TextResource resource ) {
602
    getTab( resource ).ifPresent(
603
      tab -> {
604
        close( tab );
605
        tab.getTabPane().getTabs().remove( tab );
606
      }
607
    );
608
  }
609
610
  /**
611
   * Answers whether the given {@link TextResource} may be closed.
612
   *
613
   * @param editor The {@link TextResource} to try closing.
614
   * @return {@code true} when the editor may be closed; {@code false} when
615
   * the user has requested to keep the editor open.
616
   */
617
  private boolean canClose( final TextResource editor ) {
618
    final var editorTab = getTab( editor );
619
    final var canClose = new AtomicBoolean( true );
620
621
    if( editor.isModified() ) {
622
      final var filename = new StringBuilder();
623
      editorTab.ifPresent( tab -> filename.append( tab.getText() ) );
624
625
      final var message = sNotifier.createNotification(
626
        Messages.get( "Alert.file.close.title" ),
627
        Messages.get( "Alert.file.close.text" ),
628
        filename.toString()
629
      );
630
631
      final var dialog = sNotifier.createConfirmation( getWindow(), message );
632
633
      dialog.showAndWait().ifPresent(
634
        save -> canClose.set( save == YES ? editor.save() : save == NO )
635
      );
636
    }
637
638
    return canClose.get();
639
  }
640
641
  private ObjectProperty<TextEditor> createActiveTextEditor() {
642
    final var editor = new SimpleObjectProperty<TextEditor>();
643
644
    editor.addListener( ( c, o, n ) -> {
645
      if( n != null ) {
646
        mPreview.setBaseUri( n.getPath() );
647
        process( n );
648
      }
649
    } );
650
651
    return editor;
652
  }
653
654
  /**
655
   * Adds the HTML preview tab to its own, singular tab pane.
656
   */
657
  public void viewPreview() {
658
    viewTab( mPreview, TEXT_HTML, "Pane.preview.title" );
659
  }
660
661
  /**
662
   * Adds the document outline tab to its own, singular tab pane.
663
   */
664
  public void viewOutline() {
665
    viewTab( mOutline, APP_DOCUMENT_OUTLINE, "Pane.outline.title" );
666
  }
667
668
  public void viewStatistics() {
669
    viewTab( mStatistics, APP_DOCUMENT_STATISTICS, "Pane.statistics.title" );
670
  }
671
672
  public void viewFiles() {
673
    try {
674
      final var factory = new FilePickerFactory( getWorkspace() );
675
      final var fileManager = factory.createModeless();
676
      viewTab( fileManager, APP_FILE_MANAGER, "Pane.files.title" );
677
    } catch( final Exception ex ) {
678
      clue( ex );
679
    }
680
  }
681
682
  private void viewTab(
683
    final Node node, final MediaType mediaType, final String key ) {
684
    final var tabPane = obtainTabPane( mediaType );
685
686
    for( final var tab : tabPane.getTabs() ) {
687
      if( tab.getContent() == node ) {
688
        return;
689
      }
690
    }
691
692
    tabPane.getTabs().add( createTab( get( key ), node ) );
693
    addTabPane( tabPane );
694
  }
695
696
  public void viewRefresh() {
697
    mPreview.refresh();
698
    Engine.clear();
699
    mRBootstrapController.update();
700
  }
701
702
  /**
703
   * Returns the tab that contains the given {@link TextEditor}.
704
   *
705
   * @param editor The {@link TextEditor} instance to find amongst the tabs.
706
   * @return The first tab having content that matches the given tab.
707
   */
708
  private Optional<Tab> getTab( final TextResource editor ) {
709
    return mTabPanes.stream()
710
                    .flatMap( pane -> pane.getTabs().stream() )
711
                    .filter( tab -> editor.equals( tab.getContent() ) )
712
                    .findFirst();
713
  }
714
715
  /**
716
   * Creates a new {@link DefinitionEditor} wrapped in a listener that
717
   * is used to detect when the active {@link DefinitionEditor} has changed.
718
   * Upon changing, the variables are interpolated and the active text editor
719
   * is refreshed.
720
   *
721
   * @param textEditor Text editor to update with the revised resolved map.
722
   * @return A newly configured property that represents the active
723
   * {@link DefinitionEditor}, never null.
724
   */
725
  private ObjectProperty<TextDefinition> createActiveDefinitionEditor(
726
    final ObjectProperty<TextEditor> textEditor ) {
727
    final var defEditor = new SimpleObjectProperty<>(
728
      createDefinitionEditor()
729
    );
730
731
    defEditor.addListener( ( c, o, n ) -> {
732
      final var editor = textEditor.get();
733
734
      if( editor.isMediaType( TEXT_R_MARKDOWN ) ) {
735
        // Initialize R before the editor is added.
736
        mRBootstrapController.update();
737
      }
738
739
      process( editor );
740
    } );
741
742
    return defEditor;
743
  }
744
745
  private Tab createTab( final String filename, final Node node ) {
746
    return new DetachableTab( filename, node );
747
  }
748
749
  private Tab createTab( final File file ) {
750
    final var r = createTextResource( file );
751
    final var tab = createTab( r.getFilename(), r.getNode() );
752
753
    r.modifiedProperty().addListener(
754
      ( c, o, n ) -> tab.setText( r.getFilename() + (n ? "*" : "") )
755
    );
756
757
    // This is called when either the tab is closed by the user clicking on
758
    // the tab's close icon or when closing (all) from the file menu.
759
    tab.setOnClosed(
760
      __ -> getRecentFiles().remove( file.getAbsolutePath() )
761
    );
762
763
    // When closing a tab, give focus to the newly revealed tab.
764
    tab.selectedProperty().addListener( ( c, o, n ) -> {
765
      if( n != null && n ) {
766
        final var pane = tab.getTabPane();
767
768
        if( pane != null ) {
769
          pane.requestFocus();
770
        }
771
      }
772
    } );
773
774
    tab.tabPaneProperty().addListener( ( cPane, oPane, nPane ) -> {
775
      if( nPane != null ) {
776
        nPane.focusedProperty().addListener( ( c, o, n ) -> {
777
          if( n != null && n ) {
778
            final var selected = nPane.getSelectionModel().getSelectedItem();
779
            final var node = selected.getContent();
780
            node.requestFocus();
781
          }
782
        } );
783
      }
784
    } );
785
786
    return tab;
787
  }
788
789
  /**
790
   * Creates bins for the different {@link MediaType}s, which eventually are
791
   * added to the UI as separate tab panes. If ever a general-purpose scene
792
   * exporter is developed to serialize a scene to an FXML file, this could
793
   * be replaced by such a class.
794
   * <p>
795
   * When binning the files, this makes sure that at least one file exists
796
   * for every type. If the user has opted to close a particular type (such
797
   * as the definition pane), the view will suppressed elsewhere.
798
   * </p>
799
   * <p>
800
   * The order that the binned files are returned will be reflected in the
801
   * order that the corresponding panes are rendered in the UI.
802
   * </p>
803
   *
804
   * @param paths The file paths to bin according to their type.
805
   * @return An in-order list of files, first by structured definition files,
806
   * then by plain text documents.
807
   */
808
  private List<File> collect( final SetProperty<String> paths ) {
809
    // Treat all files destined for the text editor as plain text documents
810
    // so that they are added to the same pane. Grouping by TEXT_PLAIN is a
811
    // bit arbitrary, but means explicitly capturing TEXT_PLAIN isn't needed.
812
    final Function<MediaType, MediaType> bin =
813
      m -> PLAIN_TEXT_FORMAT.contains( m ) ? TEXT_PLAIN : m;
814
815
    // Create two groups: YAML files and plain text files. The order that
816
    // the elements are listed in the enumeration for media types determines
817
    // what files are loaded first. Variable definitions come before all other
818
    // plain text documents.
819
    final var bins = paths
820
      .stream()
821
      .collect(
822
        groupingBy(
823
          path -> bin.apply( MediaType.fromFilename( path ) ),
824
          () -> new TreeMap<>( Enum::compareTo ),
825
          Collectors.toList()
826
        )
827
      );
828
829
    bins.putIfAbsent( TEXT_YAML, List.of( DEFINITION_DEFAULT.toString() ) );
830
    bins.putIfAbsent( TEXT_PLAIN, List.of( DOCUMENT_DEFAULT.toString() ) );
831
832
    final var result = new LinkedList<File>();
833
834
    // Ensure that the same types are listed together (keep insertion order).
835
    bins.forEach( ( mediaType, files ) -> result.addAll(
836
      files.stream().map( File::new ).toList() )
837
    );
838
839
    return result;
840
  }
841
842
  /**
843
   * Force the active editor to update, which will cause the processor
844
   * to re-evaluate the interpolated definition map thereby updating the
845
   * preview pane.
846
   *
847
   * @param editor Contains the source document to update in the preview pane.
848
   */
849
  private void process( final TextEditor editor ) {
850
    // Ensure processing does not run on the JavaFX thread, which frees the
851
    // text editor immediately for caret movement. The preview will have a
852
    // slight delay when catching up to the caret position.
853
    final var task = new Task<Void>() {
854
      @Override
855
      public Void call() {
856
        try {
857
          final var p = mProcessors.getOrDefault( editor, IDENTITY );
858
          p.apply( editor == null ? "" : editor.getText() );
859
        } catch( final Exception ex ) {
860
          clue( ex );
861
        }
862
863
        return null;
864
      }
865
    };
866
867
    // TODO: Each time the editor successfully runs the processor the task is
868
    //   considered successful. Due to the rapid-fire nature of processing
869
    //   (e.g., keyboard navigation, fast typing), it isn't necessary to
870
    //   scroll each time.
871
    //   The algorithm:
872
    //   1. Peek at the oldest time.
873
    //   2. If the difference between the oldest time and current time exceeds
874
    //      250 milliseconds, then invoke the scrolling.
875
    //   3. Insert the current time into the circular queue.
876
    task.setOnSucceeded(
877
      e -> invokeLater( () -> mPreview.scrollTo( CARET_ID ) )
878
    );
879
880
    // Prevents multiple process requests from executing simultaneously (due
881
    // to having a restricted queue size).
882
    sExecutor.execute( task );
883
  }
884
885
  /**
886
   * Lazily creates a {@link TabPane} configured to listen for tab select
887
   * events. The tab pane is associated with a given media type so that
888
   * similar files can be grouped together.
889
   *
890
   * @param mediaType The media type to associate with the tab pane.
891
   * @return An instance of {@link TabPane} that will handle tab docking.
892
   */
893
  private TabPane obtainTabPane( final MediaType mediaType ) {
894
    for( final var pane : mTabPanes ) {
895
      for( final var tab : pane.getTabs() ) {
896
        final var node = tab.getContent();
897
898
        if( node instanceof TextResource r && r.supports( mediaType ) ) {
899
          return pane;
900
        }
901
      }
902
    }
903
904
    final var pane = createTabPane();
905
    mTabPanes.add( pane );
906
    return pane;
907
  }
908
909
  /**
910
   * Creates an initialized {@link TabPane} instance.
911
   *
912
   * @return A new {@link TabPane} with all listeners configured.
913
   */
914
  private TabPane createTabPane() {
915
    final var tabPane = new DetachableTabPane();
916
917
    initStageOwnerFactory( tabPane );
918
    initTabListener( tabPane );
919
920
    return tabPane;
921
  }
922
923
  /**
924
   * When any {@link DetachableTabPane} is detached from the main window,
925
   * the stage owner factory must be given its parent window, which will
926
   * own the child window. The parent window is the {@link MainPane}'s
927
   * {@link Scene}'s {@link Window} instance.
928
   *
929
   * <p>
930
   * This will derives the new title from the main window title, incrementing
931
   * the window count to help uniquely identify the child windows.
932
   * </p>
933
   *
934
   * @param tabPane A new {@link DetachableTabPane} to configure.
935
   */
936
  private void initStageOwnerFactory( final DetachableTabPane tabPane ) {
937
    tabPane.setStageOwnerFactory( stage -> {
938
      final var title = get(
939
        "Detach.tab.title",
940
        ((Stage) getWindow()).getTitle(), ++mWindowCount
941
      );
942
      stage.setTitle( title );
943
944
      return getScene().getWindow();
945
    } );
946
  }
947
948
  /**
949
   * Responsible for configuring the content of each {@link DetachableTab} when
950
   * it is added to the given {@link DetachableTabPane} instance.
951
   * <p>
952
   * For {@link TextEditor} contents, an instance of {@link ScrollEventHandler}
953
   * is initialized to perform synchronized scrolling between the editor and
954
   * its preview window. Additionally, the last tab in the tab pane's list of
955
   * tabs is given focus.
956
   * </p>
957
   * <p>
958
   * Note that multiple tabs can be added simultaneously.
959
   * </p>
960
   *
961
   * @param tabPane A new {@link TabPane} to configure.
962
   */
963
  private void initTabListener( final TabPane tabPane ) {
964
    tabPane.getTabs().addListener(
965
      ( final ListChangeListener.Change<? extends Tab> listener ) -> {
966
        while( listener.next() ) {
967
          if( listener.wasAdded() ) {
968
            final var tabs = listener.getAddedSubList();
969
970
            tabs.forEach( tab -> {
971
              final var node = tab.getContent();
972
973
              if( node instanceof TextEditor ) {
974
                initScrollEventListener( tab );
975
              }
976
            } );
977
978
            // Select and give focus to the last tab opened.
979
            final var index = tabs.size() - 1;
980
            if( index >= 0 ) {
981
              final var tab = tabs.get( index );
982
              tabPane.getSelectionModel().select( tab );
983
              tab.getContent().requestFocus();
984
            }
985
          }
986
        }
987
      }
988
    );
989
  }
990
991
  /**
992
   * Synchronizes scrollbar positions between the given {@link Tab} that
993
   * contains an instance of {@link TextEditor} and {@link HtmlPreview} pane.
994
   *
995
   * @param tab The container for an instance of {@link TextEditor}.
996
   */
997
  private void initScrollEventListener( final Tab tab ) {
998
    final var editor = (TextEditor) tab.getContent();
999
    final var scrollPane = editor.getScrollPane();
1000
    final var scrollBar = mPreview.getVerticalScrollBar();
1001
    final var handler = new ScrollEventHandler( scrollPane, scrollBar );
1002
1003
    handler.enabledProperty().bind( tab.selectedProperty() );
1004
  }
1005
1006
  private void addTabPane( final int index, final TabPane tabPane ) {
1007
    final var items = getItems();
1008
1009
    if( !items.contains( tabPane ) ) {
1010
      items.add( index, tabPane );
1011
    }
1012
  }
1013
1014
  private void addTabPane( final TabPane tabPane ) {
1015
    addTabPane( getItems().size(), tabPane );
1016
  }
1017
1018
  private GenericBuilder<Mutator, ProcessorContext> processorContextBuilder() {
1019
    final var w = getWorkspace();
1020
1021
    return builder()
1022
      .with( Mutator::setDefinitions, this::getDefinitions )
1023
      .with( Mutator::setLocale, w::getLocale )
1024
      .with( Mutator::setMetadata, w::getMetadata )
1025
      .with( Mutator::setThemePath, w::getThemePath )
1026
      .with( Mutator::setCaret,
1027
             () -> getTextEditor().getCaret() )
1028
      .with( Mutator::setImageDir,
1029
             () -> w.getFile( KEY_IMAGES_DIR ) )
1030
      .with( Mutator::setImageOrder,
1031
             () -> w.getString( KEY_IMAGES_ORDER ) )
1032
      .with( Mutator::setImageServer,
1033
             () -> w.getString( KEY_IMAGES_SERVER ) )
1034
      .with( Mutator::setSigilBegan,
1035
             () -> w.getString( KEY_DEF_DELIM_BEGAN ) )
1036
      .with( Mutator::setSigilEnded,
1037
             () -> w.getString( KEY_DEF_DELIM_ENDED ) )
1038
      .with( Mutator::setRScript,
1039
             () -> w.getString( KEY_R_SCRIPT ) )
1040
      .with( Mutator::setRWorkingDir,
1041
             () -> w.getFile( KEY_R_DIR ).toPath() )
1042
      .with( Mutator::setCurlQuotes,
1043
             () -> w.getBoolean( KEY_TYPESET_TYPOGRAPHY_QUOTES ) )
1044
      .with( Mutator::setAutoClean,
1045
             () -> w.getBoolean( KEY_TYPESET_CONTEXT_CLEAN ) );
1046
  }
1047
1048
  public ProcessorContext createProcessorContext() {
1049
    return createProcessorContext( null, NONE );
1050
  }
1051
1052
  /**
1053
   * @param outputPath Used when exporting to a PDF file (binary).
1054
   * @param format     Used when processors export to a new text format.
1055
   * @return A new {@link ProcessorContext} to use when creating an instance of
1056
   * {@link Processor}.
1057
   */
1058
  public ProcessorContext createProcessorContext(
1059
    final Path outputPath, final ExportFormat format ) {
1060
    final var textEditor = getTextEditor();
1061
    final var inputPath = textEditor.getPath();
1062
1063
    return processorContextBuilder()
1064
      .with( Mutator::setInputPath, inputPath )
1065
      .with( Mutator::setOutputPath, outputPath )
1066
      .with( Mutator::setExportFormat, format )
1067
      .build();
1068
  }
1069
1070
  /**
1071
   * @param inputPath Used by {@link ProcessorFactory} to determine
1072
   *                  {@link Processor} type to create based on file type.
1073
   * @return A new {@link ProcessorContext} to use when creating an instance of
1074
   * {@link Processor}.
1075
   */
1076
  private ProcessorContext createProcessorContext( final Path inputPath ) {
1077
    return processorContextBuilder()
1078
      .with( Mutator::setInputPath, inputPath )
1079
      .with( Mutator::setExportFormat, NONE )
1080
      .build();
1081
  }
1082
1083
  private TextResource createTextResource( final File file ) {
1084
    // TODO: Create PlainTextEditor that's returned by default.
1085
    return MediaType.valueFrom( file ) == TEXT_YAML
1086
      ? createDefinitionEditor( file )
1087
      : createMarkdownEditor( file );
1088
  }
1089
1090
  /**
1091
   * Creates an instance of {@link MarkdownEditor} that listens for both
1092
   * caret change events and text change events. Text change events must
1093
   * take priority over caret change events because it's possible to change
1094
   * the text without moving the caret (e.g., delete selected text).
1095
   *
1096
   * @param inputFile The file containing contents for the text editor.
1097
   * @return A non-null text editor.
1098
   */
1099
  private TextResource createMarkdownEditor( final File inputFile ) {
1100
    final var editor = new MarkdownEditor( inputFile, getWorkspace() );
1101
1102
    mProcessors.computeIfAbsent(
1103
      editor, p -> createProcessors(
1104
        createProcessorContext( inputFile.toPath() ),
1105
        createHtmlPreviewProcessor()
1106
      )
1107
    );
1108
1109
    // Listener for editor modifications or caret position changes.
1110
    editor.addDirtyListener( ( c, o, n ) -> {
1111
      if( n ) {
1112
        // Reset the status bar after changing the text.
1113
        clue();
1114
1115
        // Processing the text may update the status bar.
1116
        process( getTextEditor() );
1117
1118
        // Update the caret position in the status bar.
1119
        CaretMovedEvent.fire( editor.getCaret() );
1120
      }
1121
    } );
1122
1123
    editor.addEventListener(
1124
      keyPressed( SPACE, CONTROL_DOWN ), this::autoinsert
1125
    );
1126
1127
    // Track the caret to restore its position later.
1128
    editor.getTextArea().caretPositionProperty().addListener( ( c, o, n ) -> {
1129
      getWorkspace().integerProperty( KEY_UI_RECENT_OFFSET ).setValue( n );
1130
    } );
1131
1132
    // Set the active editor, which refreshes the preview panel.
1133
    mTextEditor.set( editor );
1134
1135
    return editor;
1136
  }
1137
1138
  /**
1139
   * Creates a {@link Processor} capable of rendering an HTML document onto
1140
   * a GUI widget.
1141
   *
1142
   * @return The {@link Processor} for rendering an HTML document.
1143
   */
1144
  private Processor<String> createHtmlPreviewProcessor() {
1145
    return new HtmlPreviewProcessor( getPreview() );
14
import com.keenwrite.events.spelling.LexiconLoadedEvent;
15
import com.keenwrite.io.MediaType;
16
import com.keenwrite.preferences.Workspace;
17
import com.keenwrite.preview.HtmlPreview;
18
import com.keenwrite.processors.HtmlPreviewProcessor;
19
import com.keenwrite.processors.Processor;
20
import com.keenwrite.processors.ProcessorContext;
21
import com.keenwrite.processors.ProcessorFactory;
22
import com.keenwrite.processors.r.Engine;
23
import com.keenwrite.processors.r.RBootstrapController;
24
import com.keenwrite.service.events.Notifier;
25
import com.keenwrite.spelling.api.SpellChecker;
26
import com.keenwrite.spelling.impl.PermissiveSpeller;
27
import com.keenwrite.spelling.impl.SymSpellSpeller;
28
import com.keenwrite.ui.explorer.FilePickerFactory;
29
import com.keenwrite.ui.heuristics.DocumentStatistics;
30
import com.keenwrite.ui.outline.DocumentOutline;
31
import com.keenwrite.ui.spelling.TextEditorSpellChecker;
32
import com.keenwrite.util.GenericBuilder;
33
import com.panemu.tiwulfx.control.dock.DetachableTab;
34
import com.panemu.tiwulfx.control.dock.DetachableTabPane;
35
import javafx.application.Platform;
36
import javafx.beans.property.*;
37
import javafx.collections.ListChangeListener;
38
import javafx.concurrent.Task;
39
import javafx.event.ActionEvent;
40
import javafx.event.Event;
41
import javafx.event.EventHandler;
42
import javafx.scene.Node;
43
import javafx.scene.Scene;
44
import javafx.scene.control.*;
45
import javafx.scene.control.TreeItem.TreeModificationEvent;
46
import javafx.scene.input.KeyEvent;
47
import javafx.scene.layout.FlowPane;
48
import javafx.stage.Stage;
49
import javafx.stage.Window;
50
import org.greenrobot.eventbus.Subscribe;
51
52
import java.io.File;
53
import java.io.FileNotFoundException;
54
import java.nio.file.Path;
55
import java.util.*;
56
import java.util.concurrent.ExecutorService;
57
import java.util.concurrent.ScheduledExecutorService;
58
import java.util.concurrent.ScheduledFuture;
59
import java.util.concurrent.atomic.AtomicBoolean;
60
import java.util.concurrent.atomic.AtomicReference;
61
import java.util.function.Consumer;
62
import java.util.function.Function;
63
import java.util.stream.Collectors;
64
65
import static com.keenwrite.ExportFormat.NONE;
66
import static com.keenwrite.Launcher.terminate;
67
import static com.keenwrite.Messages.get;
68
import static com.keenwrite.constants.Constants.*;
69
import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG_NODE;
70
import static com.keenwrite.events.Bus.register;
71
import static com.keenwrite.events.StatusEvent.clue;
72
import static com.keenwrite.io.MediaType.*;
73
import static com.keenwrite.preferences.AppKeys.*;
74
import static com.keenwrite.processors.IdentityProcessor.IDENTITY;
75
import static com.keenwrite.processors.ProcessorContext.Mutator;
76
import static com.keenwrite.processors.ProcessorContext.builder;
77
import static com.keenwrite.processors.ProcessorFactory.createProcessors;
78
import static java.lang.String.format;
79
import static java.lang.System.getProperty;
80
import static java.util.concurrent.Executors.newFixedThreadPool;
81
import static java.util.concurrent.Executors.newScheduledThreadPool;
82
import static java.util.concurrent.TimeUnit.SECONDS;
83
import static java.util.stream.Collectors.groupingBy;
84
import static javafx.application.Platform.runLater;
85
import static javafx.scene.control.Alert.AlertType.ERROR;
86
import static javafx.scene.control.ButtonType.*;
87
import static javafx.scene.control.TabPane.TabClosingPolicy.ALL_TABS;
88
import static javafx.scene.input.KeyCode.ENTER;
89
import static javafx.scene.input.KeyCode.SPACE;
90
import static javafx.scene.input.KeyCombination.ALT_DOWN;
91
import static javafx.scene.input.KeyCombination.CONTROL_DOWN;
92
import static javafx.util.Duration.millis;
93
import static javax.swing.SwingUtilities.invokeLater;
94
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
95
96
/**
97
 * Responsible for wiring together the main application components for a
98
 * particular {@link Workspace} (project). These include the definition views,
99
 * text editors, and preview pane along with any corresponding controllers.
100
 */
101
public final class MainPane extends SplitPane {
102
103
  private static final ExecutorService sExecutor = newFixedThreadPool( 1 );
104
  private static final Notifier sNotifier = Services.load( Notifier.class );
105
106
  /**
107
   * Used when opening files to determine how each file should be binned and
108
   * therefore what tab pane to be opened within.
109
   */
110
  private static final Set<MediaType> PLAIN_TEXT_FORMAT = Set.of(
111
    TEXT_MARKDOWN, TEXT_R_MARKDOWN, UNDEFINED
112
  );
113
114
  private final ScheduledExecutorService mSaver = newScheduledThreadPool( 1 );
115
  private final AtomicReference<ScheduledFuture<?>> mSaveTask =
116
    new AtomicReference<>();
117
118
  /**
119
   * Prevents re-instantiation of processing classes.
120
   */
121
  private final Map<TextResource, Processor<String>> mProcessors =
122
    new HashMap<>();
123
124
  private final Workspace mWorkspace;
125
126
  /**
127
   * Groups similar file type tabs together.
128
   */
129
  private final List<TabPane> mTabPanes = new ArrayList<>();
130
131
  /**
132
   * Renders the actively selected plain text editor tab.
133
   */
134
  private final HtmlPreview mPreview;
135
136
  /**
137
   * Provides an interactive document outline.
138
   */
139
  private final DocumentOutline mOutline = new DocumentOutline();
140
141
  /**
142
   * Changing the active editor fires the value changed event. This allows
143
   * refreshes to happen when external definitions are modified and need to
144
   * trigger the processing chain.
145
   */
146
  private final ObjectProperty<TextEditor> mTextEditor =
147
    createActiveTextEditor();
148
149
  /**
150
   * Changing the active definition editor fires the value changed event. This
151
   * allows refreshes to happen when external definitions are modified and need
152
   * to trigger the processing chain.
153
   */
154
  private final ObjectProperty<TextDefinition> mDefinitionEditor;
155
156
  private final ObjectProperty<SpellChecker> mSpellChecker;
157
158
  private final TextEditorSpellChecker mEditorSpeller;
159
160
  /**
161
   * Called when the definition data is changed.
162
   */
163
  private final EventHandler<TreeModificationEvent<Event>> mTreeHandler =
164
    event -> {
165
      process( getTextEditor() );
166
      save( getTextDefinition() );
167
    };
168
169
  /**
170
   * Tracks the number of detached tab panels opened into their own windows,
171
   * which allows unique identification of subordinate windows by their title.
172
   * It is doubtful more than 128 windows, much less 256, will be created.
173
   */
174
  private byte mWindowCount;
175
176
  private final VariableNameInjector mVariableNameInjector;
177
178
  private final RBootstrapController mRBootstrapController;
179
180
  private final DocumentStatistics mStatistics;
181
182
  /**
183
   * Adds all content panels to the main user interface. This will load the
184
   * configuration settings from the workspace to reproduce the settings from
185
   * a previous session.
186
   */
187
  public MainPane( final Workspace workspace ) {
188
    mWorkspace = workspace;
189
    mSpellChecker = createSpellChecker();
190
    mEditorSpeller = createTextEditorSpellChecker( mSpellChecker );
191
    mPreview = new HtmlPreview( workspace );
192
    mStatistics = new DocumentStatistics( workspace );
193
    mTextEditor.set( createMarkdownEditor( DOCUMENT_DEFAULT ) );
194
    mDefinitionEditor = createActiveDefinitionEditor( mTextEditor );
195
    mVariableNameInjector = new VariableNameInjector( mWorkspace );
196
    mRBootstrapController = new RBootstrapController(
197
      mWorkspace, this::getDefinitions );
198
199
    open( collect( getRecentFiles() ) );
200
    viewPreview();
201
    setDividerPositions( calculateDividerPositions() );
202
203
    // Once the main scene's window regains focus, update the active definition
204
    // editor to the currently selected tab.
205
    runLater( () -> getWindow().setOnCloseRequest( event -> {
206
      // Order matters: Open file names must be persisted before closing all.
207
      mWorkspace.save();
208
209
      if( closeAll() ) {
210
        Platform.exit();
211
        terminate( 0 );
212
      }
213
214
      event.consume();
215
    } ) );
216
217
    register( this );
218
    initAutosave( workspace );
219
220
    restoreSession();
221
    runLater( this::restoreFocus );
222
  }
223
224
  /**
225
   * Called when spellchecking can be run. This will reload the dictionary
226
   * into memory once, and then re-use it for all the existing text editors.
227
   *
228
   * @param event The event to process, having a populated word-frequency map.
229
   */
230
  @Subscribe
231
  public void handle( final LexiconLoadedEvent event ) {
232
    final var lexicon = event.getLexicon();
233
234
    try {
235
      final var checker = SymSpellSpeller.forLexicon( lexicon );
236
      mSpellChecker.set( checker );
237
    } catch( final Exception ex ) {
238
      clue( ex );
239
    }
240
  }
241
242
  @Subscribe
243
  public void handle( final TextEditorFocusEvent event ) {
244
    mTextEditor.set( event.get() );
245
  }
246
247
  @Subscribe
248
  public void handle( final TextDefinitionFocusEvent event ) {
249
    mDefinitionEditor.set( event.get() );
250
  }
251
252
  /**
253
   * Typically called when a file name is clicked in the preview panel.
254
   *
255
   * @param event The event to process, must contain a valid file reference.
256
   */
257
  @Subscribe
258
  public void handle( final FileOpenEvent event ) {
259
    final File eventFile;
260
    final var eventUri = event.getUri();
261
262
    if( eventUri.isAbsolute() ) {
263
      eventFile = new File( eventUri.getPath() );
264
    }
265
    else {
266
      final var activeFile = getTextEditor().getFile();
267
      final var parent = activeFile.getParentFile();
268
269
      if( parent == null ) {
270
        clue( new FileNotFoundException( eventUri.getPath() ) );
271
        return;
272
      }
273
      else {
274
        final var parentPath = parent.getAbsolutePath();
275
        eventFile = Path.of( parentPath, eventUri.getPath() ).toFile();
276
      }
277
    }
278
279
    runLater( () -> open( eventFile ) );
280
  }
281
282
  @Subscribe
283
  public void handle( final CaretNavigationEvent event ) {
284
    runLater( () -> {
285
      final var textArea = getTextEditor();
286
      textArea.moveTo( event.getOffset() );
287
      textArea.requestFocus();
288
    } );
289
  }
290
291
  @Subscribe
292
  @SuppressWarnings( "unused" )
293
  public void handle( final ExportFailedEvent event ) {
294
    final var os = getProperty( "os.name" );
295
    final var arch = getProperty( "os.arch" ).toLowerCase();
296
    final var bits = getProperty( "sun.arch.data.model" );
297
298
    final var title = Messages.get( "Alert.typesetter.missing.title" );
299
    final var header = Messages.get( "Alert.typesetter.missing.header" );
300
    final var version = Messages.get(
301
      "Alert.typesetter.missing.version",
302
      os,
303
      arch
304
        .replaceAll( "amd.*|i.*|x86.*", "X86" )
305
        .replaceAll( "mips.*", "MIPS" )
306
        .replaceAll( "armv.*", "ARM" ),
307
      bits );
308
    final var text = Messages.get( "Alert.typesetter.missing.installer.text" );
309
310
    // Download and install ConTeXt for {0} {1} {2}-bit
311
    final var content = format( "%s %s", text, version );
312
    final var flowPane = new FlowPane();
313
    final var link = new Hyperlink( text );
314
    final var label = new Label( version );
315
    flowPane.getChildren().addAll( link, label );
316
317
    final var alert = new Alert( ERROR, content, OK );
318
    alert.setTitle( title );
319
    alert.setHeaderText( header );
320
    alert.getDialogPane().contentProperty().set( flowPane );
321
    alert.setGraphic( ICON_DIALOG_NODE );
322
323
    link.setOnAction( e -> {
324
      alert.close();
325
      final var url = Messages.get( "Alert.typesetter.missing.installer.url" );
326
      runLater( () -> HyperlinkOpenEvent.fire( url ) );
327
    } );
328
329
    alert.showAndWait();
330
  }
331
332
  @Subscribe
333
  public void handle( final InsertDefinitionEvent<String> event ) {
334
    final var leaf = event.getLeaf();
335
    final var editor = mTextEditor.get();
336
337
    mVariableNameInjector.insert( editor, leaf );
338
  }
339
340
  private void initAutosave( final Workspace workspace ) {
341
    final var rate = workspace.integerProperty( KEY_EDITOR_AUTOSAVE );
342
343
    rate.addListener(
344
      ( c, o, n ) -> {
345
        final var taskRef = mSaveTask.get();
346
347
        // Prevent multiple autosaves from running.
348
        if( taskRef != null ) {
349
          taskRef.cancel( false );
350
        }
351
352
        initAutosave( rate );
353
      }
354
    );
355
356
    // Start the save listener (avoids duplicating some code).
357
    initAutosave( rate );
358
  }
359
360
  private void initAutosave( final IntegerProperty rate ) {
361
    mSaveTask.set(
362
      mSaver.scheduleAtFixedRate(
363
        () -> {
364
          if( getTextEditor().isModified() ) {
365
            // Ensure the modified indicator is cleared by running on EDT.
366
            runLater( this::save );
367
          }
368
        }, 0, rate.intValue(), SECONDS
369
      )
370
    );
371
  }
372
373
  /**
374
   * TODO: Load divider positions from exported settings, see
375
   *   {@link #collect(SetProperty)} comment.
376
   */
377
  private double[] calculateDividerPositions() {
378
    final var ratio = 100f / getItems().size() / 100;
379
    final var positions = getDividerPositions();
380
381
    for( int i = 0; i < positions.length; i++ ) {
382
      positions[ i ] = ratio * i;
383
    }
384
385
    return positions;
386
  }
387
388
  /**
389
   * Opens all the files into the application, provided the paths are unique.
390
   * This may only be called for any type of files that a user can edit
391
   * (i.e., update and persist), such as definitions and text files.
392
   *
393
   * @param files The list of files to open.
394
   */
395
  public void open( final List<File> files ) {
396
    files.forEach( this::open );
397
  }
398
399
  /**
400
   * This opens the given file. Since the preview pane is not a file that
401
   * can be opened, it is safe to add a listener to the detachable pane.
402
   * This will exit early if the given file is not a regular file (i.e., a
403
   * directory).
404
   *
405
   * @param inputFile The file to open.
406
   */
407
  private void open( final File inputFile ) {
408
    // Prevent opening directories (a non-existent "untitled.md" is fine).
409
    if( !inputFile.isFile() && inputFile.exists() ) {
410
      return;
411
    }
412
413
    final var tab = createTab( inputFile );
414
    final var node = tab.getContent();
415
    final var mediaType = MediaType.valueFrom( inputFile );
416
    final var tabPane = obtainTabPane( mediaType );
417
418
    tab.setTooltip( createTooltip( inputFile ) );
419
    tabPane.setFocusTraversable( false );
420
    tabPane.setTabClosingPolicy( ALL_TABS );
421
    tabPane.getTabs().add( tab );
422
423
    // Attach the tab scene factory for new tab panes.
424
    if( !getItems().contains( tabPane ) ) {
425
      addTabPane(
426
        node instanceof TextDefinition ? 0 : getItems().size(), tabPane
427
      );
428
    }
429
430
    if( inputFile.isFile() ) {
431
      getRecentFiles().add( inputFile.getAbsolutePath() );
432
    }
433
  }
434
435
  /**
436
   * Gives focus to the most recently edited document and attempts to move
437
   * the caret to the most recently known offset into said document.
438
   */
439
  private void restoreSession() {
440
    final var workspace = getWorkspace();
441
    final var file = workspace.fileProperty( KEY_UI_RECENT_DOCUMENT );
442
    final var offset = workspace.integerProperty( KEY_UI_RECENT_OFFSET );
443
444
    for( final var pane : mTabPanes ) {
445
      for( final var tab : pane.getTabs() ) {
446
        final var tooltip = tab.getTooltip();
447
448
        if( tooltip != null ) {
449
          final var tabName = tooltip.getText();
450
          final var fileName = file.getValue().toString();
451
452
          if( tabName.equalsIgnoreCase( fileName ) ) {
453
            final var node = tab.getContent();
454
455
            pane.getSelectionModel().select( tab );
456
            node.requestFocus();
457
458
            if( node instanceof TextEditor editor ) {
459
              editor.moveTo( offset.getValue() );
460
            }
461
462
            break;
463
          }
464
        }
465
      }
466
    }
467
  }
468
469
  /**
470
   * Sets the focus to the middle pane, which contains the text editor tabs.
471
   */
472
  private void restoreFocus() {
473
    // Work around a bug where focusing directly on the middle pane results
474
    // in the R engine not loading variables properly.
475
    mTabPanes.get( 0 ).requestFocus();
476
477
    // This is the only line that should be required.
478
    mTabPanes.get( 1 ).requestFocus();
479
  }
480
481
  /**
482
   * Opens a new text editor document using the default document file name.
483
   */
484
  public void newTextEditor() {
485
    open( DOCUMENT_DEFAULT );
486
  }
487
488
  /**
489
   * Opens a new definition editor document using the default definition
490
   * file name.
491
   */
492
  public void newDefinitionEditor() {
493
    open( DEFINITION_DEFAULT );
494
  }
495
496
  /**
497
   * Iterates over all tab panes to find all {@link TextEditor}s and request
498
   * that they save themselves.
499
   */
500
  public void saveAll() {
501
    iterateEditors( this::save );
502
  }
503
504
  /**
505
   * Requests that the active {@link TextEditor} saves itself. Don't bother
506
   * checking if modified first because if the user swaps external media from
507
   * an external source (e.g., USB thumb drive), save should not second-guess
508
   * the user: save always re-saves. Also, it's less code.
509
   */
510
  public void save() {
511
    save( getTextEditor() );
512
  }
513
514
  /**
515
   * Saves the active {@link TextEditor} under a new name.
516
   *
517
   * @param files The new active editor {@link File} reference, must contain
518
   *              at least one element.
519
   */
520
  public void saveAs( final List<File> files ) {
521
    assert files != null;
522
    assert !files.isEmpty();
523
    final var editor = getTextEditor();
524
    final var tab = getTab( editor );
525
    final var file = files.get( 0 );
526
527
    editor.rename( file );
528
    tab.ifPresent( t -> {
529
      t.setText( editor.getFilename() );
530
      t.setTooltip( createTooltip( file ) );
531
    } );
532
533
    save();
534
  }
535
536
  /**
537
   * Saves the given {@link TextResource} to a file. This is typically used
538
   * to save either an instance of {@link TextEditor} or {@link TextDefinition}.
539
   *
540
   * @param resource The resource to export.
541
   */
542
  private void save( final TextResource resource ) {
543
    try {
544
      resource.save();
545
    } catch( final Exception ex ) {
546
      clue( ex );
547
      sNotifier.alert(
548
        getWindow(), resource.getPath(), "TextResource.saveFailed", ex
549
      );
550
    }
551
  }
552
553
  /**
554
   * Closes all open {@link TextEditor}s; all {@link TextDefinition}s stay open.
555
   *
556
   * @return {@code true} when all editors, modified or otherwise, were
557
   * permitted to close; {@code false} when one or more editors were modified
558
   * and the user requested no closing.
559
   */
560
  public boolean closeAll() {
561
    var closable = true;
562
563
    for( final var tabPane : mTabPanes ) {
564
      final var tabIterator = tabPane.getTabs().iterator();
565
566
      while( tabIterator.hasNext() ) {
567
        final var tab = tabIterator.next();
568
        final var resource = tab.getContent();
569
570
        // The definition panes auto-save, so being specific here prevents
571
        // closing the definitions in the situation where the user wants to
572
        // continue editing (i.e., possibly save unsaved work).
573
        if( !(resource instanceof TextEditor) ) {
574
          continue;
575
        }
576
577
        if( canClose( (TextEditor) resource ) ) {
578
          tabIterator.remove();
579
          close( tab );
580
        }
581
        else {
582
          closable = false;
583
        }
584
      }
585
    }
586
587
    return closable;
588
  }
589
590
  /**
591
   * Calls the tab's {@link Tab#getOnClosed()} handler to carry out a close
592
   * event.
593
   *
594
   * @param tab The {@link Tab} that was closed.
595
   */
596
  private void close( final Tab tab ) {
597
    assert tab != null;
598
599
    final var handler = tab.getOnClosed();
600
601
    if( handler != null ) {
602
      handler.handle( new ActionEvent() );
603
    }
604
  }
605
606
  /**
607
   * Closes the active tab; delegates to {@link #canClose(TextResource)}.
608
   */
609
  public void close() {
610
    final var editor = getTextEditor();
611
612
    if( canClose( editor ) ) {
613
      close( editor );
614
    }
615
  }
616
617
  /**
618
   * Closes the given {@link TextResource}. This must not be called from within
619
   * a loop that iterates over the tab panes using {@code forEach}, lest a
620
   * concurrent modification exception be thrown.
621
   *
622
   * @param resource The {@link TextResource} to close, without confirming with
623
   *                 the user.
624
   */
625
  private void close( final TextResource resource ) {
626
    getTab( resource ).ifPresent(
627
      tab -> {
628
        close( tab );
629
        tab.getTabPane().getTabs().remove( tab );
630
      }
631
    );
632
  }
633
634
  /**
635
   * Answers whether the given {@link TextResource} may be closed.
636
   *
637
   * @param editor The {@link TextResource} to try closing.
638
   * @return {@code true} when the editor may be closed; {@code false} when
639
   * the user has requested to keep the editor open.
640
   */
641
  private boolean canClose( final TextResource editor ) {
642
    final var editorTab = getTab( editor );
643
    final var canClose = new AtomicBoolean( true );
644
645
    if( editor.isModified() ) {
646
      final var filename = new StringBuilder();
647
      editorTab.ifPresent( tab -> filename.append( tab.getText() ) );
648
649
      final var message = sNotifier.createNotification(
650
        Messages.get( "Alert.file.close.title" ),
651
        Messages.get( "Alert.file.close.text" ),
652
        filename.toString()
653
      );
654
655
      final var dialog = sNotifier.createConfirmation( getWindow(), message );
656
657
      dialog.showAndWait().ifPresent(
658
        save -> canClose.set( save == YES ? editor.save() : save == NO )
659
      );
660
    }
661
662
    return canClose.get();
663
  }
664
665
  private void iterateEditors( final Consumer<TextEditor> consumer ) {
666
    mTabPanes.forEach(
667
      tp -> tp.getTabs().forEach( tab -> {
668
        final var node = tab.getContent();
669
670
        if( node instanceof final TextEditor editor ) {
671
          consumer.accept( editor );
672
        }
673
      } )
674
    );
675
  }
676
677
  private ObjectProperty<TextEditor> createActiveTextEditor() {
678
    final var editor = new SimpleObjectProperty<TextEditor>();
679
680
    editor.addListener( ( c, o, n ) -> {
681
      if( n != null ) {
682
        mPreview.setBaseUri( n.getPath() );
683
        process( n );
684
      }
685
    } );
686
687
    return editor;
688
  }
689
690
  /**
691
   * Adds the HTML preview tab to its own, singular tab pane.
692
   */
693
  public void viewPreview() {
694
    viewTab( mPreview, TEXT_HTML, "Pane.preview.title" );
695
  }
696
697
  /**
698
   * Adds the document outline tab to its own, singular tab pane.
699
   */
700
  public void viewOutline() {
701
    viewTab( mOutline, APP_DOCUMENT_OUTLINE, "Pane.outline.title" );
702
  }
703
704
  public void viewStatistics() {
705
    viewTab( mStatistics, APP_DOCUMENT_STATISTICS, "Pane.statistics.title" );
706
  }
707
708
  public void viewFiles() {
709
    try {
710
      final var factory = new FilePickerFactory( getWorkspace() );
711
      final var fileManager = factory.createModeless();
712
      viewTab( fileManager, APP_FILE_MANAGER, "Pane.files.title" );
713
    } catch( final Exception ex ) {
714
      clue( ex );
715
    }
716
  }
717
718
  private void viewTab(
719
    final Node node, final MediaType mediaType, final String key ) {
720
    final var tabPane = obtainTabPane( mediaType );
721
722
    for( final var tab : tabPane.getTabs() ) {
723
      if( tab.getContent() == node ) {
724
        return;
725
      }
726
    }
727
728
    tabPane.getTabs().add( createTab( get( key ), node ) );
729
    addTabPane( tabPane );
730
  }
731
732
  public void viewRefresh() {
733
    mPreview.refresh();
734
    Engine.clear();
735
    mRBootstrapController.update();
736
  }
737
738
  /**
739
   * Returns the tab that contains the given {@link TextEditor}.
740
   *
741
   * @param editor The {@link TextEditor} instance to find amongst the tabs.
742
   * @return The first tab having content that matches the given tab.
743
   */
744
  private Optional<Tab> getTab( final TextResource editor ) {
745
    return mTabPanes.stream()
746
                    .flatMap( pane -> pane.getTabs().stream() )
747
                    .filter( tab -> editor.equals( tab.getContent() ) )
748
                    .findFirst();
749
  }
750
751
  /**
752
   * Creates a new {@link DefinitionEditor} wrapped in a listener that
753
   * is used to detect when the active {@link DefinitionEditor} has changed.
754
   * Upon changing, the variables are interpolated and the active text editor
755
   * is refreshed.
756
   *
757
   * @param textEditor Text editor to update with the revised resolved map.
758
   * @return A newly configured property that represents the active
759
   * {@link DefinitionEditor}, never null.
760
   */
761
  private ObjectProperty<TextDefinition> createActiveDefinitionEditor(
762
    final ObjectProperty<TextEditor> textEditor ) {
763
    final var defEditor = new SimpleObjectProperty<>(
764
      createDefinitionEditor()
765
    );
766
767
    defEditor.addListener( ( c, o, n ) -> {
768
      final var editor = textEditor.get();
769
770
      if( editor.isMediaType( TEXT_R_MARKDOWN ) ) {
771
        // Initialize R before the editor is added.
772
        mRBootstrapController.update();
773
      }
774
775
      process( editor );
776
    } );
777
778
    return defEditor;
779
  }
780
781
  private Tab createTab( final String filename, final Node node ) {
782
    return new DetachableTab( filename, node );
783
  }
784
785
  private Tab createTab( final File file ) {
786
    final var r = createTextResource( file );
787
    final var tab = createTab( r.getFilename(), r.getNode() );
788
789
    r.modifiedProperty().addListener(
790
      ( c, o, n ) -> tab.setText( r.getFilename() + (n ? "*" : "") )
791
    );
792
793
    // This is called when either the tab is closed by the user clicking on
794
    // the tab's close icon or when closing (all) from the file menu.
795
    tab.setOnClosed(
796
      __ -> getRecentFiles().remove( file.getAbsolutePath() )
797
    );
798
799
    // When closing a tab, give focus to the newly revealed tab.
800
    tab.selectedProperty().addListener( ( c, o, n ) -> {
801
      if( n != null && n ) {
802
        final var pane = tab.getTabPane();
803
804
        if( pane != null ) {
805
          pane.requestFocus();
806
        }
807
      }
808
    } );
809
810
    tab.tabPaneProperty().addListener( ( cPane, oPane, nPane ) -> {
811
      if( nPane != null ) {
812
        nPane.focusedProperty().addListener( ( c, o, n ) -> {
813
          if( n != null && n ) {
814
            final var selected = nPane.getSelectionModel().getSelectedItem();
815
            final var node = selected.getContent();
816
            node.requestFocus();
817
          }
818
        } );
819
      }
820
    } );
821
822
    return tab;
823
  }
824
825
  /**
826
   * Creates bins for the different {@link MediaType}s, which eventually are
827
   * added to the UI as separate tab panes. If ever a general-purpose scene
828
   * exporter is developed to serialize a scene to an FXML file, this could
829
   * be replaced by such a class.
830
   * <p>
831
   * When binning the files, this makes sure that at least one file exists
832
   * for every type. If the user has opted to close a particular type (such
833
   * as the definition pane), the view will suppressed elsewhere.
834
   * </p>
835
   * <p>
836
   * The order that the binned files are returned will be reflected in the
837
   * order that the corresponding panes are rendered in the UI.
838
   * </p>
839
   *
840
   * @param paths The file paths to bin according to their type.
841
   * @return An in-order list of files, first by structured definition files,
842
   * then by plain text documents.
843
   */
844
  private List<File> collect( final SetProperty<String> paths ) {
845
    // Treat all files destined for the text editor as plain text documents
846
    // so that they are added to the same pane. Grouping by TEXT_PLAIN is a
847
    // bit arbitrary, but means explicitly capturing TEXT_PLAIN isn't needed.
848
    final Function<MediaType, MediaType> bin =
849
      m -> PLAIN_TEXT_FORMAT.contains( m ) ? TEXT_PLAIN : m;
850
851
    // Create two groups: YAML files and plain text files. The order that
852
    // the elements are listed in the enumeration for media types determines
853
    // what files are loaded first. Variable definitions come before all other
854
    // plain text documents.
855
    final var bins = paths
856
      .stream()
857
      .collect(
858
        groupingBy(
859
          path -> bin.apply( MediaType.fromFilename( path ) ),
860
          () -> new TreeMap<>( Enum::compareTo ),
861
          Collectors.toList()
862
        )
863
      );
864
865
    bins.putIfAbsent( TEXT_YAML, List.of( DEFINITION_DEFAULT.toString() ) );
866
    bins.putIfAbsent( TEXT_PLAIN, List.of( DOCUMENT_DEFAULT.toString() ) );
867
868
    final var result = new LinkedList<File>();
869
870
    // Ensure that the same types are listed together (keep insertion order).
871
    bins.forEach( ( mediaType, files ) -> result.addAll(
872
      files.stream().map( File::new ).toList() )
873
    );
874
875
    return result;
876
  }
877
878
  /**
879
   * Force the active editor to update, which will cause the processor
880
   * to re-evaluate the interpolated definition map thereby updating the
881
   * preview pane.
882
   *
883
   * @param editor Contains the source document to update in the preview pane.
884
   */
885
  private void process( final TextEditor editor ) {
886
    // Ensure processing does not run on the JavaFX thread, which frees the
887
    // text editor immediately for caret movement. The preview will have a
888
    // slight delay when catching up to the caret position.
889
    final var task = new Task<Void>() {
890
      @Override
891
      public Void call() {
892
        try {
893
          final var p = mProcessors.getOrDefault( editor, IDENTITY );
894
          p.apply( editor == null ? "" : editor.getText() );
895
        } catch( final Exception ex ) {
896
          clue( ex );
897
        }
898
899
        return null;
900
      }
901
    };
902
903
    // TODO: Each time the editor successfully runs the processor the task is
904
    //   considered successful. Due to the rapid-fire nature of processing
905
    //   (e.g., keyboard navigation, fast typing), it isn't necessary to
906
    //   scroll each time.
907
    //   The algorithm:
908
    //   1. Peek at the oldest time.
909
    //   2. If the difference between the oldest time and current time exceeds
910
    //      250 milliseconds, then invoke the scrolling.
911
    //   3. Insert the current time into the circular queue.
912
    task.setOnSucceeded(
913
      e -> invokeLater( () -> mPreview.scrollTo( CARET_ID ) )
914
    );
915
916
    // Prevents multiple process requests from executing simultaneously (due
917
    // to having a restricted queue size).
918
    sExecutor.execute( task );
919
  }
920
921
  /**
922
   * Lazily creates a {@link TabPane} configured to listen for tab select
923
   * events. The tab pane is associated with a given media type so that
924
   * similar files can be grouped together.
925
   *
926
   * @param mediaType The media type to associate with the tab pane.
927
   * @return An instance of {@link TabPane} that will handle tab docking.
928
   */
929
  private TabPane obtainTabPane( final MediaType mediaType ) {
930
    for( final var pane : mTabPanes ) {
931
      for( final var tab : pane.getTabs() ) {
932
        final var node = tab.getContent();
933
934
        if( node instanceof TextResource r && r.supports( mediaType ) ) {
935
          return pane;
936
        }
937
      }
938
    }
939
940
    final var pane = createTabPane();
941
    mTabPanes.add( pane );
942
    return pane;
943
  }
944
945
  /**
946
   * Creates an initialized {@link TabPane} instance.
947
   *
948
   * @return A new {@link TabPane} with all listeners configured.
949
   */
950
  private TabPane createTabPane() {
951
    final var tabPane = new DetachableTabPane();
952
953
    initStageOwnerFactory( tabPane );
954
    initTabListener( tabPane );
955
956
    return tabPane;
957
  }
958
959
  /**
960
   * When any {@link DetachableTabPane} is detached from the main window,
961
   * the stage owner factory must be given its parent window, which will
962
   * own the child window. The parent window is the {@link MainPane}'s
963
   * {@link Scene}'s {@link Window} instance.
964
   *
965
   * <p>
966
   * This will derives the new title from the main window title, incrementing
967
   * the window count to help uniquely identify the child windows.
968
   * </p>
969
   *
970
   * @param tabPane A new {@link DetachableTabPane} to configure.
971
   */
972
  private void initStageOwnerFactory( final DetachableTabPane tabPane ) {
973
    tabPane.setStageOwnerFactory( stage -> {
974
      final var title = get(
975
        "Detach.tab.title",
976
        ((Stage) getWindow()).getTitle(), ++mWindowCount
977
      );
978
      stage.setTitle( title );
979
980
      return getScene().getWindow();
981
    } );
982
  }
983
984
  /**
985
   * Responsible for configuring the content of each {@link DetachableTab} when
986
   * it is added to the given {@link DetachableTabPane} instance.
987
   * <p>
988
   * For {@link TextEditor} contents, an instance of {@link ScrollEventHandler}
989
   * is initialized to perform synchronized scrolling between the editor and
990
   * its preview window. Additionally, the last tab in the tab pane's list of
991
   * tabs is given focus.
992
   * </p>
993
   * <p>
994
   * Note that multiple tabs can be added simultaneously.
995
   * </p>
996
   *
997
   * @param tabPane A new {@link TabPane} to configure.
998
   */
999
  private void initTabListener( final TabPane tabPane ) {
1000
    tabPane.getTabs().addListener(
1001
      ( final ListChangeListener.Change<? extends Tab> listener ) -> {
1002
        while( listener.next() ) {
1003
          if( listener.wasAdded() ) {
1004
            final var tabs = listener.getAddedSubList();
1005
1006
            tabs.forEach( tab -> {
1007
              final var node = tab.getContent();
1008
1009
              if( node instanceof TextEditor ) {
1010
                initScrollEventListener( tab );
1011
              }
1012
            } );
1013
1014
            // Select and give focus to the last tab opened.
1015
            final var index = tabs.size() - 1;
1016
            if( index >= 0 ) {
1017
              final var tab = tabs.get( index );
1018
              tabPane.getSelectionModel().select( tab );
1019
              tab.getContent().requestFocus();
1020
            }
1021
          }
1022
        }
1023
      }
1024
    );
1025
  }
1026
1027
  /**
1028
   * Synchronizes scrollbar positions between the given {@link Tab} that
1029
   * contains an instance of {@link TextEditor} and {@link HtmlPreview} pane.
1030
   *
1031
   * @param tab The container for an instance of {@link TextEditor}.
1032
   */
1033
  private void initScrollEventListener( final Tab tab ) {
1034
    final var editor = (TextEditor) tab.getContent();
1035
    final var scrollPane = editor.getScrollPane();
1036
    final var scrollBar = mPreview.getVerticalScrollBar();
1037
    final var handler = new ScrollEventHandler( scrollPane, scrollBar );
1038
1039
    handler.enabledProperty().bind( tab.selectedProperty() );
1040
  }
1041
1042
  private void addTabPane( final int index, final TabPane tabPane ) {
1043
    final var items = getItems();
1044
1045
    if( !items.contains( tabPane ) ) {
1046
      items.add( index, tabPane );
1047
    }
1048
  }
1049
1050
  private void addTabPane( final TabPane tabPane ) {
1051
    addTabPane( getItems().size(), tabPane );
1052
  }
1053
1054
  private GenericBuilder<Mutator, ProcessorContext> processorContextBuilder() {
1055
    final var w = getWorkspace();
1056
1057
    return builder()
1058
      .with( Mutator::setDefinitions, this::getDefinitions )
1059
      .with( Mutator::setLocale, w::getLocale )
1060
      .with( Mutator::setMetadata, w::getMetadata )
1061
      .with( Mutator::setThemePath, w::getThemePath )
1062
      .with( Mutator::setCaret,
1063
             () -> getTextEditor().getCaret() )
1064
      .with( Mutator::setImageDir,
1065
             () -> w.getFile( KEY_IMAGES_DIR ) )
1066
      .with( Mutator::setImageOrder,
1067
             () -> w.getString( KEY_IMAGES_ORDER ) )
1068
      .with( Mutator::setImageServer,
1069
             () -> w.getString( KEY_IMAGES_SERVER ) )
1070
      .with( Mutator::setSigilBegan,
1071
             () -> w.getString( KEY_DEF_DELIM_BEGAN ) )
1072
      .with( Mutator::setSigilEnded,
1073
             () -> w.getString( KEY_DEF_DELIM_ENDED ) )
1074
      .with( Mutator::setRScript,
1075
             () -> w.getString( KEY_R_SCRIPT ) )
1076
      .with( Mutator::setRWorkingDir,
1077
             () -> w.getFile( KEY_R_DIR ).toPath() )
1078
      .with( Mutator::setCurlQuotes,
1079
             () -> w.getBoolean( KEY_TYPESET_TYPOGRAPHY_QUOTES ) )
1080
      .with( Mutator::setAutoClean,
1081
             () -> w.getBoolean( KEY_TYPESET_CONTEXT_CLEAN ) );
1082
  }
1083
1084
  public ProcessorContext createProcessorContext() {
1085
    return createProcessorContext( null, NONE );
1086
  }
1087
1088
  /**
1089
   * @param outputPath Used when exporting to a PDF file (binary).
1090
   * @param format     Used when processors export to a new text format.
1091
   * @return A new {@link ProcessorContext} to use when creating an instance of
1092
   * {@link Processor}.
1093
   */
1094
  public ProcessorContext createProcessorContext(
1095
    final Path outputPath, final ExportFormat format ) {
1096
    final var textEditor = getTextEditor();
1097
    final var inputPath = textEditor.getPath();
1098
1099
    return processorContextBuilder()
1100
      .with( Mutator::setInputPath, inputPath )
1101
      .with( Mutator::setOutputPath, outputPath )
1102
      .with( Mutator::setExportFormat, format )
1103
      .build();
1104
  }
1105
1106
  /**
1107
   * @param inputPath Used by {@link ProcessorFactory} to determine
1108
   *                  {@link Processor} type to create based on file type.
1109
   * @return A new {@link ProcessorContext} to use when creating an instance of
1110
   * {@link Processor}.
1111
   */
1112
  private ProcessorContext createProcessorContext( final Path inputPath ) {
1113
    return processorContextBuilder()
1114
      .with( Mutator::setInputPath, inputPath )
1115
      .with( Mutator::setExportFormat, NONE )
1116
      .build();
1117
  }
1118
1119
  private TextResource createTextResource( final File file ) {
1120
    // TODO: Create PlainTextEditor that's returned by default.
1121
    return MediaType.valueFrom( file ) == TEXT_YAML
1122
      ? createDefinitionEditor( file )
1123
      : createMarkdownEditor( file );
1124
  }
1125
1126
  /**
1127
   * Creates an instance of {@link MarkdownEditor} that listens for both
1128
   * caret change events and text change events. Text change events must
1129
   * take priority over caret change events because it's possible to change
1130
   * the text without moving the caret (e.g., delete selected text).
1131
   *
1132
   * @param inputFile The file containing contents for the text editor.
1133
   * @return A non-null text editor.
1134
   */
1135
  private MarkdownEditor createMarkdownEditor( final File inputFile ) {
1136
    final var editor = new MarkdownEditor( inputFile, getWorkspace() );
1137
1138
    mProcessors.computeIfAbsent(
1139
      editor, p -> createProcessors(
1140
        createProcessorContext( inputFile.toPath() ),
1141
        createHtmlPreviewProcessor()
1142
      )
1143
    );
1144
1145
    // Listener for editor modifications or caret position changes.
1146
    editor.addDirtyListener( ( c, o, n ) -> {
1147
      if( n ) {
1148
        // Reset the status bar after changing the text.
1149
        clue();
1150
1151
        // Processing the text may update the status bar.
1152
        process( getTextEditor() );
1153
1154
        // Update the caret position in the status bar.
1155
        CaretMovedEvent.fire( editor.getCaret() );
1156
      }
1157
    } );
1158
1159
    editor.addEventListener(
1160
      keyPressed( SPACE, CONTROL_DOWN ), this::autoinsert
1161
    );
1162
1163
    editor.addEventListener(
1164
      keyPressed( ENTER, ALT_DOWN ), event -> mEditorSpeller.autofix( editor )
1165
    );
1166
1167
    final var textArea = editor.getTextArea();
1168
1169
    // Spell check when the paragraph changes.
1170
    textArea
1171
      .plainTextChanges()
1172
      .filter( p -> !p.isIdentity() )
1173
      .subscribe( change -> mEditorSpeller.checkParagraph( textArea, change ) );
1174
1175
    // Store the caret position to restore it after restarting the application.
1176
    textArea.caretPositionProperty().addListener(
1177
      ( c, o, n ) ->
1178
        getWorkspace().integerProperty( KEY_UI_RECENT_OFFSET ).setValue( n )
1179
    );
1180
1181
    // Set the active editor, which refreshes the preview panel.
1182
    mTextEditor.set( editor );
1183
1184
    // Check the entire document after the spellchecker is initialized (with
1185
    // a valid lexicon) so that only the current paragraph need be scanned
1186
    // while editing. (Technically, only the most recently modified word must
1187
    // be scanned.)
1188
    mSpellChecker.addListener(
1189
      ( c, o, n ) -> runLater(
1190
        () -> iterateEditors( mEditorSpeller::checkDocument )
1191
      )
1192
    );
1193
1194
    // Check the entire document after it has been loaded.
1195
    mEditorSpeller.checkDocument( mTextEditor.get() );
1196
1197
    return editor;
1198
  }
1199
1200
  /**
1201
   * Creates a {@link Processor} capable of rendering an HTML document onto
1202
   * a GUI widget.
1203
   *
1204
   * @return The {@link Processor} for rendering an HTML document.
1205
   */
1206
  private Processor<String> createHtmlPreviewProcessor() {
1207
    return new HtmlPreviewProcessor( getPreview() );
1208
  }
1209
1210
  /**
1211
   * Creates a spellchecker that accepts all words as correct. This allows
1212
   * the spellchecker property to be initialized to a known valid value.
1213
   *
1214
   * @return A wrapped {@link PermissiveSpeller}.
1215
   */
1216
  private ObjectProperty<SpellChecker> createSpellChecker() {
1217
    return new SimpleObjectProperty<>( new PermissiveSpeller() );
1218
  }
1219
1220
  private TextEditorSpellChecker createTextEditorSpellChecker(
1221
    final ObjectProperty<SpellChecker> spellChecker ) {
1222
    return new TextEditorSpellChecker( spellChecker );
11461223
  }
11471224
M src/main/java/com/keenwrite/editors/markdown/MarkdownEditor.java
1010
import com.keenwrite.preferences.Workspace;
1111
import com.keenwrite.processors.markdown.extensions.CaretExtension;
12
import com.keenwrite.spelling.impl.TextEditorSpeller;
13
import javafx.beans.binding.Bindings;
14
import javafx.beans.property.*;
15
import javafx.beans.value.ChangeListener;
16
import javafx.event.Event;
17
import javafx.scene.Node;
18
import javafx.scene.control.ContextMenu;
19
import javafx.scene.control.IndexRange;
20
import javafx.scene.control.MenuItem;
21
import javafx.scene.input.KeyEvent;
22
import javafx.scene.layout.BorderPane;
23
import org.fxmisc.flowless.VirtualizedScrollPane;
24
import org.fxmisc.richtext.StyleClassedTextArea;
25
import org.fxmisc.richtext.model.StyleSpans;
26
import org.fxmisc.undo.UndoManager;
27
import org.fxmisc.wellbehaved.event.EventPattern;
28
import org.fxmisc.wellbehaved.event.Nodes;
29
30
import java.io.File;
31
import java.nio.charset.Charset;
32
import java.text.BreakIterator;
33
import java.text.MessageFormat;
34
import java.util.*;
35
import java.util.function.Consumer;
36
import java.util.function.Supplier;
37
import java.util.regex.Pattern;
38
39
import static com.keenwrite.MainApp.keyDown;
40
import static com.keenwrite.constants.Constants.*;
41
import static com.keenwrite.events.StatusEvent.clue;
42
import static com.keenwrite.io.MediaType.TEXT_MARKDOWN;
43
import static com.keenwrite.io.MediaType.TEXT_R_MARKDOWN;
44
import static com.keenwrite.preferences.AppKeys.*;
45
import static java.lang.Character.isWhitespace;
46
import static java.lang.String.format;
47
import static java.util.Collections.singletonList;
48
import static javafx.application.Platform.runLater;
49
import static javafx.scene.control.ScrollPane.ScrollBarPolicy.ALWAYS;
50
import static javafx.scene.input.KeyCode.*;
51
import static javafx.scene.input.KeyCombination.*;
52
import static org.apache.commons.lang3.StringUtils.stripEnd;
53
import static org.apache.commons.lang3.StringUtils.stripStart;
54
import static org.fxmisc.richtext.Caret.CaretVisibility.ON;
55
import static org.fxmisc.richtext.model.StyleSpans.singleton;
56
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
57
import static org.fxmisc.wellbehaved.event.InputMap.consume;
58
59
/**
60
 * Responsible for editing Markdown documents.
61
 */
62
public final class MarkdownEditor extends BorderPane implements TextEditor {
63
  /**
64
   * Regular expression that matches the type of markup block. This is used
65
   * when Enter is pressed to continue the block environment.
66
   */
67
  private static final Pattern PATTERN_AUTO_INDENT = Pattern.compile(
68
    "(\\s*[*+-]\\s+|\\s*[0-9]+\\.\\s+|\\s+)(.*)" );
69
70
  private final Workspace mWorkspace;
71
72
  /**
73
   * The text editor.
74
   */
75
  private final StyleClassedTextArea mTextArea =
76
    new StyleClassedTextArea( false );
77
78
  /**
79
   * Wraps the text editor in scrollbars.
80
   */
81
  private final VirtualizedScrollPane<StyleClassedTextArea> mScrollPane =
82
    new VirtualizedScrollPane<>( mTextArea );
83
84
  /**
85
   * Tracks where the caret is located in this document. This offers observable
86
   * properties for caret position changes.
87
   */
88
  private final Caret mCaret = createCaret( mTextArea );
89
90
  /**
91
   * For spell checking the document upon load and whenever it changes.
92
   */
93
  private final TextEditorSpeller mSpeller = new TextEditorSpeller();
94
95
  /**
96
   * File being edited by this editor instance.
97
   */
98
  private File mFile;
99
100
  /**
101
   * Set to {@code true} upon text or caret position changes. Value is {@code
102
   * false} by default.
103
   */
104
  private final BooleanProperty mDirty = new SimpleBooleanProperty();
105
106
  /**
107
   * Opened file's character encoding, or {@link Constants#DEFAULT_CHARSET} if
108
   * either no encoding could be determined or this is a new (empty) file.
109
   */
110
  private final Charset mEncoding;
111
112
  /**
113
   * Tracks whether the in-memory definitions have changed with respect to the
114
   * persisted definitions.
115
   */
116
  private final BooleanProperty mModified = new SimpleBooleanProperty();
117
118
  public MarkdownEditor( final Workspace workspace ) {
119
    this( DOCUMENT_DEFAULT, workspace );
120
  }
121
122
  public MarkdownEditor( final File file, final Workspace workspace ) {
123
    mEncoding = open( mFile = file );
124
    mWorkspace = workspace;
125
126
    initTextArea( mTextArea );
127
    initStyle( mTextArea );
128
    initScrollPane( mScrollPane );
129
    initSpellchecker( mTextArea );
130
    initHotKeys();
131
    initUndoManager();
132
  }
133
134
  private void initTextArea( final StyleClassedTextArea textArea ) {
135
    textArea.setShowCaret( ON );
136
    textArea.setWrapText( true );
137
    textArea.requestFollowCaret();
138
    textArea.moveTo( 0 );
139
140
    textArea.textProperty().addListener( ( c, o, n ) -> {
141
      // Fire, regardless of whether the caret position has changed.
142
      mDirty.set( false );
143
144
      // Prevent the subsequent caret position change from raising dirty bits.
145
      mDirty.set( true );
146
    } );
147
148
    textArea.caretPositionProperty().addListener( ( c, o, n ) -> {
149
      // Fire when the caret position has changed and the text has not.
150
      mDirty.set( true );
151
      mDirty.set( false );
152
    } );
153
154
    textArea.focusedProperty().addListener( ( c, o, n ) -> {
155
      if( n != null && n ) {
156
        TextEditorFocusEvent.fire( this );
157
      }
158
    } );
159
  }
160
161
  private void initStyle( final StyleClassedTextArea textArea ) {
162
    textArea.getStyleClass().add( "markdown" );
163
164
    final var stylesheets = textArea.getStylesheets();
165
    stylesheets.add( getStylesheetPath( getLocale() ) );
166
167
    localeProperty().addListener( ( c, o, n ) -> {
168
      if( n != null ) {
169
        stylesheets.clear();
170
        stylesheets.add( getStylesheetPath( getLocale() ) );
171
      }
172
    } );
173
174
    fontNameProperty().addListener(
175
      ( c, o, n ) ->
176
        setFont( mTextArea, getFontName(), getFontSize() )
177
    );
178
179
    fontSizeProperty().addListener(
180
      ( c, o, n ) ->
181
        setFont( mTextArea, getFontName(), getFontSize() )
182
    );
183
184
    setFont( mTextArea, getFontName(), getFontSize() );
185
  }
186
187
  private void initScrollPane(
188
    final VirtualizedScrollPane<StyleClassedTextArea> scrollpane ) {
189
    scrollpane.setVbarPolicy( ALWAYS );
190
    setCenter( scrollpane );
191
  }
192
193
  private void initSpellchecker( final StyleClassedTextArea textarea ) {
194
    mSpeller.checkDocument( textarea );
195
    mSpeller.checkParagraphs( textarea );
196
  }
197
198
  private void initHotKeys() {
199
    addEventListener( keyPressed( ENTER ), this::onEnterPressed );
200
    addEventListener( keyPressed( X, CONTROL_DOWN ), this::cut );
201
    addEventListener( keyPressed( TAB ), this::tab );
202
    addEventListener( keyPressed( TAB, SHIFT_DOWN ), this::untab );
203
    addEventListener( keyPressed( ENTER, ALT_DOWN ), this::autofix );
204
  }
205
206
  private void initUndoManager() {
207
    final var undoManager = getUndoManager();
208
    final var markedPosition = undoManager.atMarkedPositionProperty();
209
210
    undoManager.forgetHistory();
211
    undoManager.mark();
212
    mModified.bind( Bindings.not( markedPosition ) );
213
  }
214
215
  @Override
216
  public void moveTo( final int offset ) {
217
    assert 0 <= offset && offset <= mTextArea.getLength();
218
219
    mTextArea.moveTo( offset );
220
    mTextArea.requestFollowCaret();
221
  }
222
223
  /**
224
   * Delegate the focus request to the text area itself.
225
   */
226
  @Override
227
  public void requestFocus() {
228
    mTextArea.requestFocus();
229
  }
230
231
  @Override
232
  public void setText( final String text ) {
233
    mTextArea.clear();
234
    mTextArea.appendText( text );
235
    mTextArea.getUndoManager().mark();
236
  }
237
238
  @Override
239
  public String getText() {
240
    return mTextArea.getText();
241
  }
242
243
  @Override
244
  public Charset getEncoding() {
245
    return mEncoding;
246
  }
247
248
  @Override
249
  public File getFile() {
250
    return mFile;
251
  }
252
253
  @Override
254
  public void rename( final File file ) {
255
    mFile = file;
256
  }
257
258
  @Override
259
  public void undo() {
260
    final var manager = getUndoManager();
261
    xxdo( manager::isUndoAvailable, manager::undo, "Main.status.error.undo" );
262
  }
263
264
  @Override
265
  public void redo() {
266
    final var manager = getUndoManager();
267
    xxdo( manager::isRedoAvailable, manager::redo, "Main.status.error.redo" );
268
  }
269
270
  /**
271
   * Performs an undo or redo action, if possible, otherwise displays an error
272
   * message to the user.
273
   *
274
   * @param ready  Answers whether the action can be executed.
275
   * @param action The action to execute.
276
   * @param key    The informational message key having a value to display if
277
   *               the {@link Supplier} is not ready.
278
   */
279
  private void xxdo(
280
    final Supplier<Boolean> ready, final Runnable action, final String key ) {
281
    if( ready.get() ) {
282
      action.run();
283
    }
284
    else {
285
      clue( key );
286
    }
287
  }
288
289
  @Override
290
  public void cut() {
291
    final var selected = mTextArea.getSelectedText();
292
293
    // Emulate selecting the current line by firing Home then Shift+Down Arrow.
294
    if( selected == null || selected.isEmpty() ) {
295
      // Note: mTextArea.selectLine() does not select empty lines.
296
      mTextArea.fireEvent( keyDown( HOME, false ) );
297
      mTextArea.fireEvent( keyDown( DOWN, true ) );
298
    }
299
300
    mTextArea.cut();
301
  }
302
303
  @Override
304
  public void copy() {
305
    mTextArea.copy();
306
  }
307
308
  @Override
309
  public void paste() {
310
    mTextArea.paste();
311
  }
312
313
  @Override
314
  public void selectAll() {
315
    mTextArea.selectAll();
316
  }
317
318
  @Override
319
  public void bold() {
320
    enwrap( "**" );
321
  }
322
323
  @Override
324
  public void italic() {
325
    enwrap( "*" );
326
  }
327
328
  @Override
329
  public void monospace() {
330
    enwrap( "`" );
331
  }
332
333
  @Override
334
  public void superscript() {
335
    enwrap( "^" );
336
  }
337
338
  @Override
339
  public void subscript() {
340
    enwrap( "~" );
341
  }
342
343
  @Override
344
  public void strikethrough() {
345
    enwrap( "~~" );
346
  }
347
348
  @Override
349
  public void blockquote() {
350
    block( "> " );
351
  }
352
353
  @Override
354
  public void code() {
355
    enwrap( "`" );
356
  }
357
358
  @Override
359
  public void fencedCodeBlock() {
360
    enwrap( "\n\n```\n", "\n```\n\n" );
361
  }
362
363
  @Override
364
  public void heading( final int level ) {
365
    final var hashes = new String( new char[ level ] ).replace( "\0", "#" );
366
    block( format( "%s ", hashes ) );
367
  }
368
369
  @Override
370
  public void unorderedList() {
371
    block( "* " );
372
  }
373
374
  @Override
375
  public void orderedList() {
376
    block( "1. " );
377
  }
378
379
  @Override
380
  public void horizontalRule() {
381
    block( format( "---%n%n" ) );
382
  }
383
384
  @Override
385
  public Node getNode() {
386
    return this;
387
  }
388
389
  @Override
390
  public ReadOnlyBooleanProperty modifiedProperty() {
391
    return mModified;
392
  }
393
394
  @Override
395
  public void clearModifiedProperty() {
396
    getUndoManager().mark();
397
  }
398
399
  @Override
400
  public VirtualizedScrollPane<StyleClassedTextArea> getScrollPane() {
401
    return mScrollPane;
402
  }
403
404
  @Override
405
  public StyleClassedTextArea getTextArea() {
406
    return mTextArea;
407
  }
408
409
  private final Map<String, IndexRange> mStyles = new HashMap<>();
410
411
  @Override
412
  public void stylize( final IndexRange range, final String style ) {
413
    final var began = range.getStart();
414
    final var ended = range.getEnd() + 1;
415
416
    assert 0 <= began && began <= ended;
417
    assert style != null;
418
419
    // TODO: Ensure spell check and find highlights can coexist.
420
//    final var spans = mTextArea.getStyleSpans( range );
421
//    System.out.println( "SPANS: " + spans );
422
423
//    final var spans = mTextArea.getStyleSpans( range );
424
//    mTextArea.setStyleSpans( began, merge( spans, range.getLength(), style
425
//    ) );
426
427
//    final var builder = new StyleSpansBuilder<Collection<String>>();
428
//    builder.add( singleton( style ), range.getLength() + 1 );
429
//    mTextArea.setStyleSpans( began, builder.create() );
430
431
//    final var s = mTextArea.getStyleSpans( began, ended );
432
//    System.out.println( "STYLES: " +s );
433
434
    mStyles.put( style, range );
435
    mTextArea.setStyleClass( began, ended, style );
436
437
    // Ensure that whenever the user interacts with the text that the found
438
    // word will have its highlighting removed. The handler removes itself.
439
    // This won't remove the highlighting if the caret position moves by mouse.
440
    final var handler = mTextArea.getOnKeyPressed();
441
    mTextArea.setOnKeyPressed( event -> {
442
      mTextArea.setOnKeyPressed( handler );
443
      unstylize( style );
444
    } );
445
446
    //mTextArea.setStyleSpans(began, ended, s);
447
  }
448
449
  private static StyleSpans<Collection<String>> merge(
450
    StyleSpans<Collection<String>> spans, int len, String style ) {
451
    spans = spans.overlay(
452
      singleton( singletonList( style ), len ),
453
      ( bottomSpan, list ) -> {
454
        final List<String> l =
455
          new ArrayList<>( bottomSpan.size() + list.size() );
456
        l.addAll( bottomSpan );
457
        l.addAll( list );
458
        return l;
459
      } );
460
461
    return spans;
462
  }
463
464
  @Override
465
  public void unstylize( final String style ) {
466
    final var indexes = mStyles.remove( style );
467
    if( indexes != null ) {
468
      mTextArea.clearStyle( indexes.getStart(), indexes.getEnd() + 1 );
469
    }
470
  }
471
472
  @Override
473
  public Caret getCaret() {
474
    return mCaret;
475
  }
476
477
  /**
478
   * A {@link Caret} instance is not directly coupled ot the GUI because
479
   * document processing does not always require interactive status bar
480
   * updates. This can happen when processing from the command-line. However,
481
   * the processors need the {@link Caret} instance to inject the caret
482
   * position into the document. Making the {@link CaretExtension} optional
483
   * would require more effort than using a {@link Caret} model that is
484
   * decoupled from GUI widgets.
485
   *
486
   * @param editor The text editor containing caret position information.
487
   * @return An instance of {@link Caret} that tracks the GUI caret position.
488
   */
489
  private Caret createCaret( final StyleClassedTextArea editor ) {
490
    return Caret
491
      .builder()
492
      .with( Caret.Mutator::setParagraph,
493
             () -> editor.currentParagraphProperty().getValue() )
494
      .with( Caret.Mutator::setParagraphs,
495
             () -> editor.getParagraphs().size() )
496
      .with( Caret.Mutator::setParaOffset,
497
             () -> editor.caretColumnProperty().getValue() )
498
      .with( Caret.Mutator::setTextOffset,
499
             () -> editor.caretPositionProperty().getValue() )
500
      .with( Caret.Mutator::setTextLength,
501
             () -> editor.lengthProperty().getValue() )
502
      .build();
503
  }
504
505
  /**
506
   * This method adds listeners to editor events.
507
   *
508
   * @param <T>      The event type.
509
   * @param <U>      The consumer type for the given event type.
510
   * @param event    The event of interest.
511
   * @param consumer The method to call when the event happens.
512
   */
513
  public <T extends Event, U extends T> void addEventListener(
514
    final EventPattern<? super T, ? extends U> event,
515
    final Consumer<? super U> consumer ) {
516
    Nodes.addInputMap( mTextArea, consume( event, consumer ) );
517
  }
518
519
  private void onEnterPressed( final KeyEvent ignored ) {
520
    final var currentLine = getCaretParagraph();
521
    final var matcher = PATTERN_AUTO_INDENT.matcher( currentLine );
522
523
    // By default, insert a new line by itself.
524
    String newText = NEWLINE;
525
526
    // If the pattern was matched then determine what block type to continue.
527
    if( matcher.matches() ) {
528
      if( matcher.group( 2 ).isEmpty() ) {
529
        final var pos = mTextArea.getCaretPosition();
530
        mTextArea.selectRange( pos - currentLine.length(), pos );
531
      }
532
      else {
533
        // Indent the new line with the same whitespace characters and
534
        // list markers as current line. This ensures that the indentation
535
        // is propagated.
536
        newText = newText.concat( matcher.group( 1 ) );
537
      }
538
    }
539
540
    mTextArea.replaceSelection( newText );
541
  }
542
543
  /**
544
   * Delegates to {@link #autofix()}.
545
   *
546
   * @param event Ignored.
547
   */
548
  private void autofix( final KeyEvent event ) {
549
    autofix();
550
  }
551
552
  public void autofix() {
553
    final var caretWord = getCaretWord();
554
    final var textArea = getTextArea();
555
    final var word = textArea.getText( caretWord );
556
    final var suggestions = mSpeller.checkWord( word, 10 );
557
558
    if( suggestions.isEmpty() ) {
559
      clue( "Editor.spelling.check.matches.none", word );
560
    }
561
    else if( !suggestions.contains( word ) ) {
562
      final var menu = createSuggestionsPopup();
563
      final var items = menu.getItems();
564
      textArea.setContextMenu( menu );
565
566
      for( final var correction : suggestions ) {
567
        items.add( createSuggestedItem( caretWord, correction ) );
568
      }
569
570
      textArea.getCaretBounds().ifPresent(
571
        bounds -> {
572
          menu.setOnShown( event -> menu.requestFocus() );
573
          menu.show( textArea, bounds.getCenterX(), bounds.getCenterY() );
574
        }
575
      );
576
    }
577
    else {
578
      clue( "Editor.spelling.check.matches.okay", word );
579
    }
580
  }
581
582
  private ContextMenu createSuggestionsPopup() {
583
    final var menu = new ContextMenu();
584
585
    menu.setAutoHide( true );
586
    menu.setHideOnEscape( true );
587
    menu.setOnHidden( event -> getTextArea().setContextMenu( null ) );
588
589
    return menu;
590
  }
591
592
  /**
593
   * Creates a menu item capable of replacing a word under the cursor.
594
   *
595
   * @param i The beginning and ending text offset to replace.
596
   * @param s The text to replace at the given offset.
597
   * @return The menu item that, if actioned, will replace the text.
598
   */
599
  private MenuItem createSuggestedItem( final IndexRange i, final String s ) {
600
    final var menuItem = new MenuItem( s );
601
602
    menuItem.setOnAction( event -> getTextArea().replaceText( i, s ) );
603
604
    return menuItem;
12
import javafx.beans.binding.Bindings;
13
import javafx.beans.property.*;
14
import javafx.beans.value.ChangeListener;
15
import javafx.event.Event;
16
import javafx.scene.Node;
17
import javafx.scene.control.IndexRange;
18
import javafx.scene.input.KeyEvent;
19
import javafx.scene.layout.BorderPane;
20
import org.fxmisc.flowless.VirtualizedScrollPane;
21
import org.fxmisc.richtext.StyleClassedTextArea;
22
import org.fxmisc.richtext.model.StyleSpans;
23
import org.fxmisc.undo.UndoManager;
24
import org.fxmisc.wellbehaved.event.EventPattern;
25
import org.fxmisc.wellbehaved.event.Nodes;
26
27
import java.io.File;
28
import java.nio.charset.Charset;
29
import java.text.BreakIterator;
30
import java.text.MessageFormat;
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.constants.Constants.*;
38
import static com.keenwrite.events.StatusEvent.clue;
39
import static com.keenwrite.io.MediaType.TEXT_MARKDOWN;
40
import static com.keenwrite.io.MediaType.TEXT_R_MARKDOWN;
41
import static com.keenwrite.preferences.AppKeys.*;
42
import static java.lang.Character.isWhitespace;
43
import static java.lang.String.format;
44
import static java.util.Collections.singletonList;
45
import static javafx.application.Platform.runLater;
46
import static javafx.scene.control.ScrollPane.ScrollBarPolicy.ALWAYS;
47
import static javafx.scene.input.KeyCode.*;
48
import static javafx.scene.input.KeyCombination.*;
49
import static org.apache.commons.lang3.StringUtils.stripEnd;
50
import static org.apache.commons.lang3.StringUtils.stripStart;
51
import static org.fxmisc.richtext.Caret.CaretVisibility.ON;
52
import static org.fxmisc.richtext.model.StyleSpans.singleton;
53
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
54
import static org.fxmisc.wellbehaved.event.InputMap.consume;
55
56
/**
57
 * Responsible for editing Markdown documents.
58
 */
59
public final class MarkdownEditor extends BorderPane implements TextEditor {
60
  /**
61
   * Regular expression that matches the type of markup block. This is used
62
   * when Enter is pressed to continue the block environment.
63
   */
64
  private static final Pattern PATTERN_AUTO_INDENT = Pattern.compile(
65
    "(\\s*[*+-]\\s+|\\s*[0-9]+\\.\\s+|\\s+)(.*)" );
66
67
  private final Workspace mWorkspace;
68
69
  /**
70
   * The text editor.
71
   */
72
  private final StyleClassedTextArea mTextArea =
73
    new StyleClassedTextArea( false );
74
75
  /**
76
   * Wraps the text editor in scrollbars.
77
   */
78
  private final VirtualizedScrollPane<StyleClassedTextArea> mScrollPane =
79
    new VirtualizedScrollPane<>( mTextArea );
80
81
  /**
82
   * Tracks where the caret is located in this document. This offers observable
83
   * properties for caret position changes.
84
   */
85
  private final Caret mCaret = createCaret( mTextArea );
86
87
  /**
88
   * File being edited by this editor instance.
89
   */
90
  private File mFile;
91
92
  /**
93
   * Set to {@code true} upon text or caret position changes. Value is {@code
94
   * false} by default.
95
   */
96
  private final BooleanProperty mDirty = new SimpleBooleanProperty();
97
98
  /**
99
   * Opened file's character encoding, or {@link Constants#DEFAULT_CHARSET} if
100
   * either no encoding could be determined or this is a new (empty) file.
101
   */
102
  private final Charset mEncoding;
103
104
  /**
105
   * Tracks whether the in-memory definitions have changed with respect to the
106
   * persisted definitions.
107
   */
108
  private final BooleanProperty mModified = new SimpleBooleanProperty();
109
110
  public MarkdownEditor( final File file, final Workspace workspace ) {
111
    mEncoding = open( mFile = file );
112
    mWorkspace = workspace;
113
114
    initTextArea( mTextArea );
115
    initStyle( mTextArea );
116
    initScrollPane( mScrollPane );
117
    initHotKeys();
118
    initUndoManager();
119
  }
120
121
  private void initTextArea( final StyleClassedTextArea textArea ) {
122
    textArea.setShowCaret( ON );
123
    textArea.setWrapText( true );
124
    textArea.requestFollowCaret();
125
    textArea.moveTo( 0 );
126
127
    textArea.textProperty().addListener( ( c, o, n ) -> {
128
      // Fire, regardless of whether the caret position has changed.
129
      mDirty.set( false );
130
131
      // Prevent the subsequent caret position change from raising dirty bits.
132
      mDirty.set( true );
133
    } );
134
135
    textArea.caretPositionProperty().addListener( ( c, o, n ) -> {
136
      // Fire when the caret position has changed and the text has not.
137
      mDirty.set( true );
138
      mDirty.set( false );
139
    } );
140
141
    textArea.focusedProperty().addListener( ( c, o, n ) -> {
142
      if( n != null && n ) {
143
        TextEditorFocusEvent.fire( this );
144
      }
145
    } );
146
  }
147
148
  private void initStyle( final StyleClassedTextArea textArea ) {
149
    textArea.getStyleClass().add( "markdown" );
150
151
    final var stylesheets = textArea.getStylesheets();
152
    stylesheets.add( getStylesheetPath( getLocale() ) );
153
154
    localeProperty().addListener( ( c, o, n ) -> {
155
      if( n != null ) {
156
        stylesheets.clear();
157
        stylesheets.add( getStylesheetPath( getLocale() ) );
158
      }
159
    } );
160
161
    fontNameProperty().addListener(
162
      ( c, o, n ) ->
163
        setFont( mTextArea, getFontName(), getFontSize() )
164
    );
165
166
    fontSizeProperty().addListener(
167
      ( c, o, n ) ->
168
        setFont( mTextArea, getFontName(), getFontSize() )
169
    );
170
171
    setFont( mTextArea, getFontName(), getFontSize() );
172
  }
173
174
  private void initScrollPane(
175
    final VirtualizedScrollPane<StyleClassedTextArea> scrollpane ) {
176
    scrollpane.setVbarPolicy( ALWAYS );
177
    setCenter( scrollpane );
178
  }
179
180
  private void initHotKeys() {
181
    addEventListener( keyPressed( ENTER ), this::onEnterPressed );
182
    addEventListener( keyPressed( X, CONTROL_DOWN ), this::cut );
183
    addEventListener( keyPressed( TAB ), this::tab );
184
    addEventListener( keyPressed( TAB, SHIFT_DOWN ), this::untab );
185
  }
186
187
  private void initUndoManager() {
188
    final var undoManager = getUndoManager();
189
    final var markedPosition = undoManager.atMarkedPositionProperty();
190
191
    undoManager.forgetHistory();
192
    undoManager.mark();
193
    mModified.bind( Bindings.not( markedPosition ) );
194
  }
195
196
  @Override
197
  public void moveTo( final int offset ) {
198
    assert 0 <= offset && offset <= mTextArea.getLength();
199
200
    mTextArea.moveTo( offset );
201
    mTextArea.requestFollowCaret();
202
  }
203
204
  /**
205
   * Delegate the focus request to the text area itself.
206
   */
207
  @Override
208
  public void requestFocus() {
209
    mTextArea.requestFocus();
210
  }
211
212
  @Override
213
  public void setText( final String text ) {
214
    mTextArea.clear();
215
    mTextArea.appendText( text );
216
    mTextArea.getUndoManager().mark();
217
  }
218
219
  @Override
220
  public String getText() {
221
    return mTextArea.getText();
222
  }
223
224
  @Override
225
  public Charset getEncoding() {
226
    return mEncoding;
227
  }
228
229
  @Override
230
  public File getFile() {
231
    return mFile;
232
  }
233
234
  @Override
235
  public void rename( final File file ) {
236
    mFile = file;
237
  }
238
239
  @Override
240
  public void undo() {
241
    final var manager = getUndoManager();
242
    xxdo( manager::isUndoAvailable, manager::undo, "Main.status.error.undo" );
243
  }
244
245
  @Override
246
  public void redo() {
247
    final var manager = getUndoManager();
248
    xxdo( manager::isRedoAvailable, manager::redo, "Main.status.error.redo" );
249
  }
250
251
  /**
252
   * Performs an undo or redo action, if possible, otherwise displays an error
253
   * message to the user.
254
   *
255
   * @param ready  Answers whether the action can be executed.
256
   * @param action The action to execute.
257
   * @param key    The informational message key having a value to display if
258
   *               the {@link Supplier} is not ready.
259
   */
260
  private void xxdo(
261
    final Supplier<Boolean> ready, final Runnable action, final String key ) {
262
    if( ready.get() ) {
263
      action.run();
264
    }
265
    else {
266
      clue( key );
267
    }
268
  }
269
270
  @Override
271
  public void cut() {
272
    final var selected = mTextArea.getSelectedText();
273
274
    // Emulate selecting the current line by firing Home then Shift+Down Arrow.
275
    if( selected == null || selected.isEmpty() ) {
276
      // Note: mTextArea.selectLine() does not select empty lines.
277
      mTextArea.fireEvent( keyDown( HOME, false ) );
278
      mTextArea.fireEvent( keyDown( DOWN, true ) );
279
    }
280
281
    mTextArea.cut();
282
  }
283
284
  @Override
285
  public void copy() {
286
    mTextArea.copy();
287
  }
288
289
  @Override
290
  public void paste() {
291
    mTextArea.paste();
292
  }
293
294
  @Override
295
  public void selectAll() {
296
    mTextArea.selectAll();
297
  }
298
299
  @Override
300
  public void bold() {
301
    enwrap( "**" );
302
  }
303
304
  @Override
305
  public void italic() {
306
    enwrap( "*" );
307
  }
308
309
  @Override
310
  public void monospace() {
311
    enwrap( "`" );
312
  }
313
314
  @Override
315
  public void superscript() {
316
    enwrap( "^" );
317
  }
318
319
  @Override
320
  public void subscript() {
321
    enwrap( "~" );
322
  }
323
324
  @Override
325
  public void strikethrough() {
326
    enwrap( "~~" );
327
  }
328
329
  @Override
330
  public void blockquote() {
331
    block( "> " );
332
  }
333
334
  @Override
335
  public void code() {
336
    enwrap( "`" );
337
  }
338
339
  @Override
340
  public void fencedCodeBlock() {
341
    enwrap( "\n\n```\n", "\n```\n\n" );
342
  }
343
344
  @Override
345
  public void heading( final int level ) {
346
    final var hashes = new String( new char[ level ] ).replace( "\0", "#" );
347
    block( format( "%s ", hashes ) );
348
  }
349
350
  @Override
351
  public void unorderedList() {
352
    block( "* " );
353
  }
354
355
  @Override
356
  public void orderedList() {
357
    block( "1. " );
358
  }
359
360
  @Override
361
  public void horizontalRule() {
362
    block( format( "---%n%n" ) );
363
  }
364
365
  @Override
366
  public Node getNode() {
367
    return this;
368
  }
369
370
  @Override
371
  public ReadOnlyBooleanProperty modifiedProperty() {
372
    return mModified;
373
  }
374
375
  @Override
376
  public void clearModifiedProperty() {
377
    getUndoManager().mark();
378
  }
379
380
  @Override
381
  public VirtualizedScrollPane<StyleClassedTextArea> getScrollPane() {
382
    return mScrollPane;
383
  }
384
385
  @Override
386
  public StyleClassedTextArea getTextArea() {
387
    return mTextArea;
388
  }
389
390
  private final Map<String, IndexRange> mStyles = new HashMap<>();
391
392
  @Override
393
  public void stylize( final IndexRange range, final String style ) {
394
    final var began = range.getStart();
395
    final var ended = range.getEnd() + 1;
396
397
    assert 0 <= began && began <= ended;
398
    assert style != null;
399
400
    // TODO: Ensure spell check and find highlights can coexist.
401
//    final var spans = mTextArea.getStyleSpans( range );
402
//    System.out.println( "SPANS: " + spans );
403
404
//    final var spans = mTextArea.getStyleSpans( range );
405
//    mTextArea.setStyleSpans( began, merge( spans, range.getLength(), style
406
//    ) );
407
408
//    final var builder = new StyleSpansBuilder<Collection<String>>();
409
//    builder.add( singleton( style ), range.getLength() + 1 );
410
//    mTextArea.setStyleSpans( began, builder.create() );
411
412
//    final var s = mTextArea.getStyleSpans( began, ended );
413
//    System.out.println( "STYLES: " +s );
414
415
    mStyles.put( style, range );
416
    mTextArea.setStyleClass( began, ended, style );
417
418
    // Ensure that whenever the user interacts with the text that the found
419
    // word will have its highlighting removed. The handler removes itself.
420
    // This won't remove the highlighting if the caret position moves by mouse.
421
    final var handler = mTextArea.getOnKeyPressed();
422
    mTextArea.setOnKeyPressed( event -> {
423
      mTextArea.setOnKeyPressed( handler );
424
      unstylize( style );
425
    } );
426
427
    //mTextArea.setStyleSpans(began, ended, s);
428
  }
429
430
  private static StyleSpans<Collection<String>> merge(
431
    StyleSpans<Collection<String>> spans, int len, String style ) {
432
    spans = spans.overlay(
433
      singleton( singletonList( style ), len ),
434
      ( bottomSpan, list ) -> {
435
        final List<String> l =
436
          new ArrayList<>( bottomSpan.size() + list.size() );
437
        l.addAll( bottomSpan );
438
        l.addAll( list );
439
        return l;
440
      } );
441
442
    return spans;
443
  }
444
445
  @Override
446
  public void unstylize( final String style ) {
447
    final var indexes = mStyles.remove( style );
448
    if( indexes != null ) {
449
      mTextArea.clearStyle( indexes.getStart(), indexes.getEnd() + 1 );
450
    }
451
  }
452
453
  @Override
454
  public Caret getCaret() {
455
    return mCaret;
456
  }
457
458
  /**
459
   * A {@link Caret} instance is not directly coupled ot the GUI because
460
   * document processing does not always require interactive status bar
461
   * updates. This can happen when processing from the command-line. However,
462
   * the processors need the {@link Caret} instance to inject the caret
463
   * position into the document. Making the {@link CaretExtension} optional
464
   * would require more effort than using a {@link Caret} model that is
465
   * decoupled from GUI widgets.
466
   *
467
   * @param editor The text editor containing caret position information.
468
   * @return An instance of {@link Caret} that tracks the GUI caret position.
469
   */
470
  private Caret createCaret( final StyleClassedTextArea editor ) {
471
    return Caret
472
      .builder()
473
      .with( Caret.Mutator::setParagraph,
474
             () -> editor.currentParagraphProperty().getValue() )
475
      .with( Caret.Mutator::setParagraphs,
476
             () -> editor.getParagraphs().size() )
477
      .with( Caret.Mutator::setParaOffset,
478
             () -> editor.caretColumnProperty().getValue() )
479
      .with( Caret.Mutator::setTextOffset,
480
             () -> editor.caretPositionProperty().getValue() )
481
      .with( Caret.Mutator::setTextLength,
482
             () -> editor.lengthProperty().getValue() )
483
      .build();
484
  }
485
486
  /**
487
   * This method adds listeners to editor events.
488
   *
489
   * @param <T>      The event type.
490
   * @param <U>      The consumer type for the given event type.
491
   * @param event    The event of interest.
492
   * @param consumer The method to call when the event happens.
493
   */
494
  public <T extends Event, U extends T> void addEventListener(
495
    final EventPattern<? super T, ? extends U> event,
496
    final Consumer<? super U> consumer ) {
497
    Nodes.addInputMap( mTextArea, consume( event, consumer ) );
498
  }
499
500
  private void onEnterPressed( final KeyEvent ignored ) {
501
    final var currentLine = getCaretParagraph();
502
    final var matcher = PATTERN_AUTO_INDENT.matcher( currentLine );
503
504
    // By default, insert a new line by itself.
505
    String newText = NEWLINE;
506
507
    // If the pattern was matched then determine what block type to continue.
508
    if( matcher.matches() ) {
509
      if( matcher.group( 2 ).isEmpty() ) {
510
        final var pos = mTextArea.getCaretPosition();
511
        mTextArea.selectRange( pos - currentLine.length(), pos );
512
      }
513
      else {
514
        // Indent the new line with the same whitespace characters and
515
        // list markers as current line. This ensures that the indentation
516
        // is propagated.
517
        newText = newText.concat( matcher.group( 1 ) );
518
      }
519
    }
520
521
    mTextArea.replaceSelection( newText );
605522
  }
606523
A src/main/java/com/keenwrite/events/spelling/LexiconEvent.java
1
/* Copyright 2022 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.events.spelling;
3
4
import com.keenwrite.events.AppEvent;
5
6
public abstract class LexiconEvent implements AppEvent {
7
}
18
A src/main/java/com/keenwrite/events/spelling/LexiconLoadedEvent.java
1
/* Copyright 2022 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.events.spelling;
3
4
import java.util.Map;
5
6
/**
7
 * Collates information about the lexicon. Fired when the lexicon has been
8
 * fully loaded into memory.
9
 */
10
public class LexiconLoadedEvent extends LexiconEvent {
11
12
  private final Map<String, Long> mLexicon;
13
14
  private LexiconLoadedEvent( final Map<String, Long> lexicon ) {
15
    mLexicon = lexicon;
16
  }
17
18
  public static void fire( final Map<String, Long> lexicon ) {
19
    new LexiconLoadedEvent( lexicon ).publish();
20
  }
21
22
  /**
23
   * Returns a word-frequency map used by the spell checking library.
24
   *
25
   * @return The lexicon that was loaded.
26
   */
27
  public Map<String, Long> getLexicon() {
28
    return mLexicon;
29
  }
30
}
131
M src/main/java/com/keenwrite/preferences/LocaleProperty.java
2020
2121
  /**
22
   * Lists the locales having fonts that are supported by the application.
22
   * The {@link Locale}s are used for multiple purposes, including:
23
   *
24
   * <ul>
25
   *   <li>supported text editor font listing in preferences dialog;</li>
26
   *   <li>text editor CSS;</li>
27
   *   <li>preview window CSS; and</li>
28
   *   <li>lexicon to load for spellcheck.</li>
29
   * </ul>
30
   *
2331
   * When the Markdown and preview CSS files are loaded, a general file is
2432
   * first loaded, then a specific file is loaded according to the locale.
2533
   * The specific file overrides font families so that different languages
2634
   * may be presented.
2735
   * <p>
2836
   * Using an instance of {@link LinkedHashMap} preserves display order.
2937
   * </p>
3038
   * <p>
3139
   * See
32
   * <a href="https://oracle.com/java/technologies/javase/jdk12locales.html">
33
   * JDK 12 Locales
40
   * <a href="https://www.oracle.com/java/technologies/javase/jdk19-suported-locales.html">
41
   * JDK 19 Supported Locales
3442
   * </a> for details.
3543
   * </p>
3644
   */
3745
  private static final Map<String, Locale> sLocales = new LinkedHashMap<>();
3846
3947
  static {
48
    @SuppressWarnings( "SpellCheckingInspection" )
4049
    final String[] tags = {
50
      // English
4151
      "en-Latn-AU",
4252
      "en-Latn-CA",
4353
      "en-Latn-GB",
4454
      "en-Latn-NZ",
4555
      "en-Latn-US",
4656
      "en-Latn-ZA",
57
      // German
58
      "de-Latn-AT",
59
      "de-Latn-DE",
60
      "de-Latn-LU",
61
      "de-Latn-CH",
62
      // Spanish
63
      "es-Latn-AR",
64
      "es-Latn-BO",
65
      "es-Latn-CL",
66
      "es-Latn-CO",
67
      "es-Latn-CR",
68
      "es-Latn-DO",
69
      "es-Latn-EC",
70
      "es-Latn-SV",
71
      "es-Latn-GT",
72
      "es-Latn-HN",
73
      "es-Latn-MX",
74
      "es-Latn-NI",
75
      "es-Latn-PA",
76
      "es-Latn-PY",
77
      "es-Latn-PE",
78
      "es-Latn-PR",
79
      "es-Latn-ES",
80
      "es-Latn-US",
81
      "es-Latn-UY",
82
      "es-Latn-VE",
83
      // French
84
      "fr-Latn-BE",
85
      "fr-Latn-CA",
86
      "fr-Latn-FR",
87
      "fr-Latn-LU",
88
      "fr-Latn-CH",
89
      // Hebrew
90
      //"iw-Hebr-IL",
91
      // Italian
92
      "it-Latn-IT",
93
      "it-Latn-CH",
94
      // Japanese
4795
      "ja-Jpan-JP",
96
      // Korean
4897
      "ko-Kore-KR",
98
      // Chinese
4999
      "zh-Hans-CN",
50100
      "zh-Hans-SG",
...
78128
79129
  private static Locale sanitize( final Locale locale ) {
80
    // If the language is "und"efined then use the default locale.
130
    // If the language is undefined then use the default locale.
81131
    return locale == null || "und".equalsIgnoreCase( locale.toLanguageTag() )
82132
      ? LOCALE_DEFAULT
...
90140
  /**
91141
   * Performs an O(n) search through the given map to find the key that is
92
   * mapped to the given value. A bi-directional map would be faster, but
142
   * mapped to the given value. A bidirectional map would be faster, but
93143
   * also introduces additional dependencies. This doesn't need to be fast
94144
   * because it happens once, at start up, and there aren't a lot of values.
A src/main/java/com/keenwrite/spelling/impl/Lexicon.java
1
/* Copyright 2022 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.spelling.impl;
3
4
import com.keenwrite.events.spelling.LexiconLoadedEvent;
5
import com.keenwrite.exceptions.MissingFileException;
6
7
import java.io.BufferedReader;
8
import java.io.InputStream;
9
import java.io.InputStreamReader;
10
import java.util.HashMap;
11
import java.util.Locale;
12
13
import static com.keenwrite.constants.Constants.LEXICONS_DIRECTORY;
14
import static com.keenwrite.events.StatusEvent.clue;
15
import static java.lang.String.format;
16
import static java.nio.charset.StandardCharsets.UTF_8;
17
18
/**
19
 * Responsible for loading a set of single words, asynchronously.
20
 */
21
public final class Lexicon {
22
  /**
23
   * Most lexicons have 100,000 words.
24
   */
25
  private static final int LEXICON_CAPACITY = 100_000;
26
27
  /**
28
   * The word-frequency entries are tab-delimited.
29
   */
30
  private static final char DELIMITER = '\t';
31
32
  /**
33
   * Load the lexicon into memory then fire an event indicating that the
34
   * word-frequency pairs are available to use for spellchecking. This
35
   * happens asynchronously so that the UI can load faster.
36
   *
37
   * @param locale The locale having a corresponding lexicon to load.
38
   */
39
  public static void read( final Locale locale ) {
40
    assert locale != null;
41
42
    new Thread( read( toResourcePath( locale ) ) ).start();
43
  }
44
45
  private static Runnable read( final String path ) {
46
    return () -> {
47
      try( final var resource = openResource( path ) ) {
48
        read( resource );
49
      } catch( final Exception ex ) {
50
        clue( ex );
51
      }
52
    };
53
  }
54
55
  private static void read( final InputStream resource ) {
56
    try( final var input = new InputStreamReader( resource, UTF_8 );
57
         final var reader = new BufferedReader( input ) ) {
58
      read( reader );
59
    } catch( final Exception ex ) {
60
      clue( ex );
61
    }
62
  }
63
64
  private static void read( final BufferedReader reader ) {
65
    try {
66
      long count = 0;
67
      final var lexicon = new HashMap<String, Long>( LEXICON_CAPACITY );
68
      String line;
69
70
      while( (line = reader.readLine()) != null ) {
71
        final var index = line.indexOf( DELIMITER );
72
        final var word = line.substring( 0, index == -1 ? 0 : index );
73
        final var frequency = parse( line.substring( index + 1 ) );
74
75
        lexicon.put( word, frequency );
76
77
        // Slower machines may benefit users by showing a loading message.
78
        if( ++count % 25_000 == 0 ) {
79
          status( "loading", count );
80
        }
81
      }
82
83
      // Indicate that loading the lexicon is finished.
84
      status( "loaded", count );
85
      LexiconLoadedEvent.fire( lexicon );
86
    } catch( final Exception ex ) {
87
      clue( ex );
88
    }
89
  }
90
91
  /**
92
   * Prevents autoboxing and uses cached values when possible. A return value
93
   * of 0L means that the word will receive the lowest priority. If there's
94
   * an error (i.e., data corruption) parsing the number, the spell checker
95
   * will still work, but be suboptimal for all erroneous entries.
96
   *
97
   * @param number The numeric value to parse into a long object.
98
   * @return The parsed value, or 0L if the number couldn't be parsed.
99
   */
100
  private static Long parse( final String number ) {
101
    try {
102
      return Long.valueOf( number );
103
    } catch( final NumberFormatException ex ) {
104
      clue( ex );
105
      return 0L;
106
    }
107
  }
108
109
  private static InputStream openResource( final String path )
110
    throws MissingFileException {
111
    final var resource = Lexicon.class.getResourceAsStream( path );
112
113
    if( resource == null ) {
114
      throw new MissingFileException( path );
115
    }
116
117
    return resource;
118
  }
119
120
  /**
121
   * Convert a {@link Locale} into a path that can be loaded as a resource.
122
   *
123
   * @param locale The {@link Locale} to convert to a resource.
124
   * @return The slash-separated path to a lexicon resource file.
125
   */
126
  private static String toResourcePath( final Locale locale ) {
127
    final var language = locale.getLanguage();
128
    return format( "/%s/%s.txt", LEXICONS_DIRECTORY, language );
129
  }
130
131
  private static void status( final String s, final long count ) {
132
    clue( "Main.status.lexicon." + s, count );
133
  }
134
}
1135
M src/main/java/com/keenwrite/spelling/impl/SymSpellSpeller.java
22
package com.keenwrite.spelling.impl;
33
4
import com.keenwrite.exceptions.MissingFileException;
54
import com.keenwrite.spelling.api.SpellCheckListener;
65
import com.keenwrite.spelling.api.SpellChecker;
76
import io.gitlab.rxp90.jsymspell.SymSpell;
87
import io.gitlab.rxp90.jsymspell.SymSpellBuilder;
98
import io.gitlab.rxp90.jsymspell.Verbosity;
109
import io.gitlab.rxp90.jsymspell.api.SuggestItem;
10
import io.gitlab.rxp90.jsymspell.exceptions.NotInitializedException;
1111
12
import java.io.BufferedReader;
13
import java.io.InputStreamReader;
1412
import java.text.BreakIterator;
1513
import java.util.ArrayList;
16
import java.util.HashMap;
1714
import java.util.List;
1815
import java.util.Map;
1916
20
import static com.keenwrite.constants.Constants.LEXICONS_DIRECTORY;
21
import static com.keenwrite.events.StatusEvent.clue;
2217
import static io.gitlab.rxp90.jsymspell.Verbosity.ALL;
2318
import static io.gitlab.rxp90.jsymspell.Verbosity.CLOSEST;
2419
import static java.lang.Character.isLetter;
25
import static java.lang.Long.parseLong;
26
import static java.nio.charset.StandardCharsets.UTF_8;
2720
2821
/**
...
3629
   * Creates a new spellchecker for a lexicon of words in the specified file.
3730
   *
38
   * @param filename Lexicon language file (e.g., "en.txt").
31
   * @param lexicon The word-frequency map.
3932
   * @return An instance of {@link SpellChecker} that can check if a word
4033
   * is correct and suggest alternatives, or {@link PermissiveSpeller} if the
4134
   * lexicon cannot be loaded.
4235
   */
43
  public static SpellChecker forLexicon( final String filename ) {
44
    assert filename != null;
45
    assert !filename.isBlank();
46
47
    try {
48
      final var lexicon = readLexicon( filename );
49
      return SymSpellSpeller.forLexicon( lexicon );
50
    } catch( final Exception ex ) {
51
      clue( ex );
52
      return new PermissiveSpeller();
53
    }
54
  }
55
56
  private static SpellChecker forLexicon( final Map<String, Long> lexicon ) {
36
  public static SpellChecker forLexicon( final Map<String, Long> lexicon )
37
    throws NotInitializedException {
5738
    assert lexicon != null;
5839
    assert !lexicon.isEmpty();
5940
60
    try {
61
      return new SymSpellSpeller(
62
        new SymSpellBuilder()
63
          .setUnigramLexicon( lexicon )
64
          .build()
65
      );
66
    } catch( final Exception ex ) {
67
      clue( ex );
68
      return new PermissiveSpeller();
69
    }
41
    final var symSpell = new SymSpellBuilder()
42
      .setUnigramLexicon( lexicon )
43
      .build();
44
45
    return new SymSpellSpeller( symSpell );
7046
  }
7147
...
143119
      previousIndex = boundaryIndex;
144120
      boundaryIndex = mBreakIterator.next();
145
    }
146
  }
147
148
  @SuppressWarnings( "SameParameterValue" )
149
  private static Map<String, Long> readLexicon( final String filename )
150
    throws Exception {
151
    assert filename != null;
152
    assert !filename.isEmpty();
153
154
    final var path = '/' + LEXICONS_DIRECTORY + '/' + filename;
155
    final var map = new HashMap<String, Long>();
156
157
    try( final var resource =
158
           SymSpellSpeller.class.getResourceAsStream( path ) ) {
159
      if( resource == null ) {
160
        throw new MissingFileException( path );
161
      }
162
163
      try( final var isr = new InputStreamReader( resource, UTF_8 );
164
           final var reader = new BufferedReader( isr ) ) {
165
        String line;
166
167
        while( (line = reader.readLine()) != null ) {
168
          final var tokens = line.split( "\\t" );
169
          map.put( tokens[ 0 ], parseLong( tokens[ 1 ] ) );
170
        }
171
      }
172121
    }
173
174
    return map;
175122
  }
176123
D src/main/java/com/keenwrite/spelling/impl/TextEditorSpeller.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.spelling.impl;
3
4
import com.keenwrite.spelling.api.SpellCheckListener;
5
import com.keenwrite.spelling.api.SpellChecker;
6
import com.vladsch.flexmark.parser.Parser;
7
import com.vladsch.flexmark.util.ast.NodeVisitor;
8
import com.vladsch.flexmark.util.ast.VisitHandler;
9
import org.fxmisc.richtext.StyleClassedTextArea;
10
import org.fxmisc.richtext.model.StyleSpansBuilder;
11
12
import java.util.Collection;
13
import java.util.List;
14
import java.util.concurrent.atomic.AtomicInteger;
15
16
import static com.keenwrite.spelling.impl.SymSpellSpeller.forLexicon;
17
import static java.util.Collections.emptyList;
18
import static java.util.Collections.singleton;
19
import static org.fxmisc.richtext.model.TwoDimensional.Bias.Forward;
20
21
/**
22
 * Responsible for checking the spelling of a document being edited.
23
 */
24
public final class TextEditorSpeller {
25
  /**
26
   * Only load the dictionary into memory once, because it's huge.
27
   */
28
  private static final SpellChecker sSpellChecker = forLexicon( "en.txt" );
29
30
  private final Parser mParser;
31
32
  public TextEditorSpeller() {
33
    mParser = Parser.builder().build();
34
  }
35
36
  /**
37
   * Delegates to {@link #spellcheck(StyleClassedTextArea, String, int)}.
38
   * call to spell check the entire document.
39
   */
40
  public void checkDocument( final StyleClassedTextArea editor ) {
41
    spellcheck( editor, editor.getText(), -1 );
42
  }
43
44
  /**
45
   * Listen for changes to the any particular paragraph and perform a quick
46
   * spell check upon it. The style classes in the editor will be changed to
47
   * mark any spelling mistakes in the paragraph. The user may then interact
48
   * with any misspelled word (i.e., any piece of text that is marked) to
49
   * revise the spelling.
50
   *
51
   * @param editor The text area containing paragraphs to spellcheck.
52
   */
53
  public void checkParagraphs( final StyleClassedTextArea editor ) {
54
    // Use the plain text changes so that notifications of style changes
55
    // are suppressed. Checking against the identity ensures that only
56
    // new text additions or deletions trigger proofreading.
57
    editor.plainTextChanges()
58
          .filter( p -> !p.isIdentity() ).subscribe( change -> {
59
60
            // Check current paragraph; the whole document was checked upon
61
            // opening.
62
            final var offset = change.getPosition();
63
            final var position = editor.offsetToPosition( offset, Forward );
64
            final var paraId = position.getMajor();
65
            final var paragraph = editor.getParagraph( paraId );
66
            final var text = paragraph.getText();
67
68
            // Prevent doubling-up styles.
69
            editor.clearStyle( paraId );
70
71
            spellcheck( editor, text, paraId );
72
          } );
73
  }
74
75
  /**
76
   * Spellchecks a subset of the entire document.
77
   *
78
   * @param text   Look up words for this text in the lexicon.
79
   * @param paraId Set to -1 to apply resulting style spans to the entire
80
   *               text.
81
   */
82
  private void spellcheck(
83
    final StyleClassedTextArea editor, final String text, final int paraId ) {
84
    final var builder = new StyleSpansBuilder<Collection<String>>();
85
    final var runningIndex = new AtomicInteger( 0 );
86
87
    // The text nodes must be relayed through a contextual "visitor" that
88
    // can return text in chunks with correlative offsets into the string.
89
    // This allows Markdown, R Markdown, XML, and R XML documents to return
90
    // sets of words to check.
91
    final var node = mParser.parse( text );
92
    final var visitor = new TextVisitor( ( visited, bIndex, eIndex ) -> {
93
      // Treat hyphenated compound words as individual words.
94
      final var check = visited.replace( '-', ' ' );
95
96
      sSpellChecker.proofread( check, ( misspelled, prevIndex, currIndex ) -> {
97
        prevIndex += bIndex;
98
        currIndex += bIndex;
99
100
        // Clear styling between lexiconically absent words.
101
        builder.add( emptyList(), prevIndex - runningIndex.get() );
102
        builder.add( singleton( "spelling" ), currIndex - prevIndex );
103
        runningIndex.set( currIndex );
104
      } );
105
    } );
106
107
    visitor.visit( node );
108
109
    // If the running index was set, at least one word triggered the listener.
110
    if( runningIndex.get() > 0 ) {
111
      // Clear styling after the last lexiconically absent word.
112
      builder.add( emptyList(), text.length() - runningIndex.get() );
113
114
      final var spans = builder.create();
115
116
      if( paraId >= 0 ) {
117
        editor.setStyleSpans( paraId, 0, spans );
118
      }
119
      else {
120
        editor.setStyleSpans( 0, spans );
121
      }
122
    }
123
  }
124
125
  /**
126
   * Returns a list of suggests for the given word. This is typically used to
127
   * check for suitable replacements of the word at the caret position.
128
   *
129
   * @param word  The word to spellcheck.
130
   * @param count The maximum number of suggested alternatives to return.
131
   * @return A list of recommended spellings for the given word.
132
   */
133
  public List<String> checkWord( final String word, final int count ) {
134
    return sSpellChecker.suggestions( word, count );
135
  }
136
137
  /**
138
   * TODO: #59 -- Replace with generic interface; provide Markdown/XML
139
   * implementations.
140
   */
141
  private static final class TextVisitor {
142
    private final NodeVisitor mVisitor = new NodeVisitor( new VisitHandler<>(
143
      com.vladsch.flexmark.ast.Text.class, this::visit )
144
    );
145
146
    private final SpellCheckListener mConsumer;
147
148
    public TextVisitor( final SpellCheckListener consumer ) {
149
      mConsumer = consumer;
150
    }
151
152
    private void visit( final com.vladsch.flexmark.util.ast.Node node ) {
153
      if( node instanceof com.vladsch.flexmark.ast.Text ) {
154
        mConsumer.accept( node.getChars().toString(),
155
                          node.getStartOffset(),
156
                          node.getEndOffset() );
157
      }
158
159
      mVisitor.visitChildren( node );
160
    }
161
  }
162
}
1631
A src/main/java/com/keenwrite/ui/spelling/TextEditorSpellChecker.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.ui.spelling;
3
4
import com.keenwrite.editors.TextEditor;
5
import com.keenwrite.spelling.api.SpellCheckListener;
6
import com.keenwrite.spelling.api.SpellChecker;
7
import com.vladsch.flexmark.parser.Parser;
8
import com.vladsch.flexmark.util.ast.NodeVisitor;
9
import com.vladsch.flexmark.util.ast.VisitHandler;
10
import javafx.beans.property.ObjectProperty;
11
import javafx.scene.control.ContextMenu;
12
import javafx.scene.control.IndexRange;
13
import javafx.scene.control.MenuItem;
14
import org.fxmisc.richtext.StyleClassedTextArea;
15
import org.fxmisc.richtext.model.PlainTextChange;
16
import org.fxmisc.richtext.model.StyleSpansBuilder;
17
18
import java.util.Collection;
19
import java.util.List;
20
import java.util.concurrent.atomic.AtomicInteger;
21
22
import static com.keenwrite.events.StatusEvent.clue;
23
import static java.util.Collections.emptyList;
24
import static java.util.Collections.singleton;
25
import static org.fxmisc.richtext.model.TwoDimensional.Bias.Forward;
26
27
/**
28
 * Responsible for checking the spelling of a document being edited.
29
 */
30
public final class TextEditorSpellChecker {
31
  private final ObjectProperty<SpellChecker> mSpellChecker;
32
  private final Parser mParser = Parser.builder().build();
33
34
  /**
35
   * Create a new spellchecker that can highlight spelling mistakes within a
36
   * {@link StyleClassedTextArea}. The given {@link SpellChecker} is wrapped
37
   * in a mutable {@link ObjectProperty} because the user may swap languages
38
   * at runtime.
39
   *
40
   * @param checker The spellchecker to use when scanning for spelling errors.
41
   */
42
  public TextEditorSpellChecker( final ObjectProperty<SpellChecker> checker ) {
43
    assert checker != null;
44
45
    mSpellChecker = checker;
46
  }
47
48
  /**
49
   * Call to spellcheck the entire document.
50
   */
51
  public void checkDocument( final TextEditor editor ) {
52
    spellcheck( editor.getTextArea(), editor.getText(), -1 );
53
  }
54
55
  /**
56
   * Listen for changes to any particular paragraph and perform a quick
57
   * spell check upon it. The style classes in the editor will be changed to
58
   * mark any spelling mistakes in the paragraph. The user may then interact
59
   * with any misspelled word (i.e., any piece of text that is marked) to
60
   * revise the spelling.
61
   * <p>
62
   * Use {@link PlainTextChange} so that notifications of style changes
63
   * are suppressed. Checking against the identity ensures that only
64
   * new text additions or deletions trigger proofreading.
65
   */
66
  public void checkParagraph(
67
    final StyleClassedTextArea editor,
68
    final PlainTextChange change ) {
69
    // Check current paragraph; the document was checked when opened.
70
    final var offset = change.getPosition();
71
    final var position = editor.offsetToPosition( offset, Forward );
72
    final var paraId = position.getMajor();
73
    final var paragraph = editor.getParagraph( paraId );
74
    final var text = paragraph.getText();
75
76
    // Prevent doubling-up styles.
77
    editor.clearStyle( paraId );
78
79
    spellcheck( editor, text, paraId );
80
  }
81
82
  /**
83
   * Spellchecks a subset of the entire document.
84
   *
85
   * @param editor The document (or portions thereof) to spellcheck.
86
   * @param text   Look up words for this text in the lexicon.
87
   * @param paraId Set to -1 to apply resulting style spans to the entire
88
   *               text.
89
   */
90
  private void spellcheck(
91
    final StyleClassedTextArea editor, final String text, final int paraId ) {
92
    final var builder = new StyleSpansBuilder<Collection<String>>();
93
    final var runningIndex = new AtomicInteger( 0 );
94
95
    // The text nodes must be relayed through a contextual "visitor" that
96
    // can return text in chunks with correlative offsets into the string.
97
    // This allows Markdown and R Markdown documents to return sets of
98
    // words to check.
99
    final var node = mParser.parse( text );
100
    final var visitor = new TextVisitor( ( visited, bIndex, eIndex ) -> {
101
      // Treat hyphenated compound words as individual words.
102
      final var check = visited.replace( '-', ' ' );
103
      final var checker = getSpellChecker();
104
105
      checker.proofread( check, ( misspelled, prevIndex, currIndex ) -> {
106
        prevIndex += bIndex;
107
        currIndex += bIndex;
108
109
        // Clear styling between lexiconically absent words.
110
        builder.add( emptyList(), prevIndex - runningIndex.get() );
111
        builder.add( singleton( "spelling" ), currIndex - prevIndex );
112
        runningIndex.set( currIndex );
113
      } );
114
    } );
115
116
    visitor.visit( node );
117
118
    // If the running index was set, at least one word triggered the listener.
119
    if( runningIndex.get() > 0 ) {
120
      // Clear styling after the last lexiconically absent word.
121
      builder.add( emptyList(), text.length() - runningIndex.get() );
122
123
      final var spans = builder.create();
124
125
      if( paraId >= 0 ) {
126
        editor.setStyleSpans( paraId, 0, spans );
127
      }
128
      else {
129
        editor.setStyleSpans( 0, spans );
130
      }
131
    }
132
  }
133
134
  /**
135
   * Called to display a pop-up with a list of spelling corrections. When the
136
   * user selects an item from the list, the word at the caret position is
137
   * replaced (with the selected item).
138
   */
139
  public void autofix( final TextEditor editor ) {
140
    final var caretWord = editor.getCaretWord();
141
    final var textArea = editor.getTextArea();
142
    final var word = textArea.getText( caretWord );
143
    final var suggestions = checkWord( word, 10 );
144
145
    if( suggestions.isEmpty() ) {
146
      clue( "Editor.spelling.check.matches.none", word );
147
    }
148
    else if( !suggestions.contains( word ) ) {
149
      final var menu = createSuggestionsPopup( textArea );
150
      final var items = menu.getItems();
151
      textArea.setContextMenu( menu );
152
153
      for( final var correction : suggestions ) {
154
        items.add( createSuggestedItem( textArea, caretWord, correction ) );
155
      }
156
157
      textArea.getCaretBounds().ifPresent(
158
        bounds -> {
159
          menu.setOnShown( event -> menu.requestFocus() );
160
          menu.show( textArea, bounds.getCenterX(), bounds.getCenterY() );
161
        }
162
      );
163
    }
164
    else {
165
      clue( "Editor.spelling.check.matches.okay", word );
166
    }
167
  }
168
169
  private ContextMenu createSuggestionsPopup(
170
    final StyleClassedTextArea textArea ) {
171
    final var menu = new ContextMenu();
172
173
    menu.setAutoHide( true );
174
    menu.setHideOnEscape( true );
175
    menu.setOnHidden( event -> textArea.setContextMenu( null ) );
176
177
    return menu;
178
  }
179
180
  /**
181
   * Creates a menu item capable of replacing a word under the cursor.
182
   *
183
   * @param textArea The text upon which this action will replace.
184
   * @param i        The beginning and ending text offset to replace.
185
   * @param s        The text to replace at the given offset.
186
   * @return The menu item that, if actioned, will replace the text.
187
   */
188
  private MenuItem createSuggestedItem(
189
    final StyleClassedTextArea textArea,
190
    final IndexRange i,
191
    final String s ) {
192
    final var menuItem = new MenuItem( s );
193
194
    menuItem.setOnAction( event -> textArea.replaceText( i, s ) );
195
196
    return menuItem;
197
  }
198
199
  /**
200
   * Returns a list of suggests for the given word. This is typically used to
201
   * check for suitable replacements of the word at the caret position.
202
   *
203
   * @param word  The word to spellcheck.
204
   * @param count The maximum number of suggested alternatives to return.
205
   * @return A list of recommended spellings for the given word.
206
   */
207
  public List<String> checkWord( final String word, final int count ) {
208
    return getSpellChecker().suggestions( word, count );
209
  }
210
211
  private SpellChecker getSpellChecker() {
212
    return mSpellChecker.get();
213
  }
214
215
  private static final class TextVisitor {
216
    private final NodeVisitor mVisitor = new NodeVisitor( new VisitHandler<>(
217
      com.vladsch.flexmark.ast.Text.class, this::visit )
218
    );
219
220
    private final SpellCheckListener mConsumer;
221
222
    public TextVisitor( final SpellCheckListener consumer ) {
223
      mConsumer = consumer;
224
    }
225
226
    private void visit( final com.vladsch.flexmark.util.ast.Node node ) {
227
      if( node instanceof com.vladsch.flexmark.ast.Text ) {
228
        mConsumer.accept( node.getChars().toString(),
229
                          node.getStartOffset(),
230
                          node.getEndOffset() );
231
      }
232
233
      mVisitor.visitChildren( node );
234
    }
235
  }
236
}
1237
A src/main/resources/com/keenwrite/editor/markdown_de-Latn-AT.css
11
A src/main/resources/com/keenwrite/editor/markdown_de-Latn-CH.css
11
A src/main/resources/com/keenwrite/editor/markdown_de-Latn-DE.css
11
A src/main/resources/com/keenwrite/editor/markdown_de-Latn-LU.css
11
A src/main/resources/com/keenwrite/editor/markdown_es-Latn-AR.css
11
A src/main/resources/com/keenwrite/editor/markdown_es-Latn-BO.css
11
A src/main/resources/com/keenwrite/editor/markdown_es-Latn-CL.css
11
A src/main/resources/com/keenwrite/editor/markdown_es-Latn-CO.css
11
A src/main/resources/com/keenwrite/editor/markdown_es-Latn-CR.css
11
A src/main/resources/com/keenwrite/editor/markdown_es-Latn-DO.css
11
A src/main/resources/com/keenwrite/editor/markdown_es-Latn-EC.css
11
A src/main/resources/com/keenwrite/editor/markdown_es-Latn-ES.css
11
A src/main/resources/com/keenwrite/editor/markdown_es-Latn-GT.css
11
A src/main/resources/com/keenwrite/editor/markdown_es-Latn-HN.css
11
A src/main/resources/com/keenwrite/editor/markdown_es-Latn-MX.css
11
A src/main/resources/com/keenwrite/editor/markdown_es-Latn-NI.css
11
A src/main/resources/com/keenwrite/editor/markdown_es-Latn-PA.css
11
A src/main/resources/com/keenwrite/editor/markdown_es-Latn-PE.css
11
A src/main/resources/com/keenwrite/editor/markdown_es-Latn-PR.css
11
A src/main/resources/com/keenwrite/editor/markdown_es-Latn-PY.css
11
A src/main/resources/com/keenwrite/editor/markdown_es-Latn-SV.css
11
A src/main/resources/com/keenwrite/editor/markdown_es-Latn-US.css
11
A src/main/resources/com/keenwrite/editor/markdown_es-Latn-UY.css
11
A src/main/resources/com/keenwrite/editor/markdown_es-Latn-VE.css
11
A src/main/resources/com/keenwrite/editor/markdown_fr-Latn-BE.css
11
A src/main/resources/com/keenwrite/editor/markdown_fr-Latn-CA.css
11
A src/main/resources/com/keenwrite/editor/markdown_fr-Latn-CH.css
11
A src/main/resources/com/keenwrite/editor/markdown_fr-Latn-FR.css
11
A src/main/resources/com/keenwrite/editor/markdown_fr-Latn-LU.css
11
A src/main/resources/com/keenwrite/editor/markdown_it-Latn-CH.css
11
A src/main/resources/com/keenwrite/editor/markdown_it-Latn-IT.css
11
A src/main/resources/com/keenwrite/editor/markdown_iw-Hebr-IL.css
11
M src/main/resources/com/keenwrite/messages.properties
191191
Main.status.typeset.ended.failure=Failed to typeset ''{0}'' ({1} elapsed)
192192
193
Main.status.lexicon.loading=Loading lexicon: {0} words
194
Main.status.lexicon.loaded=Loaded lexicon: {0} words
195
193196
# ########################################################################
194197
# Search Bar
M src/main/resources/lexicons/README.md
1
# Building
1
# Lexicons
22
3
The lexicon files are retrieved from:
3
This directory contains lexicons used for spell checking. Each lexicon
4
file contains tab-delimited word-frequency pairs.
45
5
https://github.com/wolfgarbe/SymSpell/tree/master/SymSpell
6
Compiling a high-quality list of correctly spelled words requires the
7
following steps:
68
7
The lexicons and bigrams are space-separated by default, but parsing a
8
tab-delimited file is easier, so change them to tab-separated files.
9
1. Download a unigram frequency list for all words for a given language.
10
1. Download a high-quality source list of correctly spelled words.
11
1. Filter the unigram frequency list using all words in the source list.
12
1. Sort the filtered list by the frequency in descending order.
13
14
The latter steps can be accomplished as follows:
15
16
    # Extract unigram and frequency based on existence in source lexicon.
17
    for i in $(cat source-lexicon.txt); do
18
      grep -m 1 "^$i"$'\t' unigram-frequencies.txt;
19
    done > filtered.txt
20
21
    # Sort numerically (-n) using column two (-k2) in reverse order (-r).
22
    sort -n -k2 -r filtered.txt > en.txt
23
24
There may be more efficient ways to filter the data, which takes a few hours
25
to complete (on modern hardware).
26
27
# Lexicons
28
29
There are numerous sources of word and frequency lists available, including:
30
31
* https://storage.googleapis.com/books/ngrams/books/datasetsv3.html
32
* https://github.com/hermitdave/FrequencyWords/
33
* https://github.com/neilk/wordfrequencies
934
1035
M src/main/resources/lexicons/en.txt
Binary file