Dave Jarvis' Repositories

M README.md
99
Download one of the following editions:
1010
11
* [Windows](https://gitreleases.dev/gh/DaveJarvis/keenwrite/latest/keenwrite.exe)
12
* [Linux](https://gitreleases.dev/gh/DaveJarvis/keenwrite/latest/keenwrite.bin)
13
* [Java Archive](https://gitreleases.dev/gh/DaveJarvis/keenwrite/latest/keenwrite.jar)
11
* [Windows](https://github.com/DaveJarvis/keenwrite/releases/latest/download/keenwrite.exe)
12
* [Linux](https://github.com/DaveJarvis/keenwrite/releases/latest/download/keenwrite.bin)
13
* [Java Archive](https://github.com/DaveJarvis/keenwrite/releases/latest/download/keenwrite.jar)
1414
1515
## Run
M README.zh-CN.md
77
下载以下版本之一:
88
9
* [Windows](https://gitreleases.dev/gh/DaveJarvis/keenwrite/latest/keenwrite.exe)
10
* [Linux](https://gitreleases.dev/gh/DaveJarvis/keenwrite/latest/keenwrite.bin)
11
* [Java Archive](https://gitreleases.dev/gh/DaveJarvis/keenwrite/latest/keenwrite.jar)
9
* [Windows](https://github.com/DaveJarvis/keenwrite/releases/latest/download/keenwrite.exe)
10
* [Linux](https://github.com/DaveJarvis/keenwrite/releases/latest/download/keenwrite.bin)
11
* [Java Archive](https://github.com/DaveJarvis/keenwrite/releases/latest/download/keenwrite.jar)
1212
1313
## 跑
...
3434
### Other
3535
36
Download and install a full version of [OpenJDK 15](https://bell-sw.com/pages/downloads/?version=java-15#mn) that includes JavaFX module support, then run:
36
Download and install a full version of [OpenJDK 17](https://bell-sw.com/pages/downloads/#/java-17-lts) that includes JavaFX module support, then run:
3737
3838
``` bash
M build.gradle
22
  id 'application'
33
  id 'org.openjfx.javafxplugin' version '0.0.10'
4
  id 'com.palantir.git-version' version '0.12.3'
4
  id 'com.palantir.git-version' version '0.14.0'
55
}
66
...
5252
  def v_junit = '5.8.2'
5353
  def v_flexmark = '0.64.0'
54
  def v_jackson = '2.13.2'
54
  def v_jackson = '2.13.3'
5555
  def v_batik = '1.14'
5656
  def v_wheatsheaf = '2.0.1'
M libs/keenquotes.jar
Binary file
M src/main/java/com/keenwrite/ExportFormat.java
3434
3535
  /**
36
   * Indicates that the processors should export to a Markdown format.
37
   * Treat image links relatively.
38
   */
39
  MARKDOWN_PLAIN( ".out.md" ),
40
41
  /**
4236
   * Exports as PDF file format.
4337
   */
...
10094
        ? HTML_TEX_SVG
10195
        : HTML_TEX_DELIMITED;
102
      case TEXT_MARKDOWN -> MARKDOWN_PLAIN;
10396
      case APP_PDF -> APPLICATION_PDF;
10497
      default -> throw new IllegalArgumentException( format(
...
132125
  public File toExportFilename( final Path path ) {
133126
    return toExportFilename( path.toFile() );
134
  }
135
136
  public Path toExportPath( final Path path ) {
137
    return toExportFilename( path ).toPath();
138127
  }
139128
}
M src/main/java/com/keenwrite/MainPane.java
2020
import com.keenwrite.processors.ProcessorContext;
2121
import com.keenwrite.processors.ProcessorFactory;
22
import com.keenwrite.processors.r.InlineRProcessor;
23
import com.keenwrite.service.events.Notifier;
24
import com.keenwrite.sigils.PropertyKeyOperator;
25
import com.keenwrite.sigils.RKeyOperator;
26
import com.keenwrite.ui.explorer.FilePickerFactory;
27
import com.keenwrite.ui.heuristics.DocumentStatistics;
28
import com.keenwrite.ui.outline.DocumentOutline;
29
import com.keenwrite.util.GenericBuilder;
30
import com.panemu.tiwulfx.control.dock.DetachableTab;
31
import com.panemu.tiwulfx.control.dock.DetachableTabPane;
32
import javafx.application.Platform;
33
import javafx.beans.property.*;
34
import javafx.collections.ListChangeListener;
35
import javafx.concurrent.Task;
36
import javafx.event.ActionEvent;
37
import javafx.event.Event;
38
import javafx.event.EventHandler;
39
import javafx.scene.Node;
40
import javafx.scene.Scene;
41
import javafx.scene.control.*;
42
import javafx.scene.control.TreeItem.TreeModificationEvent;
43
import javafx.scene.input.KeyEvent;
44
import javafx.scene.layout.FlowPane;
45
import javafx.stage.Stage;
46
import javafx.stage.Window;
47
import org.greenrobot.eventbus.Subscribe;
48
49
import java.io.File;
50
import java.io.FileNotFoundException;
51
import java.nio.file.Path;
52
import java.util.*;
53
import java.util.concurrent.ExecutorService;
54
import java.util.concurrent.ScheduledExecutorService;
55
import java.util.concurrent.ScheduledFuture;
56
import java.util.concurrent.atomic.AtomicBoolean;
57
import java.util.concurrent.atomic.AtomicReference;
58
import java.util.function.Function;
59
import java.util.function.UnaryOperator;
60
import java.util.stream.Collectors;
61
62
import static com.keenwrite.ExportFormat.NONE;
63
import static com.keenwrite.Launcher.terminate;
64
import static com.keenwrite.Messages.get;
65
import static com.keenwrite.constants.Constants.*;
66
import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG_NODE;
67
import static com.keenwrite.events.Bus.register;
68
import static com.keenwrite.events.StatusEvent.clue;
69
import static com.keenwrite.io.MediaType.*;
70
import static com.keenwrite.preferences.AppKeys.*;
71
import static com.keenwrite.processors.IdentityProcessor.IDENTITY;
72
import static com.keenwrite.processors.ProcessorContext.Mutator;
73
import static com.keenwrite.processors.ProcessorContext.builder;
74
import static com.keenwrite.processors.ProcessorFactory.createProcessors;
75
import static java.lang.String.format;
76
import static java.lang.System.getProperty;
77
import static java.util.concurrent.Executors.newFixedThreadPool;
78
import static java.util.concurrent.Executors.newScheduledThreadPool;
79
import static java.util.concurrent.TimeUnit.SECONDS;
80
import static java.util.stream.Collectors.groupingBy;
81
import static javafx.application.Platform.runLater;
82
import static javafx.scene.control.Alert.AlertType.ERROR;
83
import static javafx.scene.control.ButtonType.*;
84
import static javafx.scene.control.TabPane.TabClosingPolicy.ALL_TABS;
85
import static javafx.scene.input.KeyCode.SPACE;
86
import static javafx.scene.input.KeyCombination.CONTROL_DOWN;
87
import static javafx.util.Duration.millis;
88
import static javax.swing.SwingUtilities.invokeLater;
89
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
90
91
/**
92
 * Responsible for wiring together the main application components for a
93
 * particular {@link Workspace} (project). These include the definition views,
94
 * text editors, and preview pane along with any corresponding controllers.
95
 */
96
public final class MainPane extends SplitPane {
97
98
  private static final ExecutorService sExecutor = newFixedThreadPool( 1 );
99
  private static final Notifier sNotifier = Services.load( Notifier.class );
100
101
  /**
102
   * Used when opening files to determine how each file should be binned and
103
   * therefore what tab pane to be opened within.
104
   */
105
  private static final Set<MediaType> PLAIN_TEXT_FORMAT = Set.of(
106
    TEXT_MARKDOWN, TEXT_R_MARKDOWN, UNDEFINED
107
  );
108
109
  private final ScheduledExecutorService mSaver = newScheduledThreadPool( 1 );
110
  private final AtomicReference<ScheduledFuture<?>> mSaveTask =
111
    new AtomicReference<>();
112
113
  /**
114
   * Prevents re-instantiation of processing classes.
115
   */
116
  private final Map<TextResource, Processor<String>> mProcessors =
117
    new HashMap<>();
118
119
  private final Workspace mWorkspace;
120
121
  /**
122
   * Groups similar file type tabs together.
123
   */
124
  private final List<TabPane> mTabPanes = new ArrayList<>();
125
126
  /**
127
   * Renders the actively selected plain text editor tab.
128
   */
129
  private final HtmlPreview mPreview;
130
131
  /**
132
   * Provides an interactive document outline.
133
   */
134
  private final DocumentOutline mOutline = new DocumentOutline();
135
136
  /**
137
   * Changing the active editor fires the value changed event. This allows
138
   * refreshes to happen when external definitions are modified and need to
139
   * trigger the processing chain.
140
   */
141
  private final ObjectProperty<TextEditor> mTextEditor =
142
    createActiveTextEditor();
143
144
  /**
145
   * Changing the active definition editor fires the value changed event. This
146
   * allows refreshes to happen when external definitions are modified and need
147
   * to trigger the processing chain.
148
   */
149
  private final ObjectProperty<TextDefinition> mDefinitionEditor;
150
151
  /**
152
   * Called when the definition data is changed.
153
   */
154
  private final EventHandler<TreeModificationEvent<Event>> mTreeHandler =
155
    event -> {
156
      process( getTextEditor() );
157
      save( getTextDefinition() );
158
    };
159
160
  /**
161
   * Tracks the number of detached tab panels opened into their own windows,
162
   * which allows unique identification of subordinate windows by their title.
163
   * It is doubtful more than 128 windows, much less 256, will be created.
164
   */
165
  private byte mWindowCount;
166
167
  private final DocumentStatistics mStatistics;
168
169
  /**
170
   * Adds all content panels to the main user interface. This will load the
171
   * configuration settings from the workspace to reproduce the settings from
172
   * a previous session.
173
   */
174
  public MainPane( final Workspace workspace ) {
175
    mWorkspace = workspace;
176
    mPreview = new HtmlPreview( workspace );
177
    mStatistics = new DocumentStatistics( workspace );
178
    mTextEditor.set( new MarkdownEditor( workspace ) );
179
    mDefinitionEditor = createActiveDefinitionEditor( mTextEditor );
180
181
    open( collect( getRecentFiles() ) );
182
    viewPreview();
183
    setDividerPositions( calculateDividerPositions() );
184
185
    // Once the main scene's window regains focus, update the active definition
186
    // editor to the currently selected tab.
187
    runLater( () -> getWindow().setOnCloseRequest( event -> {
188
      // Order matters: Open file names must be persisted before closing all.
189
      mWorkspace.save();
190
191
      if( closeAll() ) {
192
        Platform.exit();
193
        terminate( 0 );
194
      }
195
196
      event.consume();
197
    } ) );
198
199
    register( this );
200
    initAutosave( workspace );
201
  }
202
203
  @Subscribe
204
  public void handle( final TextEditorFocusEvent event ) {
205
    mTextEditor.set( event.get() );
206
  }
207
208
  @Subscribe
209
  public void handle( final TextDefinitionFocusEvent event ) {
210
    mDefinitionEditor.set( event.get() );
211
  }
212
213
  /**
214
   * Typically called when a file name is clicked in the preview panel.
215
   *
216
   * @param event The event to process, must contain a valid file reference.
217
   */
218
  @Subscribe
219
  public void handle( final FileOpenEvent event ) {
220
    final File eventFile;
221
    final var eventUri = event.getUri();
222
223
    if( eventUri.isAbsolute() ) {
224
      eventFile = new File( eventUri.getPath() );
225
    }
226
    else {
227
      final var activeFile = getTextEditor().getFile();
228
      final var parent = activeFile.getParentFile();
229
230
      if( parent == null ) {
231
        clue( new FileNotFoundException( eventUri.getPath() ) );
232
        return;
233
      }
234
      else {
235
        final var parentPath = parent.getAbsolutePath();
236
        eventFile = Path.of( parentPath, eventUri.getPath() ).toFile();
237
      }
238
    }
239
240
    runLater( () -> open( eventFile ) );
241
  }
242
243
  @Subscribe
244
  public void handle( final CaretNavigationEvent event ) {
245
    runLater( () -> {
246
      final var textArea = getTextEditor().getTextArea();
247
      textArea.moveTo( event.getOffset() );
248
      textArea.requestFollowCaret();
249
      textArea.requestFocus();
250
    } );
251
  }
252
253
  @Subscribe
254
  @SuppressWarnings( "unused" )
255
  public void handle( final ExportFailedEvent event ) {
256
    final var os = getProperty( "os.name" );
257
    final var arch = getProperty( "os.arch" ).toLowerCase();
258
    final var bits = getProperty( "sun.arch.data.model" );
259
260
    final var title = Messages.get( "Alert.typesetter.missing.title" );
261
    final var header = Messages.get( "Alert.typesetter.missing.header" );
262
    final var version = Messages.get(
263
      "Alert.typesetter.missing.version",
264
      os,
265
      arch
266
        .replaceAll( "amd.*|i.*|x86.*", "X86" )
267
        .replaceAll( "mips.*", "MIPS" )
268
        .replaceAll( "armv.*", "ARM" ),
269
      bits );
270
    final var text = Messages.get( "Alert.typesetter.missing.installer.text" );
271
272
    // Download and install ConTeXt for {0} {1} {2}-bit
273
    final var content = format( "%s %s", text, version );
274
    final var flowPane = new FlowPane();
275
    final var link = new Hyperlink( text );
276
    final var label = new Label( version );
277
    flowPane.getChildren().addAll( link, label );
278
279
    final var alert = new Alert( ERROR, content, OK );
280
    alert.setTitle( title );
281
    alert.setHeaderText( header );
282
    alert.getDialogPane().contentProperty().set( flowPane );
283
    alert.setGraphic( ICON_DIALOG_NODE );
284
285
    link.setOnAction( ( e ) -> {
286
      alert.close();
287
      final var url = Messages.get( "Alert.typesetter.missing.installer.url" );
288
      runLater( () -> HyperlinkOpenEvent.fire( url ) );
289
    } );
290
291
    alert.showAndWait();
292
  }
293
294
  private void initAutosave( final Workspace workspace ) {
295
    final var rate = workspace.integerProperty( KEY_EDITOR_AUTOSAVE );
296
297
    rate.addListener(
298
      ( c, o, n ) -> {
299
        final var taskRef = mSaveTask.get();
300
301
        // Prevent multiple autosaves from running.
302
        if( taskRef != null ) {
303
          taskRef.cancel( false );
304
        }
305
306
        initAutosave( rate );
307
      }
308
    );
309
310
    // Start the save listener (avoids duplicating some code).
311
    initAutosave( rate );
312
  }
313
314
  private void initAutosave( final IntegerProperty rate ) {
315
    mSaveTask.set(
316
      mSaver.scheduleAtFixedRate(
317
        () -> {
318
          if( getTextEditor().isModified() ) {
319
            // Ensure the modified indicator is cleared by running on EDT.
320
            runLater( this::save );
321
          }
322
        }, 0, rate.intValue(), SECONDS
323
      )
324
    );
325
  }
326
327
  /**
328
   * TODO: Load divider positions from exported settings, see
329
   *   {@link #collect(SetProperty)} comment.
330
   */
331
  private double[] calculateDividerPositions() {
332
    final var ratio = 100f / getItems().size() / 100;
333
    final var positions = getDividerPositions();
334
335
    for( int i = 0; i < positions.length; i++ ) {
336
      positions[ i ] = ratio * i;
337
    }
338
339
    return positions;
340
  }
341
342
  /**
343
   * Opens all the files into the application, provided the paths are unique.
344
   * This may only be called for any type of files that a user can edit
345
   * (i.e., update and persist), such as definitions and text files.
346
   *
347
   * @param files The list of files to open.
348
   */
349
  public void open( final List<File> files ) {
350
    files.forEach( this::open );
351
  }
352
353
  /**
354
   * This opens the given file. Since the preview pane is not a file that
355
   * can be opened, it is safe to add a listener to the detachable pane.
356
   * This will exit early if the given file is not a regular file (i.e., a
357
   * directory).
358
   *
359
   * @param inputFile The file to open.
360
   */
361
  private void open( final File inputFile ) {
362
    // Prevent opening directories (a non-existent "untitled.md" is fine).
363
    if( !inputFile.isFile() && inputFile.exists() ) {
364
      return;
365
    }
366
367
    final var tab = createTab( inputFile );
368
    final var node = tab.getContent();
369
    final var mediaType = MediaType.valueFrom( inputFile );
370
    final var tabPane = obtainTabPane( mediaType );
371
372
    tab.setTooltip( createTooltip( inputFile ) );
373
    tabPane.setFocusTraversable( false );
374
    tabPane.setTabClosingPolicy( ALL_TABS );
375
    tabPane.getTabs().add( tab );
376
377
    // Attach the tab scene factory for new tab panes.
378
    if( !getItems().contains( tabPane ) ) {
379
      addTabPane(
380
        node instanceof TextDefinition ? 0 : getItems().size(), tabPane
381
      );
382
    }
383
384
    if( inputFile.isFile() ) {
385
      getRecentFiles().add( inputFile.getAbsolutePath() );
386
    }
387
  }
388
389
  /**
390
   * Opens a new text editor document using the default document file name.
391
   */
392
  public void newTextEditor() {
393
    open( DOCUMENT_DEFAULT );
394
  }
395
396
  /**
397
   * Opens a new definition editor document using the default definition
398
   * file name.
399
   */
400
  public void newDefinitionEditor() {
401
    open( DEFINITION_DEFAULT );
402
  }
403
404
  /**
405
   * Iterates over all tab panes to find all {@link TextEditor}s and request
406
   * that they save themselves.
407
   */
408
  public void saveAll() {
409
    mTabPanes.forEach(
410
      tp -> tp.getTabs().forEach( tab -> {
411
        final var node = tab.getContent();
412
413
        if( node instanceof final TextEditor editor ) {
414
          save( editor );
415
        }
416
      } )
417
    );
418
  }
419
420
  /**
421
   * Requests that the active {@link TextEditor} saves itself. Don't bother
422
   * checking if modified first because if the user swaps external media from
423
   * an external source (e.g., USB thumb drive), save should not second-guess
424
   * the user: save always re-saves. Also, it's less code.
425
   */
426
  public void save() {
427
    save( getTextEditor() );
428
  }
429
430
  /**
431
   * Saves the active {@link TextEditor} under a new name.
432
   *
433
   * @param files The new active editor {@link File} reference, must contain
434
   *              at least one element.
435
   */
436
  public void saveAs( final List<File> files ) {
437
    assert files != null;
438
    assert !files.isEmpty();
439
    final var editor = getTextEditor();
440
    final var tab = getTab( editor );
441
    final var file = files.get( 0 );
442
443
    editor.rename( file );
444
    tab.ifPresent( t -> {
445
      t.setText( editor.getFilename() );
446
      t.setTooltip( createTooltip( file ) );
447
    } );
448
449
    save();
450
  }
451
452
  /**
453
   * Saves the given {@link TextResource} to a file. This is typically used
454
   * to save either an instance of {@link TextEditor} or {@link TextDefinition}.
455
   *
456
   * @param resource The resource to export.
457
   */
458
  private void save( final TextResource resource ) {
459
    try {
460
      resource.save();
461
    } catch( final Exception ex ) {
462
      clue( ex );
463
      sNotifier.alert(
464
        getWindow(), resource.getPath(), "TextResource.saveFailed", ex
465
      );
466
    }
467
  }
468
469
  /**
470
   * Closes all open {@link TextEditor}s; all {@link TextDefinition}s stay open.
471
   *
472
   * @return {@code true} when all editors, modified or otherwise, were
473
   * permitted to close; {@code false} when one or more editors were modified
474
   * and the user requested no closing.
475
   */
476
  public boolean closeAll() {
477
    var closable = true;
478
479
    for( final var tabPane : mTabPanes ) {
480
      final var tabIterator = tabPane.getTabs().iterator();
481
482
      while( tabIterator.hasNext() ) {
483
        final var tab = tabIterator.next();
484
        final var resource = tab.getContent();
485
486
        // The definition panes auto-save, so being specific here prevents
487
        // closing the definitions in the situation where the user wants to
488
        // continue editing (i.e., possibly save unsaved work).
489
        if( !(resource instanceof TextEditor) ) {
490
          continue;
491
        }
492
493
        if( canClose( (TextEditor) resource ) ) {
494
          tabIterator.remove();
495
          close( tab );
496
        }
497
        else {
498
          closable = false;
499
        }
500
      }
501
    }
502
503
    return closable;
504
  }
505
506
  /**
507
   * Calls the tab's {@link Tab#getOnClosed()} handler to carry out a close
508
   * event.
509
   *
510
   * @param tab The {@link Tab} that was closed.
511
   */
512
  private void close( final Tab tab ) {
513
    assert tab != null;
514
515
    final var handler = tab.getOnClosed();
516
517
    if( handler != null ) {
518
      handler.handle( new ActionEvent() );
519
    }
520
  }
521
522
  /**
523
   * Closes the active tab; delegates to {@link #canClose(TextResource)}.
524
   */
525
  public void close() {
526
    final var editor = getTextEditor();
527
528
    if( canClose( editor ) ) {
529
      close( editor );
530
    }
531
  }
532
533
  /**
534
   * Closes the given {@link TextResource}. This must not be called from within
535
   * a loop that iterates over the tab panes using {@code forEach}, lest a
536
   * concurrent modification exception be thrown.
537
   *
538
   * @param resource The {@link TextResource} to close, without confirming with
539
   *                 the user.
540
   */
541
  private void close( final TextResource resource ) {
542
    getTab( resource ).ifPresent(
543
      ( tab ) -> {
544
        close( tab );
545
        tab.getTabPane().getTabs().remove( tab );
546
      }
547
    );
548
  }
549
550
  /**
551
   * Answers whether the given {@link TextResource} may be closed.
552
   *
553
   * @param editor The {@link TextResource} to try closing.
554
   * @return {@code true} when the editor may be closed; {@code false} when
555
   * the user has requested to keep the editor open.
556
   */
557
  private boolean canClose( final TextResource editor ) {
558
    final var editorTab = getTab( editor );
559
    final var canClose = new AtomicBoolean( true );
560
561
    if( editor.isModified() ) {
562
      final var filename = new StringBuilder();
563
      editorTab.ifPresent( ( tab ) -> filename.append( tab.getText() ) );
564
565
      final var message = sNotifier.createNotification(
566
        Messages.get( "Alert.file.close.title" ),
567
        Messages.get( "Alert.file.close.text" ),
568
        filename.toString()
569
      );
570
571
      final var dialog = sNotifier.createConfirmation( getWindow(), message );
572
573
      dialog.showAndWait().ifPresent(
574
        save -> canClose.set( save == YES ? editor.save() : save == NO )
575
      );
576
    }
577
578
    return canClose.get();
579
  }
580
581
  private ObjectProperty<TextEditor> createActiveTextEditor() {
582
    final var editor = new SimpleObjectProperty<TextEditor>();
583
584
    editor.addListener( ( c, o, n ) -> {
585
      if( n != null ) {
586
        mPreview.setBaseUri( n.getPath() );
587
        process( n );
588
      }
589
    } );
590
591
    return editor;
592
  }
593
594
  /**
595
   * Adds the HTML preview tab to its own, singular tab pane.
596
   */
597
  public void viewPreview() {
598
    viewTab( mPreview, TEXT_HTML, "Pane.preview.title" );
599
  }
600
601
  /**
602
   * Adds the document outline tab to its own, singular tab pane.
603
   */
604
  public void viewOutline() {
605
    viewTab( mOutline, APP_DOCUMENT_OUTLINE, "Pane.outline.title" );
606
  }
607
608
  public void viewStatistics() {
609
    viewTab( mStatistics, APP_DOCUMENT_STATISTICS, "Pane.statistics.title" );
610
  }
611
612
  public void viewFiles() {
613
    try {
614
      final var factory = new FilePickerFactory( getWorkspace() );
615
      final var fileManager = factory.createModeless();
616
      viewTab( fileManager, APP_FILE_MANAGER, "Pane.files.title" );
617
    } catch( final Exception ex ) {
618
      clue( ex );
619
    }
620
  }
621
622
  private void viewTab(
623
    final Node node, final MediaType mediaType, final String key ) {
624
    final var tabPane = obtainTabPane( mediaType );
625
626
    for( final var tab : tabPane.getTabs() ) {
627
      if( tab.getContent() == node ) {
628
        return;
629
      }
630
    }
631
632
    tabPane.getTabs().add( createTab( get( key ), node ) );
633
    addTabPane( tabPane );
634
  }
635
636
  public void viewRefresh() {
637
    mPreview.refresh();
638
  }
639
640
  /**
641
   * Returns the tab that contains the given {@link TextEditor}.
642
   *
643
   * @param editor The {@link TextEditor} instance to find amongst the tabs.
644
   * @return The first tab having content that matches the given tab.
645
   */
646
  private Optional<Tab> getTab( final TextResource editor ) {
647
    return mTabPanes.stream()
648
                    .flatMap( pane -> pane.getTabs().stream() )
649
                    .filter( tab -> editor.equals( tab.getContent() ) )
650
                    .findFirst();
651
  }
652
653
  /**
654
   * Creates a new {@link DefinitionEditor} wrapped in a listener that
655
   * is used to detect when the active {@link DefinitionEditor} has changed.
656
   * Upon changing, the variables are interpolated and the active text editor
657
   * is refreshed.
658
   *
659
   * @param textEditor Text editor to update with the revised resolved map.
660
   * @return A newly configured property that represents the active
661
   * {@link DefinitionEditor}, never null.
662
   */
663
  private ObjectProperty<TextDefinition> createActiveDefinitionEditor(
664
    final ObjectProperty<TextEditor> textEditor ) {
665
    final var defEditor = new SimpleObjectProperty<>(
666
      createDefinitionEditor()
667
    );
668
669
    defEditor.addListener( ( c, o, n ) -> process( textEditor.get() ) );
670
671
    return defEditor;
672
  }
673
674
  private Tab createTab( final String filename, final Node node ) {
675
    return new DetachableTab( filename, node );
676
  }
677
678
  private Tab createTab( final File file ) {
679
    final var r = createTextResource( file );
680
    final var tab = createTab( r.getFilename(), r.getNode() );
681
682
    r.modifiedProperty().addListener(
683
      ( c, o, n ) -> tab.setText( r.getFilename() + (n ? "*" : "") )
684
    );
685
686
    // This is called when either the tab is closed by the user clicking on
687
    // the tab's close icon or when closing (all) from the file menu.
688
    tab.setOnClosed(
689
      ( __ ) -> getRecentFiles().remove( file.getAbsolutePath() )
690
    );
691
692
    // When closing a tab, give focus to the newly revealed tab.
693
    tab.selectedProperty().addListener( ( c, o, n ) -> {
694
      if( n != null && n ) {
695
        final var pane = tab.getTabPane();
696
697
        if( pane != null ) {
698
          pane.requestFocus();
699
        }
700
      }
701
    } );
702
703
    tab.tabPaneProperty().addListener( ( cPane, oPane, nPane ) -> {
704
      if( nPane != null ) {
705
        nPane.focusedProperty().addListener( ( c, o, n ) -> {
706
          if( n != null && n ) {
707
            final var selected = nPane.getSelectionModel().getSelectedItem();
708
            final var node = selected.getContent();
709
            node.requestFocus();
710
          }
711
        } );
712
      }
713
    } );
714
715
    return tab;
716
  }
717
718
  /**
719
   * Creates bins for the different {@link MediaType}s, which eventually are
720
   * added to the UI as separate tab panes. If ever a general-purpose scene
721
   * exporter is developed to serialize a scene to an FXML file, this could
722
   * be replaced by such a class.
723
   * <p>
724
   * When binning the files, this makes sure that at least one file exists
725
   * for every type. If the user has opted to close a particular type (such
726
   * as the definition pane), the view will suppressed elsewhere.
727
   * </p>
728
   * <p>
729
   * The order that the binned files are returned will be reflected in the
730
   * order that the corresponding panes are rendered in the UI.
731
   * </p>
732
   *
733
   * @param paths The file paths to bin according to their type.
734
   * @return An in-order list of files, first by structured definition files,
735
   * then by plain text documents.
736
   */
737
  private List<File> collect( final SetProperty<String> paths ) {
738
    // Treat all files destined for the text editor as plain text documents
739
    // so that they are added to the same pane. Grouping by TEXT_PLAIN is a
740
    // bit arbitrary, but means explicitly capturing TEXT_PLAIN isn't needed.
741
    final Function<MediaType, MediaType> bin =
742
      m -> PLAIN_TEXT_FORMAT.contains( m ) ? TEXT_PLAIN : m;
743
744
    // Create two groups: YAML files and plain text files. The order that
745
    // the elements are listed in the enumeration for media types determines
746
    // what files are loaded first. Variable definitions come before all other
747
    // plain text documents.
748
    final var bins = paths
749
      .stream()
750
      .collect(
751
        groupingBy(
752
          path -> bin.apply( MediaType.valueFrom( path ) ),
753
          () -> new TreeMap<>( Enum::compareTo ),
754
          Collectors.toList()
755
        )
756
      );
757
758
    bins.putIfAbsent( TEXT_YAML, List.of( DEFINITION_DEFAULT.toString() ) );
759
    bins.putIfAbsent( TEXT_PLAIN, List.of( DOCUMENT_DEFAULT.toString() ) );
760
761
    final var result = new LinkedList<File>();
762
763
    // Ensure that the same types are listed together (keep insertion order).
764
    bins.forEach( ( mediaType, files ) -> result.addAll(
765
      files.stream().map( File::new ).toList() )
766
    );
767
768
    return result;
769
  }
770
771
  /**
772
   * Force the active editor to update, which will cause the processor
773
   * to re-evaluate the interpolated definition map thereby updating the
774
   * preview pane.
775
   *
776
   * @param editor Contains the source document to update in the preview pane.
777
   */
778
  private void process( final TextEditor editor ) {
779
    // Ensure processing does not run on the JavaFX thread, which frees the
780
    // text editor immediately for caret movement. The preview will have a
781
    // slight delay when catching up to the caret position.
782
    final var task = new Task<Void>() {
783
      @Override
784
      public Void call() {
785
        try {
786
          final var p = mProcessors.getOrDefault( editor, IDENTITY );
787
          p.apply( editor == null ? "" : editor.getText() );
788
        } catch( final Exception ex ) {
789
          clue( ex );
790
        }
791
792
        return null;
793
      }
794
    };
795
796
    // TODO: Each time the editor successfully runs the processor the task is
797
    //   considered successful. Due to the rapid-fire nature of processing
798
    //   (e.g., keyboard navigation, fast typing), it isn't necessary to
799
    //   scroll each time.
800
    //   The algorithm:
801
    //   1. Peek at the oldest time.
802
    //   2. If the difference between the oldest time and current time exceeds
803
    //      250 milliseconds, then invoke the scrolling.
804
    //   3. Insert the current time into the circular queue.
805
    task.setOnSucceeded(
806
      e -> invokeLater( () -> mPreview.scrollTo( CARET_ID ) )
807
    );
808
809
    // Prevents multiple process requests from executing simultaneously (due
810
    // to having a restricted queue size).
811
    sExecutor.execute( task );
812
  }
813
814
  /**
815
   * Lazily creates a {@link TabPane} configured to listen for tab select
816
   * events. The tab pane is associated with a given media type so that
817
   * similar files can be grouped together.
818
   *
819
   * @param mediaType The media type to associate with the tab pane.
820
   * @return An instance of {@link TabPane} that will handle tab docking.
821
   */
822
  private TabPane obtainTabPane( final MediaType mediaType ) {
823
    for( final var pane : mTabPanes ) {
824
      for( final var tab : pane.getTabs() ) {
825
        final var node = tab.getContent();
826
827
        if( node instanceof TextResource r && r.supports( mediaType ) ) {
828
          return pane;
829
        }
830
      }
831
    }
832
833
    final var pane = createTabPane();
834
    mTabPanes.add( pane );
835
    return pane;
836
  }
837
838
  /**
839
   * Creates an initialized {@link TabPane} instance.
840
   *
841
   * @return A new {@link TabPane} with all listeners configured.
842
   */
843
  private TabPane createTabPane() {
844
    final var tabPane = new DetachableTabPane();
845
846
    initStageOwnerFactory( tabPane );
847
    initTabListener( tabPane );
848
849
    return tabPane;
850
  }
851
852
  /**
853
   * When any {@link DetachableTabPane} is detached from the main window,
854
   * the stage owner factory must be given its parent window, which will
855
   * own the child window. The parent window is the {@link MainPane}'s
856
   * {@link Scene}'s {@link Window} instance.
857
   *
858
   * <p>
859
   * This will derives the new title from the main window title, incrementing
860
   * the window count to help uniquely identify the child windows.
861
   * </p>
862
   *
863
   * @param tabPane A new {@link DetachableTabPane} to configure.
864
   */
865
  private void initStageOwnerFactory( final DetachableTabPane tabPane ) {
866
    tabPane.setStageOwnerFactory( ( stage ) -> {
867
      final var title = get(
868
        "Detach.tab.title",
869
        ((Stage) getWindow()).getTitle(), ++mWindowCount
870
      );
871
      stage.setTitle( title );
872
873
      return getScene().getWindow();
874
    } );
875
  }
876
877
  /**
878
   * Responsible for configuring the content of each {@link DetachableTab} when
879
   * it is added to the given {@link DetachableTabPane} instance.
880
   * <p>
881
   * For {@link TextEditor} contents, an instance of {@link ScrollEventHandler}
882
   * is initialized to perform synchronized scrolling between the editor and
883
   * its preview window. Additionally, the last tab in the tab pane's list of
884
   * tabs is given focus.
885
   * </p>
886
   * <p>
887
   * Note that multiple tabs can be added simultaneously.
888
   * </p>
889
   *
890
   * @param tabPane A new {@link TabPane} to configure.
891
   */
892
  private void initTabListener( final TabPane tabPane ) {
893
    tabPane.getTabs().addListener(
894
      ( final ListChangeListener.Change<? extends Tab> listener ) -> {
895
        while( listener.next() ) {
896
          if( listener.wasAdded() ) {
897
            final var tabs = listener.getAddedSubList();
898
899
            tabs.forEach( tab -> {
900
              final var node = tab.getContent();
901
902
              if( node instanceof TextEditor ) {
903
                initScrollEventListener( tab );
904
              }
905
            } );
906
907
            // Select and give focus to the last tab opened.
908
            final var index = tabs.size() - 1;
909
            if( index >= 0 ) {
910
              final var tab = tabs.get( index );
911
              tabPane.getSelectionModel().select( tab );
912
              tab.getContent().requestFocus();
913
            }
914
          }
915
        }
916
      }
917
    );
918
  }
919
920
  /**
921
   * Synchronizes scrollbar positions between the given {@link Tab} that
922
   * contains an instance of {@link TextEditor} and {@link HtmlPreview} pane.
923
   *
924
   * @param tab The container for an instance of {@link TextEditor}.
925
   */
926
  private void initScrollEventListener( final Tab tab ) {
927
    final var editor = (TextEditor) tab.getContent();
928
    final var scrollPane = editor.getScrollPane();
929
    final var scrollBar = mPreview.getVerticalScrollBar();
930
    final var handler = new ScrollEventHandler( scrollPane, scrollBar );
931
932
    handler.enabledProperty().bind( tab.selectedProperty() );
933
  }
934
935
  private void addTabPane( final int index, final TabPane tabPane ) {
936
    final var items = getItems();
937
938
    if( !items.contains( tabPane ) ) {
939
      items.add( index, tabPane );
940
    }
941
  }
942
943
  private void addTabPane( final TabPane tabPane ) {
944
    addTabPane( getItems().size(), tabPane );
945
  }
946
947
  private GenericBuilder<Mutator, ProcessorContext> createProcessorContextBuilder() {
948
    final var w = getWorkspace();
949
950
    return builder()
951
      .with( Mutator::setDefinitions, this::getDefinitions )
952
      .with( Mutator::setLocale, w::getLocale )
953
      .with( Mutator::setMetadata, w::getMetadata )
954
      .with( Mutator::setThemePath, w::getThemePath )
955
      .with( Mutator::setCaret,
956
             () -> getTextEditor().getCaret() )
957
      .with( Mutator::setImageDir,
958
             () -> w.getFile( KEY_IMAGES_DIR ) )
959
      .with( Mutator::setImageOrder,
960
             () -> w.getString( KEY_IMAGES_ORDER ) )
961
      .with( Mutator::setImageServer,
962
             () -> w.getString( KEY_IMAGES_SERVER ) )
963
      .with( Mutator::setSigilBegan,
964
             () -> w.getString( KEY_DEF_DELIM_BEGAN ) )
965
      .with( Mutator::setSigilEnded,
966
             () -> w.getString( KEY_DEF_DELIM_ENDED ) )
967
      .with( Mutator::setRScript,
968
             () -> w.getString( KEY_R_SCRIPT ) )
969
      .with( Mutator::setRWorkingDir,
970
             () -> w.getFile( KEY_R_DIR ).toPath() )
971
      .with( Mutator::setCurlQuotes,
972
             () -> w.getBoolean( KEY_TYPESET_TYPOGRAPHY_QUOTES ) )
973
      .with( Mutator::setAutoClean,
974
             () -> w.getBoolean( KEY_TYPESET_CONTEXT_CLEAN ) );
975
  }
976
977
  public ProcessorContext createProcessorContext() {
978
    return createProcessorContext( null, NONE );
979
  }
980
981
  /**
982
   * @param outputPath Used when exporting to a PDF file (binary).
983
   * @param format     Used when processors export to a new text format.
984
   * @return A new {@link ProcessorContext} to use when creating an instance of
985
   * {@link Processor}.
986
   */
987
  public ProcessorContext createProcessorContext(
988
    final Path outputPath, final ExportFormat format ) {
989
    final var textEditor = getTextEditor();
990
    final var inputPath = textEditor.getPath();
991
992
    return createProcessorContextBuilder()
993
      .with( Mutator::setInputPath, inputPath )
994
      .with( Mutator::setOutputPath, outputPath )
995
      .with( Mutator::setExportFormat, format )
996
      .build();
997
  }
998
999
  /**
1000
   * @param inputPath Used by {@link ProcessorFactory} to determine
1001
   *                  {@link Processor} type to create based on file type.
1002
   * @return A new {@link ProcessorContext} to use when creating an instance of
1003
   * {@link Processor}.
1004
   */
1005
  private ProcessorContext createProcessorContext( final Path inputPath ) {
1006
    return createProcessorContextBuilder()
1007
      .with( Mutator::setInputPath, inputPath )
1008
      .with( Mutator::setExportFormat, NONE )
1009
      .build();
1010
  }
1011
1012
  private TextResource createTextResource( final File file ) {
1013
    // TODO: Create PlainTextEditor that's returned by default.
1014
    return MediaType.valueFrom( file ) == TEXT_YAML
1015
      ? createDefinitionEditor( file )
1016
      : createMarkdownEditor( file );
1017
  }
1018
1019
  /**
1020
   * Creates an instance of {@link MarkdownEditor} that listens for both
1021
   * caret change events and text change events. Text change events must
1022
   * take priority over caret change events because it's possible to change
1023
   * the text without moving the caret (e.g., delete selected text).
1024
   *
1025
   * @param inputFile The file containing contents for the text editor.
1026
   * @return A non-null text editor.
1027
   */
1028
  private TextResource createMarkdownEditor( final File inputFile ) {
1029
    final var editor = new MarkdownEditor( inputFile, getWorkspace() );
1030
1031
    mProcessors.computeIfAbsent(
1032
      editor, p -> createProcessors(
1033
        createProcessorContext( inputFile.toPath() ),
1034
        createHtmlPreviewProcessor()
1035
      )
1036
    );
1037
1038
    // Listener for editor modifications or caret position changes.
1039
    editor.addDirtyListener( ( c, o, n ) -> {
1040
      if( n ) {
1041
        // Reset the status bar after changing the text.
1042
        clue();
1043
1044
        // Processing the text may update the status bar.
1045
        process( getTextEditor() );
1046
1047
        // Update the caret position in the status bar.
1048
        CaretMovedEvent.fire( editor.getCaret() );
1049
      }
1050
    } );
1051
1052
    editor.addEventListener(
1053
      keyPressed( SPACE, CONTROL_DOWN ), this::autoinsert
1054
    );
1055
1056
    // Set the active editor, which refreshes the preview panel.
1057
    mTextEditor.set( editor );
1058
1059
    return editor;
1060
  }
1061
1062
  /**
1063
   * Creates a {@link Processor} capable of rendering an HTML document onto
1064
   * a GUI widget.
1065
   *
1066
   * @return The {@link Processor} for rendering an HTML document.
1067
   */
1068
  private Processor<String> createHtmlPreviewProcessor() {
1069
    return new HtmlPreviewProcessor( getPreview() );
1070
  }
1071
1072
  /**
1073
   * See {@link #autoinsert()}.
1074
   */
1075
  private void autoinsert( final KeyEvent ignored ) {
1076
    autoinsert();
1077
  }
1078
1079
  /**
1080
   * Finds a node that matches the word at the caret, then inserts the
1081
   * corresponding definition. The definition token delimiters depend on
1082
   * the type of file being edited.
1083
   */
1084
  public void autoinsert() {
1085
    final var editor = getTextEditor();
1086
    final var mediaType = editor.getMediaType();
1087
    final var injector = createInjector( mediaType );
1088
    final var definitions = getTextDefinition();
1089
1090
    VariableNameInjector.autoinsert( editor, definitions, injector );
1091
  }
1092
1093
  private UnaryOperator<String> createInjector( final MediaType mediaType ) {
1094
    final String began;
1095
    final String ended;
1096
    final UnaryOperator<String> operator;
1097
1098
    switch( mediaType ) {
1099
      case TEXT_MARKDOWN -> {
1100
        began = getString( KEY_DEF_DELIM_BEGAN );
1101
        ended = getString( KEY_DEF_DELIM_ENDED );
1102
        operator = s -> s;
1103
      }
1104
      case TEXT_R_MARKDOWN -> {
1105
        began = InlineRProcessor.PREFIX + getString( KEY_R_DELIM_BEGAN );
1106
        ended = getString( KEY_R_DELIM_ENDED ) + InlineRProcessor.SUFFIX;
22
import com.keenwrite.processors.r.RInlineEvaluator;
23
import com.keenwrite.service.events.Notifier;
24
import com.keenwrite.sigils.PropertyKeyOperator;
25
import com.keenwrite.sigils.RKeyOperator;
26
import com.keenwrite.ui.explorer.FilePickerFactory;
27
import com.keenwrite.ui.heuristics.DocumentStatistics;
28
import com.keenwrite.ui.outline.DocumentOutline;
29
import com.keenwrite.util.GenericBuilder;
30
import com.panemu.tiwulfx.control.dock.DetachableTab;
31
import com.panemu.tiwulfx.control.dock.DetachableTabPane;
32
import javafx.application.Platform;
33
import javafx.beans.property.*;
34
import javafx.collections.ListChangeListener;
35
import javafx.concurrent.Task;
36
import javafx.event.ActionEvent;
37
import javafx.event.Event;
38
import javafx.event.EventHandler;
39
import javafx.scene.Node;
40
import javafx.scene.Scene;
41
import javafx.scene.control.*;
42
import javafx.scene.control.TreeItem.TreeModificationEvent;
43
import javafx.scene.input.KeyEvent;
44
import javafx.scene.layout.FlowPane;
45
import javafx.stage.Stage;
46
import javafx.stage.Window;
47
import org.greenrobot.eventbus.Subscribe;
48
49
import java.io.File;
50
import java.io.FileNotFoundException;
51
import java.nio.file.Path;
52
import java.util.*;
53
import java.util.concurrent.ExecutorService;
54
import java.util.concurrent.ScheduledExecutorService;
55
import java.util.concurrent.ScheduledFuture;
56
import java.util.concurrent.atomic.AtomicBoolean;
57
import java.util.concurrent.atomic.AtomicReference;
58
import java.util.function.Function;
59
import java.util.function.UnaryOperator;
60
import java.util.stream.Collectors;
61
62
import static com.keenwrite.ExportFormat.NONE;
63
import static com.keenwrite.Launcher.terminate;
64
import static com.keenwrite.Messages.get;
65
import static com.keenwrite.constants.Constants.*;
66
import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG_NODE;
67
import static com.keenwrite.events.Bus.register;
68
import static com.keenwrite.events.StatusEvent.clue;
69
import static com.keenwrite.io.MediaType.*;
70
import static com.keenwrite.preferences.AppKeys.*;
71
import static com.keenwrite.processors.IdentityProcessor.IDENTITY;
72
import static com.keenwrite.processors.ProcessorContext.Mutator;
73
import static com.keenwrite.processors.ProcessorContext.builder;
74
import static com.keenwrite.processors.ProcessorFactory.createProcessors;
75
import static java.lang.String.format;
76
import static java.lang.System.getProperty;
77
import static java.util.concurrent.Executors.newFixedThreadPool;
78
import static java.util.concurrent.Executors.newScheduledThreadPool;
79
import static java.util.concurrent.TimeUnit.SECONDS;
80
import static java.util.stream.Collectors.groupingBy;
81
import static javafx.application.Platform.runLater;
82
import static javafx.scene.control.Alert.AlertType.ERROR;
83
import static javafx.scene.control.ButtonType.*;
84
import static javafx.scene.control.TabPane.TabClosingPolicy.ALL_TABS;
85
import static javafx.scene.input.KeyCode.SPACE;
86
import static javafx.scene.input.KeyCombination.CONTROL_DOWN;
87
import static javafx.util.Duration.millis;
88
import static javax.swing.SwingUtilities.invokeLater;
89
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
90
91
/**
92
 * Responsible for wiring together the main application components for a
93
 * particular {@link Workspace} (project). These include the definition views,
94
 * text editors, and preview pane along with any corresponding controllers.
95
 */
96
public final class MainPane extends SplitPane {
97
98
  private static final ExecutorService sExecutor = newFixedThreadPool( 1 );
99
  private static final Notifier sNotifier = Services.load( Notifier.class );
100
101
  /**
102
   * Used when opening files to determine how each file should be binned and
103
   * therefore what tab pane to be opened within.
104
   */
105
  private static final Set<MediaType> PLAIN_TEXT_FORMAT = Set.of(
106
    TEXT_MARKDOWN, TEXT_R_MARKDOWN, UNDEFINED
107
  );
108
109
  private final ScheduledExecutorService mSaver = newScheduledThreadPool( 1 );
110
  private final AtomicReference<ScheduledFuture<?>> mSaveTask =
111
    new AtomicReference<>();
112
113
  /**
114
   * Prevents re-instantiation of processing classes.
115
   */
116
  private final Map<TextResource, Processor<String>> mProcessors =
117
    new HashMap<>();
118
119
  private final Workspace mWorkspace;
120
121
  /**
122
   * Groups similar file type tabs together.
123
   */
124
  private final List<TabPane> mTabPanes = new ArrayList<>();
125
126
  /**
127
   * Renders the actively selected plain text editor tab.
128
   */
129
  private final HtmlPreview mPreview;
130
131
  /**
132
   * Provides an interactive document outline.
133
   */
134
  private final DocumentOutline mOutline = new DocumentOutline();
135
136
  /**
137
   * Changing the active editor fires the value changed event. This allows
138
   * refreshes to happen when external definitions are modified and need to
139
   * trigger the processing chain.
140
   */
141
  private final ObjectProperty<TextEditor> mTextEditor =
142
    createActiveTextEditor();
143
144
  /**
145
   * Changing the active definition editor fires the value changed event. This
146
   * allows refreshes to happen when external definitions are modified and need
147
   * to trigger the processing chain.
148
   */
149
  private final ObjectProperty<TextDefinition> mDefinitionEditor;
150
151
  /**
152
   * Called when the definition data is changed.
153
   */
154
  private final EventHandler<TreeModificationEvent<Event>> mTreeHandler =
155
    event -> {
156
      process( getTextEditor() );
157
      save( getTextDefinition() );
158
    };
159
160
  /**
161
   * Tracks the number of detached tab panels opened into their own windows,
162
   * which allows unique identification of subordinate windows by their title.
163
   * It is doubtful more than 128 windows, much less 256, will be created.
164
   */
165
  private byte mWindowCount;
166
167
  private final DocumentStatistics mStatistics;
168
169
  /**
170
   * Adds all content panels to the main user interface. This will load the
171
   * configuration settings from the workspace to reproduce the settings from
172
   * a previous session.
173
   */
174
  public MainPane( final Workspace workspace ) {
175
    mWorkspace = workspace;
176
    mPreview = new HtmlPreview( workspace );
177
    mStatistics = new DocumentStatistics( workspace );
178
    mTextEditor.set( new MarkdownEditor( workspace ) );
179
    mDefinitionEditor = createActiveDefinitionEditor( mTextEditor );
180
181
    open( collect( getRecentFiles() ) );
182
    viewPreview();
183
    setDividerPositions( calculateDividerPositions() );
184
185
    // Once the main scene's window regains focus, update the active definition
186
    // editor to the currently selected tab.
187
    runLater( () -> getWindow().setOnCloseRequest( event -> {
188
      // Order matters: Open file names must be persisted before closing all.
189
      mWorkspace.save();
190
191
      if( closeAll() ) {
192
        Platform.exit();
193
        terminate( 0 );
194
      }
195
196
      event.consume();
197
    } ) );
198
199
    register( this );
200
    initAutosave( workspace );
201
  }
202
203
  @Subscribe
204
  public void handle( final TextEditorFocusEvent event ) {
205
    mTextEditor.set( event.get() );
206
  }
207
208
  @Subscribe
209
  public void handle( final TextDefinitionFocusEvent event ) {
210
    mDefinitionEditor.set( event.get() );
211
  }
212
213
  /**
214
   * Typically called when a file name is clicked in the preview panel.
215
   *
216
   * @param event The event to process, must contain a valid file reference.
217
   */
218
  @Subscribe
219
  public void handle( final FileOpenEvent event ) {
220
    final File eventFile;
221
    final var eventUri = event.getUri();
222
223
    if( eventUri.isAbsolute() ) {
224
      eventFile = new File( eventUri.getPath() );
225
    }
226
    else {
227
      final var activeFile = getTextEditor().getFile();
228
      final var parent = activeFile.getParentFile();
229
230
      if( parent == null ) {
231
        clue( new FileNotFoundException( eventUri.getPath() ) );
232
        return;
233
      }
234
      else {
235
        final var parentPath = parent.getAbsolutePath();
236
        eventFile = Path.of( parentPath, eventUri.getPath() ).toFile();
237
      }
238
    }
239
240
    runLater( () -> open( eventFile ) );
241
  }
242
243
  @Subscribe
244
  public void handle( final CaretNavigationEvent event ) {
245
    runLater( () -> {
246
      final var textArea = getTextEditor().getTextArea();
247
      textArea.moveTo( event.getOffset() );
248
      textArea.requestFollowCaret();
249
      textArea.requestFocus();
250
    } );
251
  }
252
253
  @Subscribe
254
  @SuppressWarnings( "unused" )
255
  public void handle( final ExportFailedEvent event ) {
256
    final var os = getProperty( "os.name" );
257
    final var arch = getProperty( "os.arch" ).toLowerCase();
258
    final var bits = getProperty( "sun.arch.data.model" );
259
260
    final var title = Messages.get( "Alert.typesetter.missing.title" );
261
    final var header = Messages.get( "Alert.typesetter.missing.header" );
262
    final var version = Messages.get(
263
      "Alert.typesetter.missing.version",
264
      os,
265
      arch
266
        .replaceAll( "amd.*|i.*|x86.*", "X86" )
267
        .replaceAll( "mips.*", "MIPS" )
268
        .replaceAll( "armv.*", "ARM" ),
269
      bits );
270
    final var text = Messages.get( "Alert.typesetter.missing.installer.text" );
271
272
    // Download and install ConTeXt for {0} {1} {2}-bit
273
    final var content = format( "%s %s", text, version );
274
    final var flowPane = new FlowPane();
275
    final var link = new Hyperlink( text );
276
    final var label = new Label( version );
277
    flowPane.getChildren().addAll( link, label );
278
279
    final var alert = new Alert( ERROR, content, OK );
280
    alert.setTitle( title );
281
    alert.setHeaderText( header );
282
    alert.getDialogPane().contentProperty().set( flowPane );
283
    alert.setGraphic( ICON_DIALOG_NODE );
284
285
    link.setOnAction( ( e ) -> {
286
      alert.close();
287
      final var url = Messages.get( "Alert.typesetter.missing.installer.url" );
288
      runLater( () -> HyperlinkOpenEvent.fire( url ) );
289
    } );
290
291
    alert.showAndWait();
292
  }
293
294
  private void initAutosave( final Workspace workspace ) {
295
    final var rate = workspace.integerProperty( KEY_EDITOR_AUTOSAVE );
296
297
    rate.addListener(
298
      ( c, o, n ) -> {
299
        final var taskRef = mSaveTask.get();
300
301
        // Prevent multiple autosaves from running.
302
        if( taskRef != null ) {
303
          taskRef.cancel( false );
304
        }
305
306
        initAutosave( rate );
307
      }
308
    );
309
310
    // Start the save listener (avoids duplicating some code).
311
    initAutosave( rate );
312
  }
313
314
  private void initAutosave( final IntegerProperty rate ) {
315
    mSaveTask.set(
316
      mSaver.scheduleAtFixedRate(
317
        () -> {
318
          if( getTextEditor().isModified() ) {
319
            // Ensure the modified indicator is cleared by running on EDT.
320
            runLater( this::save );
321
          }
322
        }, 0, rate.intValue(), SECONDS
323
      )
324
    );
325
  }
326
327
  /**
328
   * TODO: Load divider positions from exported settings, see
329
   *   {@link #collect(SetProperty)} comment.
330
   */
331
  private double[] calculateDividerPositions() {
332
    final var ratio = 100f / getItems().size() / 100;
333
    final var positions = getDividerPositions();
334
335
    for( int i = 0; i < positions.length; i++ ) {
336
      positions[ i ] = ratio * i;
337
    }
338
339
    return positions;
340
  }
341
342
  /**
343
   * Opens all the files into the application, provided the paths are unique.
344
   * This may only be called for any type of files that a user can edit
345
   * (i.e., update and persist), such as definitions and text files.
346
   *
347
   * @param files The list of files to open.
348
   */
349
  public void open( final List<File> files ) {
350
    files.forEach( this::open );
351
  }
352
353
  /**
354
   * This opens the given file. Since the preview pane is not a file that
355
   * can be opened, it is safe to add a listener to the detachable pane.
356
   * This will exit early if the given file is not a regular file (i.e., a
357
   * directory).
358
   *
359
   * @param inputFile The file to open.
360
   */
361
  private void open( final File inputFile ) {
362
    // Prevent opening directories (a non-existent "untitled.md" is fine).
363
    if( !inputFile.isFile() && inputFile.exists() ) {
364
      return;
365
    }
366
367
    final var tab = createTab( inputFile );
368
    final var node = tab.getContent();
369
    final var mediaType = MediaType.valueFrom( inputFile );
370
    final var tabPane = obtainTabPane( mediaType );
371
372
    tab.setTooltip( createTooltip( inputFile ) );
373
    tabPane.setFocusTraversable( false );
374
    tabPane.setTabClosingPolicy( ALL_TABS );
375
    tabPane.getTabs().add( tab );
376
377
    // Attach the tab scene factory for new tab panes.
378
    if( !getItems().contains( tabPane ) ) {
379
      addTabPane(
380
        node instanceof TextDefinition ? 0 : getItems().size(), tabPane
381
      );
382
    }
383
384
    if( inputFile.isFile() ) {
385
      getRecentFiles().add( inputFile.getAbsolutePath() );
386
    }
387
  }
388
389
  /**
390
   * Opens a new text editor document using the default document file name.
391
   */
392
  public void newTextEditor() {
393
    open( DOCUMENT_DEFAULT );
394
  }
395
396
  /**
397
   * Opens a new definition editor document using the default definition
398
   * file name.
399
   */
400
  public void newDefinitionEditor() {
401
    open( DEFINITION_DEFAULT );
402
  }
403
404
  /**
405
   * Iterates over all tab panes to find all {@link TextEditor}s and request
406
   * that they save themselves.
407
   */
408
  public void saveAll() {
409
    mTabPanes.forEach(
410
      tp -> tp.getTabs().forEach( tab -> {
411
        final var node = tab.getContent();
412
413
        if( node instanceof final TextEditor editor ) {
414
          save( editor );
415
        }
416
      } )
417
    );
418
  }
419
420
  /**
421
   * Requests that the active {@link TextEditor} saves itself. Don't bother
422
   * checking if modified first because if the user swaps external media from
423
   * an external source (e.g., USB thumb drive), save should not second-guess
424
   * the user: save always re-saves. Also, it's less code.
425
   */
426
  public void save() {
427
    save( getTextEditor() );
428
  }
429
430
  /**
431
   * Saves the active {@link TextEditor} under a new name.
432
   *
433
   * @param files The new active editor {@link File} reference, must contain
434
   *              at least one element.
435
   */
436
  public void saveAs( final List<File> files ) {
437
    assert files != null;
438
    assert !files.isEmpty();
439
    final var editor = getTextEditor();
440
    final var tab = getTab( editor );
441
    final var file = files.get( 0 );
442
443
    editor.rename( file );
444
    tab.ifPresent( t -> {
445
      t.setText( editor.getFilename() );
446
      t.setTooltip( createTooltip( file ) );
447
    } );
448
449
    save();
450
  }
451
452
  /**
453
   * Saves the given {@link TextResource} to a file. This is typically used
454
   * to save either an instance of {@link TextEditor} or {@link TextDefinition}.
455
   *
456
   * @param resource The resource to export.
457
   */
458
  private void save( final TextResource resource ) {
459
    try {
460
      resource.save();
461
    } catch( final Exception ex ) {
462
      clue( ex );
463
      sNotifier.alert(
464
        getWindow(), resource.getPath(), "TextResource.saveFailed", ex
465
      );
466
    }
467
  }
468
469
  /**
470
   * Closes all open {@link TextEditor}s; all {@link TextDefinition}s stay open.
471
   *
472
   * @return {@code true} when all editors, modified or otherwise, were
473
   * permitted to close; {@code false} when one or more editors were modified
474
   * and the user requested no closing.
475
   */
476
  public boolean closeAll() {
477
    var closable = true;
478
479
    for( final var tabPane : mTabPanes ) {
480
      final var tabIterator = tabPane.getTabs().iterator();
481
482
      while( tabIterator.hasNext() ) {
483
        final var tab = tabIterator.next();
484
        final var resource = tab.getContent();
485
486
        // The definition panes auto-save, so being specific here prevents
487
        // closing the definitions in the situation where the user wants to
488
        // continue editing (i.e., possibly save unsaved work).
489
        if( !(resource instanceof TextEditor) ) {
490
          continue;
491
        }
492
493
        if( canClose( (TextEditor) resource ) ) {
494
          tabIterator.remove();
495
          close( tab );
496
        }
497
        else {
498
          closable = false;
499
        }
500
      }
501
    }
502
503
    return closable;
504
  }
505
506
  /**
507
   * Calls the tab's {@link Tab#getOnClosed()} handler to carry out a close
508
   * event.
509
   *
510
   * @param tab The {@link Tab} that was closed.
511
   */
512
  private void close( final Tab tab ) {
513
    assert tab != null;
514
515
    final var handler = tab.getOnClosed();
516
517
    if( handler != null ) {
518
      handler.handle( new ActionEvent() );
519
    }
520
  }
521
522
  /**
523
   * Closes the active tab; delegates to {@link #canClose(TextResource)}.
524
   */
525
  public void close() {
526
    final var editor = getTextEditor();
527
528
    if( canClose( editor ) ) {
529
      close( editor );
530
    }
531
  }
532
533
  /**
534
   * Closes the given {@link TextResource}. This must not be called from within
535
   * a loop that iterates over the tab panes using {@code forEach}, lest a
536
   * concurrent modification exception be thrown.
537
   *
538
   * @param resource The {@link TextResource} to close, without confirming with
539
   *                 the user.
540
   */
541
  private void close( final TextResource resource ) {
542
    getTab( resource ).ifPresent(
543
      ( tab ) -> {
544
        close( tab );
545
        tab.getTabPane().getTabs().remove( tab );
546
      }
547
    );
548
  }
549
550
  /**
551
   * Answers whether the given {@link TextResource} may be closed.
552
   *
553
   * @param editor The {@link TextResource} to try closing.
554
   * @return {@code true} when the editor may be closed; {@code false} when
555
   * the user has requested to keep the editor open.
556
   */
557
  private boolean canClose( final TextResource editor ) {
558
    final var editorTab = getTab( editor );
559
    final var canClose = new AtomicBoolean( true );
560
561
    if( editor.isModified() ) {
562
      final var filename = new StringBuilder();
563
      editorTab.ifPresent( ( tab ) -> filename.append( tab.getText() ) );
564
565
      final var message = sNotifier.createNotification(
566
        Messages.get( "Alert.file.close.title" ),
567
        Messages.get( "Alert.file.close.text" ),
568
        filename.toString()
569
      );
570
571
      final var dialog = sNotifier.createConfirmation( getWindow(), message );
572
573
      dialog.showAndWait().ifPresent(
574
        save -> canClose.set( save == YES ? editor.save() : save == NO )
575
      );
576
    }
577
578
    return canClose.get();
579
  }
580
581
  private ObjectProperty<TextEditor> createActiveTextEditor() {
582
    final var editor = new SimpleObjectProperty<TextEditor>();
583
584
    editor.addListener( ( c, o, n ) -> {
585
      if( n != null ) {
586
        mPreview.setBaseUri( n.getPath() );
587
        process( n );
588
      }
589
    } );
590
591
    return editor;
592
  }
593
594
  /**
595
   * Adds the HTML preview tab to its own, singular tab pane.
596
   */
597
  public void viewPreview() {
598
    viewTab( mPreview, TEXT_HTML, "Pane.preview.title" );
599
  }
600
601
  /**
602
   * Adds the document outline tab to its own, singular tab pane.
603
   */
604
  public void viewOutline() {
605
    viewTab( mOutline, APP_DOCUMENT_OUTLINE, "Pane.outline.title" );
606
  }
607
608
  public void viewStatistics() {
609
    viewTab( mStatistics, APP_DOCUMENT_STATISTICS, "Pane.statistics.title" );
610
  }
611
612
  public void viewFiles() {
613
    try {
614
      final var factory = new FilePickerFactory( getWorkspace() );
615
      final var fileManager = factory.createModeless();
616
      viewTab( fileManager, APP_FILE_MANAGER, "Pane.files.title" );
617
    } catch( final Exception ex ) {
618
      clue( ex );
619
    }
620
  }
621
622
  private void viewTab(
623
    final Node node, final MediaType mediaType, final String key ) {
624
    final var tabPane = obtainTabPane( mediaType );
625
626
    for( final var tab : tabPane.getTabs() ) {
627
      if( tab.getContent() == node ) {
628
        return;
629
      }
630
    }
631
632
    tabPane.getTabs().add( createTab( get( key ), node ) );
633
    addTabPane( tabPane );
634
  }
635
636
  public void viewRefresh() {
637
    mPreview.refresh();
638
  }
639
640
  /**
641
   * Returns the tab that contains the given {@link TextEditor}.
642
   *
643
   * @param editor The {@link TextEditor} instance to find amongst the tabs.
644
   * @return The first tab having content that matches the given tab.
645
   */
646
  private Optional<Tab> getTab( final TextResource editor ) {
647
    return mTabPanes.stream()
648
                    .flatMap( pane -> pane.getTabs().stream() )
649
                    .filter( tab -> editor.equals( tab.getContent() ) )
650
                    .findFirst();
651
  }
652
653
  /**
654
   * Creates a new {@link DefinitionEditor} wrapped in a listener that
655
   * is used to detect when the active {@link DefinitionEditor} has changed.
656
   * Upon changing, the variables are interpolated and the active text editor
657
   * is refreshed.
658
   *
659
   * @param textEditor Text editor to update with the revised resolved map.
660
   * @return A newly configured property that represents the active
661
   * {@link DefinitionEditor}, never null.
662
   */
663
  private ObjectProperty<TextDefinition> createActiveDefinitionEditor(
664
    final ObjectProperty<TextEditor> textEditor ) {
665
    final var defEditor = new SimpleObjectProperty<>(
666
      createDefinitionEditor()
667
    );
668
669
    defEditor.addListener( ( c, o, n ) -> process( textEditor.get() ) );
670
671
    return defEditor;
672
  }
673
674
  private Tab createTab( final String filename, final Node node ) {
675
    return new DetachableTab( filename, node );
676
  }
677
678
  private Tab createTab( final File file ) {
679
    final var r = createTextResource( file );
680
    final var tab = createTab( r.getFilename(), r.getNode() );
681
682
    r.modifiedProperty().addListener(
683
      ( c, o, n ) -> tab.setText( r.getFilename() + (n ? "*" : "") )
684
    );
685
686
    // This is called when either the tab is closed by the user clicking on
687
    // the tab's close icon or when closing (all) from the file menu.
688
    tab.setOnClosed(
689
      ( __ ) -> getRecentFiles().remove( file.getAbsolutePath() )
690
    );
691
692
    // When closing a tab, give focus to the newly revealed tab.
693
    tab.selectedProperty().addListener( ( c, o, n ) -> {
694
      if( n != null && n ) {
695
        final var pane = tab.getTabPane();
696
697
        if( pane != null ) {
698
          pane.requestFocus();
699
        }
700
      }
701
    } );
702
703
    tab.tabPaneProperty().addListener( ( cPane, oPane, nPane ) -> {
704
      if( nPane != null ) {
705
        nPane.focusedProperty().addListener( ( c, o, n ) -> {
706
          if( n != null && n ) {
707
            final var selected = nPane.getSelectionModel().getSelectedItem();
708
            final var node = selected.getContent();
709
            node.requestFocus();
710
          }
711
        } );
712
      }
713
    } );
714
715
    return tab;
716
  }
717
718
  /**
719
   * Creates bins for the different {@link MediaType}s, which eventually are
720
   * added to the UI as separate tab panes. If ever a general-purpose scene
721
   * exporter is developed to serialize a scene to an FXML file, this could
722
   * be replaced by such a class.
723
   * <p>
724
   * When binning the files, this makes sure that at least one file exists
725
   * for every type. If the user has opted to close a particular type (such
726
   * as the definition pane), the view will suppressed elsewhere.
727
   * </p>
728
   * <p>
729
   * The order that the binned files are returned will be reflected in the
730
   * order that the corresponding panes are rendered in the UI.
731
   * </p>
732
   *
733
   * @param paths The file paths to bin according to their type.
734
   * @return An in-order list of files, first by structured definition files,
735
   * then by plain text documents.
736
   */
737
  private List<File> collect( final SetProperty<String> paths ) {
738
    // Treat all files destined for the text editor as plain text documents
739
    // so that they are added to the same pane. Grouping by TEXT_PLAIN is a
740
    // bit arbitrary, but means explicitly capturing TEXT_PLAIN isn't needed.
741
    final Function<MediaType, MediaType> bin =
742
      m -> PLAIN_TEXT_FORMAT.contains( m ) ? TEXT_PLAIN : m;
743
744
    // Create two groups: YAML files and plain text files. The order that
745
    // the elements are listed in the enumeration for media types determines
746
    // what files are loaded first. Variable definitions come before all other
747
    // plain text documents.
748
    final var bins = paths
749
      .stream()
750
      .collect(
751
        groupingBy(
752
          path -> bin.apply( MediaType.valueFrom( path ) ),
753
          () -> new TreeMap<>( Enum::compareTo ),
754
          Collectors.toList()
755
        )
756
      );
757
758
    bins.putIfAbsent( TEXT_YAML, List.of( DEFINITION_DEFAULT.toString() ) );
759
    bins.putIfAbsent( TEXT_PLAIN, List.of( DOCUMENT_DEFAULT.toString() ) );
760
761
    final var result = new LinkedList<File>();
762
763
    // Ensure that the same types are listed together (keep insertion order).
764
    bins.forEach( ( mediaType, files ) -> result.addAll(
765
      files.stream().map( File::new ).toList() )
766
    );
767
768
    return result;
769
  }
770
771
  /**
772
   * Force the active editor to update, which will cause the processor
773
   * to re-evaluate the interpolated definition map thereby updating the
774
   * preview pane.
775
   *
776
   * @param editor Contains the source document to update in the preview pane.
777
   */
778
  private void process( final TextEditor editor ) {
779
    // Ensure processing does not run on the JavaFX thread, which frees the
780
    // text editor immediately for caret movement. The preview will have a
781
    // slight delay when catching up to the caret position.
782
    final var task = new Task<Void>() {
783
      @Override
784
      public Void call() {
785
        try {
786
          final var p = mProcessors.getOrDefault( editor, IDENTITY );
787
          p.apply( editor == null ? "" : editor.getText() );
788
        } catch( final Exception ex ) {
789
          clue( ex );
790
        }
791
792
        return null;
793
      }
794
    };
795
796
    // TODO: Each time the editor successfully runs the processor the task is
797
    //   considered successful. Due to the rapid-fire nature of processing
798
    //   (e.g., keyboard navigation, fast typing), it isn't necessary to
799
    //   scroll each time.
800
    //   The algorithm:
801
    //   1. Peek at the oldest time.
802
    //   2. If the difference between the oldest time and current time exceeds
803
    //      250 milliseconds, then invoke the scrolling.
804
    //   3. Insert the current time into the circular queue.
805
    task.setOnSucceeded(
806
      e -> invokeLater( () -> mPreview.scrollTo( CARET_ID ) )
807
    );
808
809
    // Prevents multiple process requests from executing simultaneously (due
810
    // to having a restricted queue size).
811
    sExecutor.execute( task );
812
  }
813
814
  /**
815
   * Lazily creates a {@link TabPane} configured to listen for tab select
816
   * events. The tab pane is associated with a given media type so that
817
   * similar files can be grouped together.
818
   *
819
   * @param mediaType The media type to associate with the tab pane.
820
   * @return An instance of {@link TabPane} that will handle tab docking.
821
   */
822
  private TabPane obtainTabPane( final MediaType mediaType ) {
823
    for( final var pane : mTabPanes ) {
824
      for( final var tab : pane.getTabs() ) {
825
        final var node = tab.getContent();
826
827
        if( node instanceof TextResource r && r.supports( mediaType ) ) {
828
          return pane;
829
        }
830
      }
831
    }
832
833
    final var pane = createTabPane();
834
    mTabPanes.add( pane );
835
    return pane;
836
  }
837
838
  /**
839
   * Creates an initialized {@link TabPane} instance.
840
   *
841
   * @return A new {@link TabPane} with all listeners configured.
842
   */
843
  private TabPane createTabPane() {
844
    final var tabPane = new DetachableTabPane();
845
846
    initStageOwnerFactory( tabPane );
847
    initTabListener( tabPane );
848
849
    return tabPane;
850
  }
851
852
  /**
853
   * When any {@link DetachableTabPane} is detached from the main window,
854
   * the stage owner factory must be given its parent window, which will
855
   * own the child window. The parent window is the {@link MainPane}'s
856
   * {@link Scene}'s {@link Window} instance.
857
   *
858
   * <p>
859
   * This will derives the new title from the main window title, incrementing
860
   * the window count to help uniquely identify the child windows.
861
   * </p>
862
   *
863
   * @param tabPane A new {@link DetachableTabPane} to configure.
864
   */
865
  private void initStageOwnerFactory( final DetachableTabPane tabPane ) {
866
    tabPane.setStageOwnerFactory( ( stage ) -> {
867
      final var title = get(
868
        "Detach.tab.title",
869
        ((Stage) getWindow()).getTitle(), ++mWindowCount
870
      );
871
      stage.setTitle( title );
872
873
      return getScene().getWindow();
874
    } );
875
  }
876
877
  /**
878
   * Responsible for configuring the content of each {@link DetachableTab} when
879
   * it is added to the given {@link DetachableTabPane} instance.
880
   * <p>
881
   * For {@link TextEditor} contents, an instance of {@link ScrollEventHandler}
882
   * is initialized to perform synchronized scrolling between the editor and
883
   * its preview window. Additionally, the last tab in the tab pane's list of
884
   * tabs is given focus.
885
   * </p>
886
   * <p>
887
   * Note that multiple tabs can be added simultaneously.
888
   * </p>
889
   *
890
   * @param tabPane A new {@link TabPane} to configure.
891
   */
892
  private void initTabListener( final TabPane tabPane ) {
893
    tabPane.getTabs().addListener(
894
      ( final ListChangeListener.Change<? extends Tab> listener ) -> {
895
        while( listener.next() ) {
896
          if( listener.wasAdded() ) {
897
            final var tabs = listener.getAddedSubList();
898
899
            tabs.forEach( tab -> {
900
              final var node = tab.getContent();
901
902
              if( node instanceof TextEditor ) {
903
                initScrollEventListener( tab );
904
              }
905
            } );
906
907
            // Select and give focus to the last tab opened.
908
            final var index = tabs.size() - 1;
909
            if( index >= 0 ) {
910
              final var tab = tabs.get( index );
911
              tabPane.getSelectionModel().select( tab );
912
              tab.getContent().requestFocus();
913
            }
914
          }
915
        }
916
      }
917
    );
918
  }
919
920
  /**
921
   * Synchronizes scrollbar positions between the given {@link Tab} that
922
   * contains an instance of {@link TextEditor} and {@link HtmlPreview} pane.
923
   *
924
   * @param tab The container for an instance of {@link TextEditor}.
925
   */
926
  private void initScrollEventListener( final Tab tab ) {
927
    final var editor = (TextEditor) tab.getContent();
928
    final var scrollPane = editor.getScrollPane();
929
    final var scrollBar = mPreview.getVerticalScrollBar();
930
    final var handler = new ScrollEventHandler( scrollPane, scrollBar );
931
932
    handler.enabledProperty().bind( tab.selectedProperty() );
933
  }
934
935
  private void addTabPane( final int index, final TabPane tabPane ) {
936
    final var items = getItems();
937
938
    if( !items.contains( tabPane ) ) {
939
      items.add( index, tabPane );
940
    }
941
  }
942
943
  private void addTabPane( final TabPane tabPane ) {
944
    addTabPane( getItems().size(), tabPane );
945
  }
946
947
  private GenericBuilder<Mutator, ProcessorContext> createProcessorContextBuilder() {
948
    final var w = getWorkspace();
949
950
    return builder()
951
      .with( Mutator::setDefinitions, this::getDefinitions )
952
      .with( Mutator::setLocale, w::getLocale )
953
      .with( Mutator::setMetadata, w::getMetadata )
954
      .with( Mutator::setThemePath, w::getThemePath )
955
      .with( Mutator::setCaret,
956
             () -> getTextEditor().getCaret() )
957
      .with( Mutator::setImageDir,
958
             () -> w.getFile( KEY_IMAGES_DIR ) )
959
      .with( Mutator::setImageOrder,
960
             () -> w.getString( KEY_IMAGES_ORDER ) )
961
      .with( Mutator::setImageServer,
962
             () -> w.getString( KEY_IMAGES_SERVER ) )
963
      .with( Mutator::setSigilBegan,
964
             () -> w.getString( KEY_DEF_DELIM_BEGAN ) )
965
      .with( Mutator::setSigilEnded,
966
             () -> w.getString( KEY_DEF_DELIM_ENDED ) )
967
      .with( Mutator::setRScript,
968
             () -> w.getString( KEY_R_SCRIPT ) )
969
      .with( Mutator::setRWorkingDir,
970
             () -> w.getFile( KEY_R_DIR ).toPath() )
971
      .with( Mutator::setCurlQuotes,
972
             () -> w.getBoolean( KEY_TYPESET_TYPOGRAPHY_QUOTES ) )
973
      .with( Mutator::setAutoClean,
974
             () -> w.getBoolean( KEY_TYPESET_CONTEXT_CLEAN ) );
975
  }
976
977
  public ProcessorContext createProcessorContext() {
978
    return createProcessorContext( null, NONE );
979
  }
980
981
  /**
982
   * @param outputPath Used when exporting to a PDF file (binary).
983
   * @param format     Used when processors export to a new text format.
984
   * @return A new {@link ProcessorContext} to use when creating an instance of
985
   * {@link Processor}.
986
   */
987
  public ProcessorContext createProcessorContext(
988
    final Path outputPath, final ExportFormat format ) {
989
    final var textEditor = getTextEditor();
990
    final var inputPath = textEditor.getPath();
991
992
    return createProcessorContextBuilder()
993
      .with( Mutator::setInputPath, inputPath )
994
      .with( Mutator::setOutputPath, outputPath )
995
      .with( Mutator::setExportFormat, format )
996
      .build();
997
  }
998
999
  /**
1000
   * @param inputPath Used by {@link ProcessorFactory} to determine
1001
   *                  {@link Processor} type to create based on file type.
1002
   * @return A new {@link ProcessorContext} to use when creating an instance of
1003
   * {@link Processor}.
1004
   */
1005
  private ProcessorContext createProcessorContext( final Path inputPath ) {
1006
    return createProcessorContextBuilder()
1007
      .with( Mutator::setInputPath, inputPath )
1008
      .with( Mutator::setExportFormat, NONE )
1009
      .build();
1010
  }
1011
1012
  private TextResource createTextResource( final File file ) {
1013
    // TODO: Create PlainTextEditor that's returned by default.
1014
    return MediaType.valueFrom( file ) == TEXT_YAML
1015
      ? createDefinitionEditor( file )
1016
      : createMarkdownEditor( file );
1017
  }
1018
1019
  /**
1020
   * Creates an instance of {@link MarkdownEditor} that listens for both
1021
   * caret change events and text change events. Text change events must
1022
   * take priority over caret change events because it's possible to change
1023
   * the text without moving the caret (e.g., delete selected text).
1024
   *
1025
   * @param inputFile The file containing contents for the text editor.
1026
   * @return A non-null text editor.
1027
   */
1028
  private TextResource createMarkdownEditor( final File inputFile ) {
1029
    final var editor = new MarkdownEditor( inputFile, getWorkspace() );
1030
1031
    mProcessors.computeIfAbsent(
1032
      editor, p -> createProcessors(
1033
        createProcessorContext( inputFile.toPath() ),
1034
        createHtmlPreviewProcessor()
1035
      )
1036
    );
1037
1038
    // Listener for editor modifications or caret position changes.
1039
    editor.addDirtyListener( ( c, o, n ) -> {
1040
      if( n ) {
1041
        // Reset the status bar after changing the text.
1042
        clue();
1043
1044
        // Processing the text may update the status bar.
1045
        process( getTextEditor() );
1046
1047
        // Update the caret position in the status bar.
1048
        CaretMovedEvent.fire( editor.getCaret() );
1049
      }
1050
    } );
1051
1052
    editor.addEventListener(
1053
      keyPressed( SPACE, CONTROL_DOWN ), this::autoinsert
1054
    );
1055
1056
    // Set the active editor, which refreshes the preview panel.
1057
    mTextEditor.set( editor );
1058
1059
    return editor;
1060
  }
1061
1062
  /**
1063
   * Creates a {@link Processor} capable of rendering an HTML document onto
1064
   * a GUI widget.
1065
   *
1066
   * @return The {@link Processor} for rendering an HTML document.
1067
   */
1068
  private Processor<String> createHtmlPreviewProcessor() {
1069
    return new HtmlPreviewProcessor( getPreview() );
1070
  }
1071
1072
  /**
1073
   * See {@link #autoinsert()}.
1074
   */
1075
  private void autoinsert( final KeyEvent ignored ) {
1076
    autoinsert();
1077
  }
1078
1079
  /**
1080
   * Finds a node that matches the word at the caret, then inserts the
1081
   * corresponding definition. The definition token delimiters depend on
1082
   * the type of file being edited.
1083
   */
1084
  public void autoinsert() {
1085
    final var editor = getTextEditor();
1086
    final var mediaType = editor.getMediaType();
1087
    final var injector = createInjector( mediaType );
1088
    final var definitions = getTextDefinition();
1089
1090
    VariableNameInjector.autoinsert( editor, definitions, injector );
1091
  }
1092
1093
  private UnaryOperator<String> createInjector( final MediaType mediaType ) {
1094
    final String began;
1095
    final String ended;
1096
    final UnaryOperator<String> operator;
1097
1098
    switch( mediaType ) {
1099
      case TEXT_MARKDOWN -> {
1100
        began = getString( KEY_DEF_DELIM_BEGAN );
1101
        ended = getString( KEY_DEF_DELIM_ENDED );
1102
        operator = s -> s;
1103
      }
1104
      case TEXT_R_MARKDOWN -> {
1105
        began = RInlineEvaluator.PREFIX + getString( KEY_R_DELIM_BEGAN );
1106
        ended = getString( KEY_R_DELIM_ENDED ) + RInlineEvaluator.SUFFIX;
11071107
        operator = new RKeyOperator();
11081108
      }
M src/main/java/com/keenwrite/dom/DocumentParser.java
4444
  private static final DocumentBuilderFactory sDocumentFactory;
4545
  private static DocumentBuilder sDocumentBuilder;
46
  public static DOMImplementation sDomImplementation;
47
  public static Transformer sTransformer;
46
  private static Transformer sTransformer;
4847
  private static final XPath sXpath = XPathFactory.newInstance().newXPath();
48
49
  public static DOMImplementation sDomImplementation;
4950
5051
  static {
M src/main/java/com/keenwrite/io/SysFile.java
2020
2121
  /**
22
   * Creates a new instance for a given file name.
23
   *
24
   * @param pathname File name to represent for subsequent operations.
25
   */
26
  public SysFile( final String pathname ) {
27
    super( pathname );
28
  }
29
30
  /**
2231
   * For a file name that represents an executable (without an extension)
2332
   * file, this determines whether the executable is found in the PATH
...
3342
    final var exe = getName();
3443
    final var paths = getenv( "PATH" ).split( quote( pathSeparator ) );
44
3545
    return Stream.of( paths ).map( Paths::get ).anyMatch(
3646
      path -> {
...
4656
      }
4757
    );
48
  }
49
50
  /**
51
   * Creates a new instance for a given file name.
52
   *
53
   * @param pathname File name to represent for subsequent operations.
54
   */
55
  public SysFile( final String pathname ) {
56
    super( pathname );
5758
  }
5859
}
M src/main/java/com/keenwrite/processors/ProcessorFactory.java
33
44
import com.keenwrite.processors.markdown.MarkdownProcessor;
5
import com.keenwrite.processors.r.InlineRProcessor;
5
import com.keenwrite.processors.r.RBootstrapController;
6
import com.keenwrite.processors.r.RInlineEvaluator;
67
import com.keenwrite.processors.r.RVariableProcessor;
78
8
import static com.keenwrite.ExportFormat.MARKDOWN_PLAIN;
99
import static com.keenwrite.io.FileType.RMARKDOWN;
1010
import static com.keenwrite.io.FileType.SOURCE;
...
6868
    final Processor<String> processor;
6969
70
    // When there's no preview, determine processor by file name extension.
70
    // When there's no preview, convert to HTML.
7171
    if( preview == null ) {
72
      if( outputType == MARKDOWN_PLAIN ) {
73
        processor = inputType == RMARKDOWN
74
          ? createInlineRProcessor( successor, context )
75
          : createVariableProcessor( successor, context );
76
      }
77
      else {
78
        processor = createMarkdownProcessor( successor, context );
79
      }
72
      processor = createMarkdownProcessor( successor, context );
8073
    }
8174
    else {
...
125118
   * is useful for converting R Markdown documents into plain Markdown.
126119
   *
127
   * @param successor {@link Processor} invoked after {@link InlineRProcessor}.
120
   * @param successor {@link Processor} invoked after {@link RInlineEvaluator}.
128121
   * @param context   {@link Processor} configuration settings.
129122
   * @return An instance of {@link Processor} that performs variable
130123
   * interpolation, replacement, and execution of R statements.
131124
   */
132
  private static Processor<String> createInlineRProcessor(
125
  public static Processor<String> createRProcessor(
133126
    final Processor<String> successor, final ProcessorContext context ) {
134
    final var irp = new InlineRProcessor( successor, context );
135
    final var rvp = new RVariableProcessor( irp, context );
127
    RBootstrapController.init( context );
128
    final var rvp = new RVariableProcessor( successor, context );
136129
    return createVariableProcessor( rvp, context );
137130
  }
M src/main/java/com/keenwrite/processors/markdown/BaseMarkdownProcessor.java
66
import com.keenwrite.processors.ProcessorContext;
77
import com.keenwrite.processors.markdown.extensions.fences.FencedDivExtension;
8
import com.keenwrite.processors.markdown.extensions.r.RExtension;
8
import com.keenwrite.processors.markdown.extensions.r.RInlineExtension;
99
import com.vladsch.flexmark.ext.definition.DefinitionExtension;
1010
import com.vladsch.flexmark.ext.gfm.strikethrough.StrikethroughSubscriptExtension;
...
2424
 * Responsible for parsing and rendering Markdown into HTML. This is required
2525
 * to break a circular dependency between the {@link MarkdownProcessor} and
26
 * {@link RExtension}.
26
 * {@link RInlineExtension}.
2727
 */
2828
public class BaseMarkdownProcessor extends ExecutorProcessor<String> {
M src/main/java/com/keenwrite/processors/markdown/MarkdownProcessor.java
33
44
import com.keenwrite.io.MediaType;
5
import com.keenwrite.processors.VariableProcessor;
65
import com.keenwrite.processors.Processor;
76
import com.keenwrite.processors.ProcessorContext;
8
import com.keenwrite.processors.markdown.extensions.*;
7
import com.keenwrite.processors.VariableProcessor;
8
import com.keenwrite.processors.markdown.extensions.CaretExtension;
9
import com.keenwrite.processors.markdown.extensions.DocumentOutlineExtension;
10
import com.keenwrite.processors.markdown.extensions.ImageLinkExtension;
911
import com.keenwrite.processors.markdown.extensions.fences.FencedBlockExtension;
10
import com.keenwrite.processors.markdown.extensions.r.RExtension;
12
import com.keenwrite.processors.markdown.extensions.r.RInlineExtension;
1113
import com.keenwrite.processors.markdown.extensions.tex.TeXExtension;
12
import com.keenwrite.processors.r.RProcessor;
1314
import com.vladsch.flexmark.util.misc.Extension;
1415
...
5960
6061
    if( mediaType == TEXT_R_MARKDOWN ) {
61
      final var rProcessor = new RProcessor( context );
62
      extensions.add( RExtension.create( rProcessor, context ) );
63
      processor = rProcessor;
62
      extensions.add( RInlineExtension.create( context ) );
63
      processor = IDENTITY;
6464
    }
6565
    else {
M src/main/java/com/keenwrite/processors/markdown/extensions/fences/FencedBlockExtension.java
88
import com.keenwrite.processors.markdown.MarkdownProcessor;
99
import com.keenwrite.processors.markdown.extensions.HtmlRendererAdapter;
10
import com.keenwrite.processors.r.RChunkEvaluator;
11
import com.keenwrite.processors.r.RVariableProcessor;
12
import com.keenwrite.util.Pair;
1013
import com.vladsch.flexmark.ast.FencedCodeBlock;
1114
import com.vladsch.flexmark.html.HtmlRendererOptions;
1215
import com.vladsch.flexmark.html.HtmlWriter;
13
import com.vladsch.flexmark.html.renderer.DelegatingNodeRendererFactory;
14
import com.vladsch.flexmark.html.renderer.NodeRenderer;
15
import com.vladsch.flexmark.html.renderer.NodeRendererContext;
16
import com.vladsch.flexmark.html.renderer.NodeRenderingHandler;
16
import com.vladsch.flexmark.html.renderer.*;
1717
import com.vladsch.flexmark.util.data.DataHolder;
1818
import com.vladsch.flexmark.util.sequence.BasedSequence;
1919
import org.jetbrains.annotations.NotNull;
2020
21
import java.nio.file.Paths;
2122
import java.util.HashSet;
2223
import java.util.Set;
2324
25
import static com.keenwrite.Bootstrap.APP_TITLE_LOWERCASE;
26
import static com.keenwrite.processors.IdentityProcessor.IDENTITY;
2427
import static com.vladsch.flexmark.html.HtmlRenderer.Builder;
2528
import static com.vladsch.flexmark.html.renderer.CoreNodeRenderer.CODE_CONTENT;
2629
import static com.vladsch.flexmark.html.renderer.LinkType.LINK;
30
import static java.lang.String.format;
2731
2832
/**
2933
 * Responsible for converting textual diagram descriptions into HTML image
3034
 * elements.
3135
 */
32
public class FencedBlockExtension extends HtmlRendererAdapter {
33
  private final static String DIAGRAM_STYLE = "diagram-";
34
  private final static int DIAGRAM_STYLE_LEN = DIAGRAM_STYLE.length();
36
public final class FencedBlockExtension extends HtmlRendererAdapter {
37
  private final static String STYLE_DIAGRAM = "diagram-";
38
  private final static int STYLE_DIAGRAM_LEN = STYLE_DIAGRAM.length();
39
40
  private final static String STYLE_R_CHUNK = "{r";
41
42
  private final static class VerbatimRVariableProcessor
43
    extends RVariableProcessor {
44
45
    public VerbatimRVariableProcessor(
46
      final Processor<String> successor, final ProcessorContext context ) {
47
      super( successor, context );
48
    }
49
50
    @Override
51
    protected String processValue( final String value ) {
52
      return value;
53
    }
54
  }
3555
56
  private final RChunkEvaluator mEvaluator;
3657
  private final Processor<String> mProcessor;
58
  private final Processor<String> mRVariableProcessor;
3759
  private final ProcessorContext mContext;
3860
3961
  public FencedBlockExtension(
4062
    final Processor<String> processor, final ProcessorContext context ) {
4163
    assert processor != null;
4264
    assert context != null;
4365
    mProcessor = processor;
4466
    mContext = context;
67
    mEvaluator = new RChunkEvaluator( context );
68
    mRVariableProcessor = new VerbatimRVariableProcessor( IDENTITY, context );
4569
  }
4670
...
101125
        final var style = sanitize( node.getInfo() );
102126
103
        if( style.startsWith( DIAGRAM_STYLE ) ) {
104
          final var type = style.substring( DIAGRAM_STYLE_LEN );
105
          final var content = node.getContentChars().normalizeEOL();
106
          final var text = mProcessor.apply( content );
107
          final var server = mContext.getImageServer();
108
          final var source = DiagramUrlGenerator.toUrl( server, type, text );
109
          final var link = context.resolveLink( LINK, source, false );
127
        Pair<String, ResolvedLink> imagePair;
110128
111
          html.attr( "src", source );
112
          html.withAttr( link );
129
        if( style.startsWith( STYLE_DIAGRAM ) ) {
130
          imagePair = importTextDiagram( style, node, context );
131
132
          html.attr( "src", imagePair.getKey() );
133
          html.withAttr( imagePair.getValue() );
134
          html.tagVoid( "img" );
135
        }
136
        else if( style.startsWith( STYLE_R_CHUNK ) ) {
137
          imagePair = evaluateRChunk( node, context );
138
139
          html.attr( "src", imagePair.getKey() );
140
          html.withAttr( imagePair.getValue() );
113141
          html.tagVoid( "img" );
114142
        }
...
121149
122150
      return set;
151
    }
152
153
    private Pair<String, ResolvedLink> importTextDiagram(
154
      final String style,
155
      final FencedCodeBlock node,
156
      final NodeRendererContext context ) {
157
158
      final var type = style.substring( STYLE_DIAGRAM_LEN );
159
      final var content = node.getContentChars().normalizeEOL();
160
      final var text = mProcessor.apply( content );
161
      final var server = mContext.getImageServer();
162
      final var source = DiagramUrlGenerator.toUrl( server, type, text );
163
      final var link = context.resolveLink( LINK, source, false );
164
165
      return new Pair<>( source, link );
166
    }
167
168
    private Pair<String, ResolvedLink> evaluateRChunk(
169
      final FencedCodeBlock node,
170
      final NodeRendererContext context ) {
171
      final var content = node.getContentChars().normalizeEOL().trim();
172
      final var text = mRVariableProcessor.apply( content );
173
      final var hash = Integer.toHexString( text.hashCode() );
174
      final var temp = System.getProperty( "java.io.tmpdir" );
175
      final var file = format( "%s-%s.svg", APP_TITLE_LOWERCASE, hash );
176
      final var source = Paths.get( temp, file ).toString();
177
      final var link = context.resolveLink( LINK, source, false );
178
      final var r = format( "svg('%s')%n%s%ndev.off()%n", source, text );
179
      final var result = mEvaluator.apply( r );
180
181
      return new Pair<>( source, link );
123182
    }
124183
D src/main/java/com/keenwrite/processors/markdown/extensions/r/RExtension.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.processors.markdown.extensions.r;
3
4
import com.keenwrite.processors.Processor;
5
import com.keenwrite.processors.ProcessorContext;
6
import com.keenwrite.processors.markdown.BaseMarkdownProcessor;
7
import com.keenwrite.processors.r.InlineRProcessor;
8
import com.keenwrite.processors.r.RProcessor;
9
import com.vladsch.flexmark.ast.Paragraph;
10
import com.vladsch.flexmark.parser.InlineParserExtensionFactory;
11
import com.vladsch.flexmark.parser.delimiter.DelimiterProcessor;
12
import com.vladsch.flexmark.parser.internal.InlineParserImpl;
13
import com.vladsch.flexmark.parser.internal.LinkRefProcessorData;
14
import com.vladsch.flexmark.util.data.DataHolder;
15
import com.vladsch.flexmark.util.data.MutableDataHolder;
16
17
import java.util.BitSet;
18
import java.util.List;
19
import java.util.Map;
20
21
import static com.keenwrite.processors.IdentityProcessor.IDENTITY;
22
import static com.vladsch.flexmark.parser.Parser.Builder;
23
import static com.vladsch.flexmark.parser.Parser.ParserExtension;
24
25
/**
26
 * Responsible for processing inline R statements (denoted using the
27
 * {@link InlineRProcessor#PREFIX}) to prevent them from being converted to
28
 * HTML {@code <code>} elements and stop them from interfering with TeX
29
 * statements. Note that TeX statements are processed using a Markdown
30
 * extension, rather than an implementation of {@link Processor}. For this
31
 * reason, some pre-conversion is necessary.
32
 */
33
public final class RExtension implements ParserExtension {
34
  private final RProcessor mProcessor;
35
  private final BaseMarkdownProcessor mMarkdownProcessor;
36
37
  private RExtension(
38
    final RProcessor processor, final ProcessorContext context ) {
39
    mProcessor = processor;
40
    mMarkdownProcessor = new BaseMarkdownProcessor( IDENTITY, context );
41
  }
42
43
  /**
44
   * Creates an extension capable of intercepting R code blocks and preventing
45
   * them from being converted into HTML {@code <code>} elements.
46
   */
47
  public static RExtension create(
48
    final RProcessor processor, final ProcessorContext context ) {
49
    return new RExtension( processor, context );
50
  }
51
52
  @Override
53
  public void extend( final Builder builder ) {
54
    builder.customInlineParserFactory( InlineParser::new );
55
  }
56
57
  @Override
58
  public void parserOptions( final MutableDataHolder options ) {}
59
60
  /**
61
   * Prevents rendering {@code `r} statements as inline HTML {@code <code>}
62
   * blocks, which allows the {@link InlineRProcessor} to post-process the
63
   * text prior to display in the preview pane. This intervention assists
64
   * with decoupling the caret from the Markdown content so that the two
65
   * can vary independently in the architecture while permitting synchronization
66
   * of the editor and preview pane.
67
   * <p>
68
   * The text is therefore processed twice: once by flexmark-java and once by
69
   * {@link InlineRProcessor}.
70
   * </p>
71
   */
72
  private class InlineParser extends InlineParserImpl {
73
    private InlineParser(
74
      final DataHolder options,
75
      final BitSet specialCharacters,
76
      final BitSet delimiterCharacters,
77
      final Map<Character, DelimiterProcessor> delimiterProcessors,
78
      final LinkRefProcessorData referenceLinkProcessors,
79
      final List<InlineParserExtensionFactory> inlineParserExtensions ) {
80
      super(
81
        options,
82
        specialCharacters,
83
        delimiterCharacters,
84
        delimiterProcessors,
85
        referenceLinkProcessors,
86
        inlineParserExtensions
87
      );
88
      mProcessor.init();
89
    }
90
91
    /**
92
     * The superclass handles a number backtick parsing edge cases; this method
93
     * changes the behaviour to retain R code snippets, identified by
94
     * {@link InlineRProcessor#PREFIX}, so that subsequent processing can
95
     * invoke R. If other languages are added, the {@link InlineParser} will
96
     * have to be rewritten to identify more than merely R.
97
     *
98
     * @return The return value from {@link super#parseBackticks()}.
99
     * @inheritDoc
100
     */
101
    @Override
102
    protected final boolean parseBackticks() {
103
      final var foundTicks = super.parseBackticks();
104
105
      if( foundTicks && mProcessor.isReady() ) {
106
        final var blockNode = getBlock();
107
        final var codeNode = blockNode.getLastChild();
108
109
        if( codeNode != null ) {
110
          final var code = codeNode.getChars().toString();
111
112
          if( code.startsWith( InlineRProcessor.PREFIX ) ) {
113
            codeNode.unlink();
114
115
            final var rText = mProcessor.apply( code );
116
            var node = mMarkdownProcessor.toNode( rText );
117
118
            if( node.getFirstChild() instanceof Paragraph paragraph ) {
119
              node = paragraph.getFirstChild();
120
            }
121
122
            if( node != null ) {
123
              blockNode.appendChild( node );
124
            }
125
          }
126
        }
127
      }
128
129
      return foundTicks;
130
    }
131
  }
132
}
1331
A src/main/java/com/keenwrite/processors/markdown/extensions/r/RInlineExtension.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.processors.markdown.extensions.r;
3
4
import com.keenwrite.processors.Processor;
5
import com.keenwrite.processors.ProcessorContext;
6
import com.keenwrite.processors.markdown.BaseMarkdownProcessor;
7
import com.keenwrite.processors.r.RBootstrapController;
8
import com.keenwrite.processors.r.RInlineEvaluator;
9
import com.vladsch.flexmark.ast.Paragraph;
10
import com.vladsch.flexmark.parser.InlineParserExtensionFactory;
11
import com.vladsch.flexmark.parser.delimiter.DelimiterProcessor;
12
import com.vladsch.flexmark.parser.internal.InlineParserImpl;
13
import com.vladsch.flexmark.parser.internal.LinkRefProcessorData;
14
import com.vladsch.flexmark.util.data.DataHolder;
15
import com.vladsch.flexmark.util.data.MutableDataHolder;
16
17
import java.util.BitSet;
18
import java.util.List;
19
import java.util.Map;
20
21
import static com.keenwrite.processors.IdentityProcessor.IDENTITY;
22
import static com.vladsch.flexmark.parser.Parser.Builder;
23
import static com.vladsch.flexmark.parser.Parser.ParserExtension;
24
25
/**
26
 * Responsible for processing inline R statements (denoted using the
27
 * {@link RInlineEvaluator#PREFIX}) to prevent them from being converted to
28
 * HTML {@code <code>} elements and stop them from interfering with TeX
29
 * statements. Note that TeX statements are processed using a Markdown
30
 * extension, rather than an implementation of {@link Processor}. For this
31
 * reason, some pre-conversion is necessary.
32
 */
33
public final class RInlineExtension implements ParserExtension {
34
  private final RInlineEvaluator mEvaluator;
35
  private final BaseMarkdownProcessor mMarkdownProcessor;
36
  private final ProcessorContext mContext;
37
38
  private RInlineExtension( final ProcessorContext context ) {
39
    mContext = context;
40
    mEvaluator = new RInlineEvaluator( context );
41
    mMarkdownProcessor = new BaseMarkdownProcessor( IDENTITY, context );
42
  }
43
44
  /**
45
   * Creates an extension capable of intercepting R code blocks and preventing
46
   * them from being converted into HTML {@code <code>} elements.
47
   */
48
  public static RInlineExtension create( final ProcessorContext context ) {
49
    return new RInlineExtension( context );
50
  }
51
52
  @Override
53
  public void extend( final Builder builder ) {
54
    builder.customInlineParserFactory( InlineParser::new );
55
  }
56
57
  @Override
58
  public void parserOptions( final MutableDataHolder options ) {}
59
60
  /**
61
   * Prevents rendering {@code `r} statements as inline HTML {@code <code>}
62
   * blocks, which allows the {@link RInlineEvaluator} to post-process the
63
   * text prior to display in the preview pane. This intervention assists
64
   * with decoupling the caret from the Markdown content so that the two
65
   * can vary independently in the architecture while permitting synchronization
66
   * of the editor and preview pane.
67
   * <p>
68
   * The text is therefore processed twice: once by flexmark-java and once by
69
   * {@link RInlineEvaluator}.
70
   * </p>
71
   */
72
  private final class InlineParser extends InlineParserImpl {
73
    private InlineParser(
74
      final DataHolder options,
75
      final BitSet specialCharacters,
76
      final BitSet delimiterCharacters,
77
      final Map<Character, DelimiterProcessor> delimiterProcessors,
78
      final LinkRefProcessorData referenceLinkProcessors,
79
      final List<InlineParserExtensionFactory> inlineParserExtensions ) {
80
      super(
81
        options,
82
        specialCharacters,
83
        delimiterCharacters,
84
        delimiterProcessors,
85
        referenceLinkProcessors,
86
        inlineParserExtensions
87
      );
88
    }
89
90
    /**
91
     * The superclass handles a number backtick parsing edge cases; this method
92
     * changes the behaviour to retain R code snippets, identified by
93
     * {@link RInlineEvaluator#PREFIX}, so that subsequent processing can
94
     * invoke R. If other languages are added, the {@link InlineParser} will
95
     * have to be rewritten to identify more than merely R.
96
     *
97
     * @return The return value from {@link super#parseBackticks()}.
98
     * @inheritDoc
99
     */
100
    @Override
101
    protected boolean parseBackticks() {
102
      final var foundTicks = super.parseBackticks();
103
104
      if( foundTicks ) {
105
        final var blockNode = getBlock();
106
        final var codeNode = blockNode.getLastChild();
107
108
        if( codeNode != null ) {
109
          final var code = codeNode.getChars().toString();
110
111
          if( mEvaluator.test( code ) ) {
112
            codeNode.unlink();
113
114
            RBootstrapController.init( mContext );
115
116
            final var rText = mEvaluator.apply( code );
117
            var node = mMarkdownProcessor.toNode( rText );
118
119
            if( node.getFirstChild() instanceof Paragraph paragraph ) {
120
              node = paragraph.getFirstChild();
121
            }
122
123
            if( node != null ) {
124
              blockNode.appendChild( node );
125
            }
126
          }
127
        }
128
      }
129
130
      return foundTicks;
131
    }
132
  }
133
}
1134
M src/main/java/com/keenwrite/processors/markdown/extensions/tex/TexNodeRenderer.java
3232
      HTML_TEX_DELIMITED, new TexDelimNodeRenderer(),
3333
      XHTML_TEX, new TexElementNodeRenderer( true ),
34
      MARKDOWN_PLAIN, new TexDelimNodeRenderer(),
3534
      NONE, RENDERER
3635
    );
D src/main/java/com/keenwrite/processors/r/InlineRProcessor.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.processors.r;
3
4
import com.keenwrite.processors.Processor;
5
import com.keenwrite.processors.ProcessorContext;
6
import com.keenwrite.processors.VariableProcessor;
7
import com.keenwrite.sigils.RKeyOperator;
8
import org.jetbrains.annotations.NotNull;
9
10
import java.util.HashMap;
11
import java.util.concurrent.atomic.AtomicBoolean;
12
13
import static com.keenwrite.constants.Constants.STATUS_PARSE_ERROR;
14
import static com.keenwrite.events.StatusEvent.clue;
15
import static com.keenwrite.processors.r.RVariableProcessor.escape;
16
import static com.keenwrite.processors.text.TextReplacementFactory.replace;
17
import static java.lang.Math.min;
18
19
/**
20
 * Transforms a document containing R statements into Markdown.
21
 */
22
public final class InlineRProcessor extends VariableProcessor {
23
  public static final String PREFIX = "`r#";
24
  public static final char SUFFIX = '`';
25
26
  private static final int PREFIX_LENGTH = PREFIX.length();
27
28
  /**
29
   * Set to {@code true} when the R bootstrap script is loaded successfully.
30
   */
31
  private final AtomicBoolean mReady = new AtomicBoolean();
32
33
  private final RKeyOperator mOperator = new RKeyOperator();
34
35
  /**
36
   * Constructs a processor capable of evaluating R statements.
37
   *
38
   * @param successor Subsequent link in the processing chain.
39
   * @param context   Contains resolved definitions map.
40
   */
41
  public InlineRProcessor(
42
    final Processor<String> successor,
43
    final ProcessorContext context ) {
44
    super( successor, context );
45
  }
46
47
  /**
48
   * Initializes the R code so that R can find imported libraries. Note that
49
   * any existing R functionality will not be overwritten if this method is
50
   * called multiple times.
51
   * <p>
52
   * If the R code to bootstrap contained variables, and they were all updated
53
   * successfully, this will update the internal ready flag to {@code true}.
54
   */
55
  public void init() {
56
    final var context = getContext();
57
    final var bootstrap = context.getRScript();
58
59
    if( !bootstrap.isBlank() ) {
60
      final var wd = context.getRWorkingDir();
61
      final var dir = wd.toString().replace( '\\', '/' );
62
      final var definitions = getContext().getDefinitions();
63
      final var map = new HashMap<String, String>( definitions.size() + 1 );
64
65
      definitions.forEach(
66
        ( k, v ) -> map.put( mOperator.apply( k ), escape( v ) )
67
      );
68
      map.put(
69
        mOperator.apply( "application.r.working.directory" ),
70
        escape( dir )
71
      );
72
73
      try {
74
        Engine.eval( replace( bootstrap, map ) );
75
        mReady.set( true );
76
      } catch( final Exception ex ) {
77
        clue( ex );
78
        // A problem with the bootstrap script is likely caused by variables
79
        // not being loaded. This implies that the R processor is being invoked
80
        // too soon.
81
      }
82
    }
83
  }
84
85
  /**
86
   * Answers whether R has been initialized without failures.
87
   *
88
   * @return {@code true} the R engine is ready to process inline R statements.
89
   */
90
  public boolean isReady() {
91
    return mReady.get();
92
  }
93
94
  /**
95
   * Evaluates all R statements in the source document and inserts the
96
   * calculated value into the generated document.
97
   *
98
   * @param text The document text that includes variables that should be
99
   *             replaced with values when rendered as HTML.
100
   * @return The generated document with output from all R statements
101
   * substituted with value returned from their execution.
102
   */
103
  @Override
104
  public @NotNull String apply( final String text ) {
105
    final int length = text.length();
106
107
    // The * 2 is a wild guess at the ratio of R statements to the length
108
    // of text produced by those statements.
109
    final StringBuilder sb = new StringBuilder( length * 2 );
110
111
    int prevIndex = 0;
112
    int currIndex = text.indexOf( PREFIX );
113
114
    while( currIndex >= 0 ) {
115
      // Copy everything up to, but not including, the opening token.
116
      sb.append( text, prevIndex, currIndex );
117
118
      // Jump to the start of the R statement.
119
      prevIndex = currIndex + PREFIX_LENGTH;
120
121
      // Find the closing token, without indexing past the text boundary.
122
      currIndex = text.indexOf( SUFFIX, min( currIndex + 1, length ) );
123
124
      // Only evaluate inline R statements that have end delimiters.
125
      if( currIndex > 1 ) {
126
        // Extract the inline R statement to be evaluated.
127
        final var r = text.substring( prevIndex, currIndex );
128
129
        // Pass the R statement into the R engine for evaluation.
130
        try {
131
          // Append the string representation of the result into the text.
132
          sb.append( Engine.eval( r ) );
133
        } catch( final Exception ex ) {
134
          // Inform the user that there was a problem.
135
          clue( STATUS_PARSE_ERROR, ex.getMessage(), currIndex );
136
137
          // If the string couldn't be parsed using R, append the statement
138
          // that failed to parse, instead of its evaluated value.
139
          sb.append( PREFIX ).append( r ).append( SUFFIX );
140
        }
141
142
        // Retain the R statement's ending position in the text.
143
        prevIndex = currIndex + 1;
144
      }
145
146
      // Find the start of the next inline R statement.
147
      currIndex = text.indexOf( PREFIX, min( currIndex + 1, length ) );
148
    }
149
150
    // Copy from the previous index to the end of the string.
151
    return sb.append( text.substring( min( prevIndex, length ) ) ).toString();
152
  }
153
}
1541
A src/main/java/com/keenwrite/processors/r/RBootstrapController.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.processors.r;
3
4
import com.keenwrite.processors.ProcessorContext;
5
import com.keenwrite.sigils.RKeyOperator;
6
7
import java.util.HashMap;
8
9
import static com.keenwrite.events.StatusEvent.clue;
10
import static com.keenwrite.processors.r.RVariableProcessor.escape;
11
import static com.keenwrite.processors.text.TextReplacementFactory.replace;
12
13
/**
14
 * Transforms a document containing R statements into Markdown.
15
 */
16
public final class RBootstrapController {
17
18
  private final static RKeyOperator KEY_OPERATOR = new RKeyOperator();
19
20
  private RBootstrapController() {}
21
22
  /**
23
   * Initializes the R code so that R can find imported libraries. Note that
24
   * any existing R functionality will not be overwritten if this method is
25
   * called multiple times.
26
   * <p>
27
   * If the R code to bootstrap contained variables, and they were all updated
28
   * successfully, this will update the internal ready flag to {@code true}.
29
   */
30
  public static void init( final ProcessorContext context ) {
31
    final var bootstrap = context.getRScript();
32
33
    if( !bootstrap.isBlank() ) {
34
      final var wd = context.getRWorkingDir();
35
      final var dir = wd.toString().replace( '\\', '/' );
36
      final var definitions = context.getDefinitions();
37
      final var map = new HashMap<String, String>( definitions.size() + 1 );
38
39
      definitions.forEach(
40
        ( k, v ) -> map.put( KEY_OPERATOR.apply( k ), escape( v ) )
41
      );
42
      map.put(
43
        KEY_OPERATOR.apply( "application.r.working.directory" ),
44
        escape( dir )
45
      );
46
47
      try {
48
        Engine.eval( replace( bootstrap, map ) );
49
      } catch( final Exception ex ) {
50
        clue( ex );
51
        // A problem with the bootstrap script is likely caused by variables
52
        // not being loaded. This implies that the R processor is being invoked
53
        // too soon.
54
      }
55
    }
56
  }
57
}
158
A src/main/java/com/keenwrite/processors/r/RChunkEvaluator.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.processors.r;
3
4
import com.keenwrite.processors.ProcessorContext;
5
6
import java.util.function.Function;
7
8
import static com.keenwrite.constants.Constants.STATUS_PARSE_ERROR;
9
import static com.keenwrite.events.StatusEvent.clue;
10
11
/**
12
 * Transforms a document containing R statements into Markdown. The statements
13
 * are part of an R chunk, <code>```{r}</code>.
14
 */
15
public final class RChunkEvaluator implements Function<String, String> {
16
17
  private final ProcessorContext mContext;
18
19
  /**
20
   * Constructs an evaluator capable of executing R statements.
21
   *
22
   * @param context Used to initialize the {@link RBootstrapController}.
23
   */
24
  public RChunkEvaluator( final ProcessorContext context ) {
25
    mContext = context;
26
  }
27
28
  /**
29
   * Evaluates the given R statements and returns the result as a string.
30
   * If an image was produced, the calling code is responsible for persisting
31
   * and making the file embeddable into the document.
32
   *
33
   * @param r The R statements to evaluate.
34
   * @return The output from the final R statement.
35
   */
36
  @Override
37
  public String apply( final String r ) {
38
    try {
39
      RBootstrapController.init( mContext );
40
      return Engine.eval( r );
41
    } catch( final Exception ex ) {
42
      clue( STATUS_PARSE_ERROR, ex.getMessage() );
43
44
      return r;
45
    }
46
  }
47
}
148
A src/main/java/com/keenwrite/processors/r/RInlineEvaluator.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.processors.r;
3
4
import com.keenwrite.processors.Processor;
5
import com.keenwrite.processors.ProcessorContext;
6
7
import java.util.function.Function;
8
import java.util.function.Predicate;
9
10
import static com.keenwrite.constants.Constants.STATUS_PARSE_ERROR;
11
import static com.keenwrite.events.StatusEvent.clue;
12
import static com.keenwrite.processors.IdentityProcessor.IDENTITY;
13
14
/**
15
 * Evaluates inline R statements.
16
 */
17
public final class RInlineEvaluator
18
  implements Function<String, String>, Predicate<String> {
19
  public static final String PREFIX = "`r#";
20
  public static final String SUFFIX = "`";
21
22
  private static final int PREFIX_LENGTH = PREFIX.length();
23
  private static final int SUFFIX_LENGTH = SUFFIX.length();
24
25
  private final Processor<String> mProcessor;
26
27
  /**
28
   * Constructs an evaluator capable of executing R statements.
29
   */
30
  public RInlineEvaluator( final ProcessorContext context ) {
31
    mProcessor = new RVariableProcessor( IDENTITY, context );
32
  }
33
34
  /**
35
   * Evaluates all R statements in the source document and inserts the
36
   * calculated value into the generated document.
37
   *
38
   * @param text The document text that includes variables that should be
39
   *             replaced with values when rendered as HTML.
40
   * @return The generated document with output from all R statements
41
   * substituted with value returned from their execution.
42
   */
43
  @Override
44
  public String apply( final String text ) {
45
    try {
46
      final var len = text.length();
47
      final var r = mProcessor.apply(
48
        text.substring( PREFIX_LENGTH, len - SUFFIX_LENGTH )
49
      );
50
51
      // Return the evaluated R expression for insertion back into the text.
52
      return Engine.eval( r );
53
    } catch( final Exception ex ) {
54
      clue( STATUS_PARSE_ERROR, ex.getMessage() );
55
56
      // If the string couldn't be parsed using R, append the statement
57
      // that failed to parse, instead of its evaluated value.
58
      return text;
59
    }
60
  }
61
62
  /**
63
   * Answers whether the engine associated with this evaluator may attempt to
64
   * evaluate the given source code statement.
65
   *
66
   * @param code The source code to verify.
67
   * @return {@code true} if the code may be evaluated.
68
   */
69
  @Override
70
  public boolean test( final String code ) {
71
    return code.startsWith( PREFIX );
72
  }
73
}
174
D src/main/java/com/keenwrite/processors/r/RProcessor.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.processors.r;
3
4
import com.keenwrite.processors.ExecutorProcessor;
5
import com.keenwrite.processors.Processor;
6
import com.keenwrite.processors.ProcessorContext;
7
8
import java.util.function.Function;
9
10
import static com.keenwrite.processors.IdentityProcessor.IDENTITY;
11
12
/**
13
 * Responsible for processing R statements within a text block.
14
 */
15
public final class RProcessor
16
  extends ExecutorProcessor<String> implements Function<String, String> {
17
  private final Processor<String> mProcessor;
18
  private final InlineRProcessor mInlineRProcessor;
19
20
  public RProcessor( final ProcessorContext context ) {
21
    final var irp = new InlineRProcessor( IDENTITY, context );
22
    final var rvp = new RVariableProcessor( irp, context );
23
24
    mProcessor = new ExecutorProcessor<>( rvp );
25
    mInlineRProcessor = irp;
26
  }
27
28
  public String apply( final String text ) {
29
    if( !mInlineRProcessor.isReady() ) {
30
      mInlineRProcessor.init();
31
    }
32
33
    return mProcessor.apply( text );
34
  }
35
36
  /**
37
   * Called when the {@link InlineRProcessor} is instantiated, which triggers
38
   * a re-evaluation of all R expressions in the document. Without this, when
39
   * the document is first viewed, no R expressions are evaluated until the
40
   * user interacts with the document.
41
   */
42
  public void init() {
43
    mInlineRProcessor.init();
44
  }
45
46
  public boolean isReady() {
47
    return mInlineRProcessor.isReady();
48
  }
49
}
501
M src/main/java/com/keenwrite/processors/r/RVariableProcessor.java
22
package com.keenwrite.processors.r;
33
4
import com.keenwrite.processors.Processor;
45
import com.keenwrite.processors.ProcessorContext;
56
import com.keenwrite.processors.VariableProcessor;
...
1314
 * <pre>v$tree$leaf</pre>.
1415
 */
15
public final class RVariableProcessor extends VariableProcessor {
16
public class RVariableProcessor extends VariableProcessor {
1617
  public RVariableProcessor(
17
    final InlineRProcessor irp, final ProcessorContext context ) {
18
    super( irp, context );
18
    final Processor<String> successor, final ProcessorContext context ) {
19
    super( successor, context );
1920
  }
2021
M src/main/java/com/keenwrite/ui/actions/ApplicationBars.java
6161
          addAction( "file.export.html_svg", e -> actions.file_export_html_svg() ),
6262
          addAction( "file.export.html_tex", e -> actions.file_export_html_tex() ),
63
          addAction( "file.export.xhtml_tex", e -> actions.file_export_xhtml_tex() ),
64
          addAction( "file.export.markdown", e -> actions.file_export_markdown() )
63
          addAction( "file.export.xhtml_tex", e -> actions.file_export_xhtml_tex() )
6564
        ),
6665
      SEPARATOR_ACTION,
M src/main/java/com/keenwrite/ui/actions/GuiCommands.java
270270
  }
271271
272
  public void file_export_markdown() {
273
    file_export( MARKDOWN_PLAIN );
274
  }
275
276272
  private void fireExportFailedEvent() {
277273
    runLater( ExportFailedEvent::fire );
A src/main/java/com/keenwrite/util/Pair.java
1
package com.keenwrite.util;
2
3
import java.util.AbstractMap;
4
import java.util.Map;
5
6
/**
7
 * Convenience class for pairing two objects together; this is a synonym for
8
 * {@link Map.Entry}.
9
 *
10
 * @param <K> The type of key to store in this pair.
11
 * @param <V> The type of value to store in this pair.
12
 */
13
public class Pair<K, V> extends AbstractMap.SimpleImmutableEntry<K, V> {
14
  /**
15
   * Associates a new key-value pair.
16
   *
17
   * @param key   The key for this key-value pairing.
18
   * @param value The value for this key-value pairing.
19
   */
20
  public Pair( final K key, final V value ) {
21
    super( key, value );
22
  }
23
}
124
M src/main/resources/com/keenwrite/messages.properties
148148
Main.status.error.bootstrap.eval=Note: Bootstrap variable of ''{0}'' not found
149149
150
Main.status.error.parse={0} (near ${Main.status.text.offset} {1})
150
Main.status.error.parse=Evaluation error: {0}
151151
Main.status.error.def.blank=Move the caret to a word before inserting a variable
152152
Main.status.error.def.empty=Create a variable before inserting one
D src/main/resources/fonts/emoji/OpenSansEmoji-Regular.otf
Binary file
A src/main/resources/fonts/emoji/OpenSansEmoji-Regular.ttf
Binary file
A src/test/java/com/keenwrite/richtext/StyleClassedTextAreaTest.java
1
package com.keenwrite.richtext;
2
3
import javafx.application.Application;
4
import javafx.scene.Scene;
5
import javafx.scene.layout.StackPane;
6
import javafx.stage.Stage;
7
8
/**
9
 * Scaffolding for creating one-off tests, not run as part of test suite.
10
 */
11
public class StyleClassedTextAreaTest extends Application {
12
  private final org.fxmisc.richtext.StyleClassedTextArea mTextArea =
13
    new org.fxmisc.richtext.StyleClassedTextArea( false );
14
15
  public static void main( final String[] args ) {
16
    launch( args );
17
  }
18
19
  @Override
20
  public void start( final Stage stage ) {
21
    final var pane = new StackPane( mTextArea );
22
    final var scene = new Scene( pane, 600, 400 );
23
24
    stage.setScene( scene );
25
    stage.show();
26
  }
27
}
128