Dave Jarvis' Repositories

git clone https://repo.autonoma.ca/repo/keenwrite.git
M src/main/java/com/keenwrite/Bootstrap.java
2222
public final class Bootstrap {
2323
  /**
24
   * Order matters, this must be populated before deriving the app title.
24
   * Must be populated before deriving the app title (order matters).
2525
   */
2626
  private static final Properties sP = new Properties();
M src/main/java/com/keenwrite/MainPane.java
1313
import com.keenwrite.events.*;
1414
import com.keenwrite.io.MediaType;
15
import com.keenwrite.preferences.Key;
16
import com.keenwrite.preferences.Workspace;
17
import com.keenwrite.preview.HtmlPreview;
18
import com.keenwrite.processors.HtmlPreviewProcessor;
19
import com.keenwrite.processors.Processor;
20
import com.keenwrite.processors.ProcessorContext;
21
import com.keenwrite.processors.ProcessorFactory;
22
import com.keenwrite.processors.r.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;
1107
        operator = new RKeyOperator();
1108
      }
1109
      case TEXT_PROPERTIES -> {
1110
        began = PropertyKeyOperator.BEGAN;
1111
        ended = PropertyKeyOperator.ENDED;
1112
        operator = s -> s;
1113
      }
1114
      default -> {
1115
        began = "";
1116
        ended = "";
1117
        operator = s -> s;
1118
      }
1119
    }
1120
1121
    return s -> began + operator.apply( s ) + ended;
1122
  }
1123
1124
  private String getString( final Key key ) {
1125
    assert key != null;
1126
    return getWorkspace().getString( key );
15
import com.keenwrite.preferences.Workspace;
16
import com.keenwrite.preview.HtmlPreview;
17
import com.keenwrite.processors.HtmlPreviewProcessor;
18
import com.keenwrite.processors.Processor;
19
import com.keenwrite.processors.ProcessorContext;
20
import com.keenwrite.processors.ProcessorFactory;
21
import com.keenwrite.processors.r.Engine;
22
import com.keenwrite.processors.r.RBootstrapController;
23
import com.keenwrite.service.events.Notifier;
24
import com.keenwrite.ui.explorer.FilePickerFactory;
25
import com.keenwrite.ui.heuristics.DocumentStatistics;
26
import com.keenwrite.ui.outline.DocumentOutline;
27
import com.keenwrite.util.GenericBuilder;
28
import com.panemu.tiwulfx.control.dock.DetachableTab;
29
import com.panemu.tiwulfx.control.dock.DetachableTabPane;
30
import javafx.application.Platform;
31
import javafx.beans.property.*;
32
import javafx.collections.ListChangeListener;
33
import javafx.concurrent.Task;
34
import javafx.event.ActionEvent;
35
import javafx.event.Event;
36
import javafx.event.EventHandler;
37
import javafx.scene.Node;
38
import javafx.scene.Scene;
39
import javafx.scene.control.*;
40
import javafx.scene.control.TreeItem.TreeModificationEvent;
41
import javafx.scene.input.KeyEvent;
42
import javafx.scene.layout.FlowPane;
43
import javafx.stage.Stage;
44
import javafx.stage.Window;
45
import org.greenrobot.eventbus.Subscribe;
46
47
import java.io.File;
48
import java.io.FileNotFoundException;
49
import java.nio.file.Path;
50
import java.util.*;
51
import java.util.concurrent.ExecutorService;
52
import java.util.concurrent.ScheduledExecutorService;
53
import java.util.concurrent.ScheduledFuture;
54
import java.util.concurrent.atomic.AtomicBoolean;
55
import java.util.concurrent.atomic.AtomicReference;
56
import java.util.function.Function;
57
import java.util.stream.Collectors;
58
59
import static com.keenwrite.ExportFormat.NONE;
60
import static com.keenwrite.Launcher.terminate;
61
import static com.keenwrite.Messages.get;
62
import static com.keenwrite.constants.Constants.*;
63
import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG_NODE;
64
import static com.keenwrite.events.Bus.register;
65
import static com.keenwrite.events.StatusEvent.clue;
66
import static com.keenwrite.io.MediaType.*;
67
import static com.keenwrite.preferences.AppKeys.*;
68
import static com.keenwrite.processors.IdentityProcessor.IDENTITY;
69
import static com.keenwrite.processors.ProcessorContext.Mutator;
70
import static com.keenwrite.processors.ProcessorContext.builder;
71
import static com.keenwrite.processors.ProcessorFactory.createProcessors;
72
import static java.lang.String.format;
73
import static java.lang.System.getProperty;
74
import static java.util.concurrent.Executors.newFixedThreadPool;
75
import static java.util.concurrent.Executors.newScheduledThreadPool;
76
import static java.util.concurrent.TimeUnit.SECONDS;
77
import static java.util.stream.Collectors.groupingBy;
78
import static javafx.application.Platform.runLater;
79
import static javafx.scene.control.Alert.AlertType.ERROR;
80
import static javafx.scene.control.ButtonType.*;
81
import static javafx.scene.control.TabPane.TabClosingPolicy.ALL_TABS;
82
import static javafx.scene.input.KeyCode.SPACE;
83
import static javafx.scene.input.KeyCombination.CONTROL_DOWN;
84
import static javafx.util.Duration.millis;
85
import static javax.swing.SwingUtilities.invokeLater;
86
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
87
88
/**
89
 * Responsible for wiring together the main application components for a
90
 * particular {@link Workspace} (project). These include the definition views,
91
 * text editors, and preview pane along with any corresponding controllers.
92
 */
93
public final class MainPane extends SplitPane {
94
95
  private static final ExecutorService sExecutor = newFixedThreadPool( 1 );
96
  private static final Notifier sNotifier = Services.load( Notifier.class );
97
98
  /**
99
   * Used when opening files to determine how each file should be binned and
100
   * therefore what tab pane to be opened within.
101
   */
102
  private static final Set<MediaType> PLAIN_TEXT_FORMAT = Set.of(
103
    TEXT_MARKDOWN, TEXT_R_MARKDOWN, UNDEFINED
104
  );
105
106
  private final ScheduledExecutorService mSaver = newScheduledThreadPool( 1 );
107
  private final AtomicReference<ScheduledFuture<?>> mSaveTask =
108
    new AtomicReference<>();
109
110
  /**
111
   * Prevents re-instantiation of processing classes.
112
   */
113
  private final Map<TextResource, Processor<String>> mProcessors =
114
    new HashMap<>();
115
116
  private final Workspace mWorkspace;
117
118
  /**
119
   * Groups similar file type tabs together.
120
   */
121
  private final List<TabPane> mTabPanes = new ArrayList<>();
122
123
  /**
124
   * Renders the actively selected plain text editor tab.
125
   */
126
  private final HtmlPreview mPreview;
127
128
  /**
129
   * Provides an interactive document outline.
130
   */
131
  private final DocumentOutline mOutline = new DocumentOutline();
132
133
  /**
134
   * Changing the active editor fires the value changed event. This allows
135
   * refreshes to happen when external definitions are modified and need to
136
   * trigger the processing chain.
137
   */
138
  private final ObjectProperty<TextEditor> mTextEditor =
139
    createActiveTextEditor();
140
141
  /**
142
   * Changing the active definition editor fires the value changed event. This
143
   * allows refreshes to happen when external definitions are modified and need
144
   * to trigger the processing chain.
145
   */
146
  private final ObjectProperty<TextDefinition> mDefinitionEditor;
147
148
  /**
149
   * Called when the definition data is changed.
150
   */
151
  private final EventHandler<TreeModificationEvent<Event>> mTreeHandler =
152
    event -> {
153
      process( getTextEditor() );
154
      save( getTextDefinition() );
155
    };
156
157
  /**
158
   * Tracks the number of detached tab panels opened into their own windows,
159
   * which allows unique identification of subordinate windows by their title.
160
   * It is doubtful more than 128 windows, much less 256, will be created.
161
   */
162
  private byte mWindowCount;
163
164
  private final VariableNameInjector mVariableNameInjector;
165
166
  private final RBootstrapController mRBootstrapController;
167
168
  private final DocumentStatistics mStatistics;
169
170
  /**
171
   * Adds all content panels to the main user interface. This will load the
172
   * configuration settings from the workspace to reproduce the settings from
173
   * a previous session.
174
   */
175
  public MainPane( final Workspace workspace ) {
176
    mWorkspace = workspace;
177
    mPreview = new HtmlPreview( workspace );
178
    mStatistics = new DocumentStatistics( workspace );
179
    mTextEditor.set( new MarkdownEditor( workspace ) );
180
    mDefinitionEditor = createActiveDefinitionEditor( mTextEditor );
181
    mVariableNameInjector = new VariableNameInjector( mWorkspace );
182
    mRBootstrapController = new RBootstrapController(
183
      mWorkspace, this::getDefinitions );
184
185
    open( collect( getRecentFiles() ) );
186
    viewPreview();
187
    setDividerPositions( calculateDividerPositions() );
188
189
    // Once the main scene's window regains focus, update the active definition
190
    // editor to the currently selected tab.
191
    runLater( () -> getWindow().setOnCloseRequest( event -> {
192
      // Order matters: Open file names must be persisted before closing all.
193
      mWorkspace.save();
194
195
      if( closeAll() ) {
196
        Platform.exit();
197
        terminate( 0 );
198
      }
199
200
      event.consume();
201
    } ) );
202
203
    register( this );
204
    initAutosave( workspace );
205
  }
206
207
  @Subscribe
208
  public void handle( final TextEditorFocusEvent event ) {
209
    mTextEditor.set( event.get() );
210
  }
211
212
  @Subscribe
213
  public void handle( final TextDefinitionFocusEvent event ) {
214
    mDefinitionEditor.set( event.get() );
215
  }
216
217
  /**
218
   * Typically called when a file name is clicked in the preview panel.
219
   *
220
   * @param event The event to process, must contain a valid file reference.
221
   */
222
  @Subscribe
223
  public void handle( final FileOpenEvent event ) {
224
    final File eventFile;
225
    final var eventUri = event.getUri();
226
227
    if( eventUri.isAbsolute() ) {
228
      eventFile = new File( eventUri.getPath() );
229
    }
230
    else {
231
      final var activeFile = getTextEditor().getFile();
232
      final var parent = activeFile.getParentFile();
233
234
      if( parent == null ) {
235
        clue( new FileNotFoundException( eventUri.getPath() ) );
236
        return;
237
      }
238
      else {
239
        final var parentPath = parent.getAbsolutePath();
240
        eventFile = Path.of( parentPath, eventUri.getPath() ).toFile();
241
      }
242
    }
243
244
    runLater( () -> open( eventFile ) );
245
  }
246
247
  @Subscribe
248
  public void handle( final CaretNavigationEvent event ) {
249
    runLater( () -> {
250
      final var textArea = getTextEditor().getTextArea();
251
      textArea.moveTo( event.getOffset() );
252
      textArea.requestFollowCaret();
253
      textArea.requestFocus();
254
    } );
255
  }
256
257
  @Subscribe
258
  @SuppressWarnings( "unused" )
259
  public void handle( final ExportFailedEvent event ) {
260
    final var os = getProperty( "os.name" );
261
    final var arch = getProperty( "os.arch" ).toLowerCase();
262
    final var bits = getProperty( "sun.arch.data.model" );
263
264
    final var title = Messages.get( "Alert.typesetter.missing.title" );
265
    final var header = Messages.get( "Alert.typesetter.missing.header" );
266
    final var version = Messages.get(
267
      "Alert.typesetter.missing.version",
268
      os,
269
      arch
270
        .replaceAll( "amd.*|i.*|x86.*", "X86" )
271
        .replaceAll( "mips.*", "MIPS" )
272
        .replaceAll( "armv.*", "ARM" ),
273
      bits );
274
    final var text = Messages.get( "Alert.typesetter.missing.installer.text" );
275
276
    // Download and install ConTeXt for {0} {1} {2}-bit
277
    final var content = format( "%s %s", text, version );
278
    final var flowPane = new FlowPane();
279
    final var link = new Hyperlink( text );
280
    final var label = new Label( version );
281
    flowPane.getChildren().addAll( link, label );
282
283
    final var alert = new Alert( ERROR, content, OK );
284
    alert.setTitle( title );
285
    alert.setHeaderText( header );
286
    alert.getDialogPane().contentProperty().set( flowPane );
287
    alert.setGraphic( ICON_DIALOG_NODE );
288
289
    link.setOnAction( ( e ) -> {
290
      alert.close();
291
      final var url = Messages.get( "Alert.typesetter.missing.installer.url" );
292
      runLater( () -> HyperlinkOpenEvent.fire( url ) );
293
    } );
294
295
    alert.showAndWait();
296
  }
297
298
  private void initAutosave( final Workspace workspace ) {
299
    final var rate = workspace.integerProperty( KEY_EDITOR_AUTOSAVE );
300
301
    rate.addListener(
302
      ( c, o, n ) -> {
303
        final var taskRef = mSaveTask.get();
304
305
        // Prevent multiple autosaves from running.
306
        if( taskRef != null ) {
307
          taskRef.cancel( false );
308
        }
309
310
        initAutosave( rate );
311
      }
312
    );
313
314
    // Start the save listener (avoids duplicating some code).
315
    initAutosave( rate );
316
  }
317
318
  private void initAutosave( final IntegerProperty rate ) {
319
    mSaveTask.set(
320
      mSaver.scheduleAtFixedRate(
321
        () -> {
322
          if( getTextEditor().isModified() ) {
323
            // Ensure the modified indicator is cleared by running on EDT.
324
            runLater( this::save );
325
          }
326
        }, 0, rate.intValue(), SECONDS
327
      )
328
    );
329
  }
330
331
  /**
332
   * TODO: Load divider positions from exported settings, see
333
   *   {@link #collect(SetProperty)} comment.
334
   */
335
  private double[] calculateDividerPositions() {
336
    final var ratio = 100f / getItems().size() / 100;
337
    final var positions = getDividerPositions();
338
339
    for( int i = 0; i < positions.length; i++ ) {
340
      positions[ i ] = ratio * i;
341
    }
342
343
    return positions;
344
  }
345
346
  /**
347
   * Opens all the files into the application, provided the paths are unique.
348
   * This may only be called for any type of files that a user can edit
349
   * (i.e., update and persist), such as definitions and text files.
350
   *
351
   * @param files The list of files to open.
352
   */
353
  public void open( final List<File> files ) {
354
    files.forEach( this::open );
355
  }
356
357
  /**
358
   * This opens the given file. Since the preview pane is not a file that
359
   * can be opened, it is safe to add a listener to the detachable pane.
360
   * This will exit early if the given file is not a regular file (i.e., a
361
   * directory).
362
   *
363
   * @param inputFile The file to open.
364
   */
365
  private void open( final File inputFile ) {
366
    // Prevent opening directories (a non-existent "untitled.md" is fine).
367
    if( !inputFile.isFile() && inputFile.exists() ) {
368
      return;
369
    }
370
371
    final var tab = createTab( inputFile );
372
    final var node = tab.getContent();
373
    final var mediaType = MediaType.valueFrom( inputFile );
374
    final var tabPane = obtainTabPane( mediaType );
375
376
    tab.setTooltip( createTooltip( inputFile ) );
377
    tabPane.setFocusTraversable( false );
378
    tabPane.setTabClosingPolicy( ALL_TABS );
379
    tabPane.getTabs().add( tab );
380
381
    // Attach the tab scene factory for new tab panes.
382
    if( !getItems().contains( tabPane ) ) {
383
      addTabPane(
384
        node instanceof TextDefinition ? 0 : getItems().size(), tabPane
385
      );
386
    }
387
388
    if( inputFile.isFile() ) {
389
      getRecentFiles().add( inputFile.getAbsolutePath() );
390
    }
391
  }
392
393
  /**
394
   * Opens a new text editor document using the default document file name.
395
   */
396
  public void newTextEditor() {
397
    open( DOCUMENT_DEFAULT );
398
  }
399
400
  /**
401
   * Opens a new definition editor document using the default definition
402
   * file name.
403
   */
404
  public void newDefinitionEditor() {
405
    open( DEFINITION_DEFAULT );
406
  }
407
408
  /**
409
   * Iterates over all tab panes to find all {@link TextEditor}s and request
410
   * that they save themselves.
411
   */
412
  public void saveAll() {
413
    mTabPanes.forEach(
414
      tp -> tp.getTabs().forEach( tab -> {
415
        final var node = tab.getContent();
416
417
        if( node instanceof final TextEditor editor ) {
418
          save( editor );
419
        }
420
      } )
421
    );
422
  }
423
424
  /**
425
   * Requests that the active {@link TextEditor} saves itself. Don't bother
426
   * checking if modified first because if the user swaps external media from
427
   * an external source (e.g., USB thumb drive), save should not second-guess
428
   * the user: save always re-saves. Also, it's less code.
429
   */
430
  public void save() {
431
    save( getTextEditor() );
432
  }
433
434
  /**
435
   * Saves the active {@link TextEditor} under a new name.
436
   *
437
   * @param files The new active editor {@link File} reference, must contain
438
   *              at least one element.
439
   */
440
  public void saveAs( final List<File> files ) {
441
    assert files != null;
442
    assert !files.isEmpty();
443
    final var editor = getTextEditor();
444
    final var tab = getTab( editor );
445
    final var file = files.get( 0 );
446
447
    editor.rename( file );
448
    tab.ifPresent( t -> {
449
      t.setText( editor.getFilename() );
450
      t.setTooltip( createTooltip( file ) );
451
    } );
452
453
    save();
454
  }
455
456
  /**
457
   * Saves the given {@link TextResource} to a file. This is typically used
458
   * to save either an instance of {@link TextEditor} or {@link TextDefinition}.
459
   *
460
   * @param resource The resource to export.
461
   */
462
  private void save( final TextResource resource ) {
463
    try {
464
      resource.save();
465
    } catch( final Exception ex ) {
466
      clue( ex );
467
      sNotifier.alert(
468
        getWindow(), resource.getPath(), "TextResource.saveFailed", ex
469
      );
470
    }
471
  }
472
473
  /**
474
   * Closes all open {@link TextEditor}s; all {@link TextDefinition}s stay open.
475
   *
476
   * @return {@code true} when all editors, modified or otherwise, were
477
   * permitted to close; {@code false} when one or more editors were modified
478
   * and the user requested no closing.
479
   */
480
  public boolean closeAll() {
481
    var closable = true;
482
483
    for( final var tabPane : mTabPanes ) {
484
      final var tabIterator = tabPane.getTabs().iterator();
485
486
      while( tabIterator.hasNext() ) {
487
        final var tab = tabIterator.next();
488
        final var resource = tab.getContent();
489
490
        // The definition panes auto-save, so being specific here prevents
491
        // closing the definitions in the situation where the user wants to
492
        // continue editing (i.e., possibly save unsaved work).
493
        if( !(resource instanceof TextEditor) ) {
494
          continue;
495
        }
496
497
        if( canClose( (TextEditor) resource ) ) {
498
          tabIterator.remove();
499
          close( tab );
500
        }
501
        else {
502
          closable = false;
503
        }
504
      }
505
    }
506
507
    return closable;
508
  }
509
510
  /**
511
   * Calls the tab's {@link Tab#getOnClosed()} handler to carry out a close
512
   * event.
513
   *
514
   * @param tab The {@link Tab} that was closed.
515
   */
516
  private void close( final Tab tab ) {
517
    assert tab != null;
518
519
    final var handler = tab.getOnClosed();
520
521
    if( handler != null ) {
522
      handler.handle( new ActionEvent() );
523
    }
524
  }
525
526
  /**
527
   * Closes the active tab; delegates to {@link #canClose(TextResource)}.
528
   */
529
  public void close() {
530
    final var editor = getTextEditor();
531
532
    if( canClose( editor ) ) {
533
      close( editor );
534
    }
535
  }
536
537
  /**
538
   * Closes the given {@link TextResource}. This must not be called from within
539
   * a loop that iterates over the tab panes using {@code forEach}, lest a
540
   * concurrent modification exception be thrown.
541
   *
542
   * @param resource The {@link TextResource} to close, without confirming with
543
   *                 the user.
544
   */
545
  private void close( final TextResource resource ) {
546
    getTab( resource ).ifPresent(
547
      ( tab ) -> {
548
        close( tab );
549
        tab.getTabPane().getTabs().remove( tab );
550
      }
551
    );
552
  }
553
554
  /**
555
   * Answers whether the given {@link TextResource} may be closed.
556
   *
557
   * @param editor The {@link TextResource} to try closing.
558
   * @return {@code true} when the editor may be closed; {@code false} when
559
   * the user has requested to keep the editor open.
560
   */
561
  private boolean canClose( final TextResource editor ) {
562
    final var editorTab = getTab( editor );
563
    final var canClose = new AtomicBoolean( true );
564
565
    if( editor.isModified() ) {
566
      final var filename = new StringBuilder();
567
      editorTab.ifPresent( ( tab ) -> filename.append( tab.getText() ) );
568
569
      final var message = sNotifier.createNotification(
570
        Messages.get( "Alert.file.close.title" ),
571
        Messages.get( "Alert.file.close.text" ),
572
        filename.toString()
573
      );
574
575
      final var dialog = sNotifier.createConfirmation( getWindow(), message );
576
577
      dialog.showAndWait().ifPresent(
578
        save -> canClose.set( save == YES ? editor.save() : save == NO )
579
      );
580
    }
581
582
    return canClose.get();
583
  }
584
585
  private ObjectProperty<TextEditor> createActiveTextEditor() {
586
    final var editor = new SimpleObjectProperty<TextEditor>();
587
588
    editor.addListener( ( c, o, n ) -> {
589
      if( n != null ) {
590
        mPreview.setBaseUri( n.getPath() );
591
        process( n );
592
      }
593
    } );
594
595
    return editor;
596
  }
597
598
  /**
599
   * Adds the HTML preview tab to its own, singular tab pane.
600
   */
601
  public void viewPreview() {
602
    viewTab( mPreview, TEXT_HTML, "Pane.preview.title" );
603
  }
604
605
  /**
606
   * Adds the document outline tab to its own, singular tab pane.
607
   */
608
  public void viewOutline() {
609
    viewTab( mOutline, APP_DOCUMENT_OUTLINE, "Pane.outline.title" );
610
  }
611
612
  public void viewStatistics() {
613
    viewTab( mStatistics, APP_DOCUMENT_STATISTICS, "Pane.statistics.title" );
614
  }
615
616
  public void viewFiles() {
617
    try {
618
      final var factory = new FilePickerFactory( getWorkspace() );
619
      final var fileManager = factory.createModeless();
620
      viewTab( fileManager, APP_FILE_MANAGER, "Pane.files.title" );
621
    } catch( final Exception ex ) {
622
      clue( ex );
623
    }
624
  }
625
626
  private void viewTab(
627
    final Node node, final MediaType mediaType, final String key ) {
628
    final var tabPane = obtainTabPane( mediaType );
629
630
    for( final var tab : tabPane.getTabs() ) {
631
      if( tab.getContent() == node ) {
632
        return;
633
      }
634
    }
635
636
    tabPane.getTabs().add( createTab( get( key ), node ) );
637
    addTabPane( tabPane );
638
  }
639
640
  public void viewRefresh() {
641
    mPreview.refresh();
642
    Engine.clear();
643
  }
644
645
  /**
646
   * Returns the tab that contains the given {@link TextEditor}.
647
   *
648
   * @param editor The {@link TextEditor} instance to find amongst the tabs.
649
   * @return The first tab having content that matches the given tab.
650
   */
651
  private Optional<Tab> getTab( final TextResource editor ) {
652
    return mTabPanes.stream()
653
                    .flatMap( pane -> pane.getTabs().stream() )
654
                    .filter( tab -> editor.equals( tab.getContent() ) )
655
                    .findFirst();
656
  }
657
658
  /**
659
   * Creates a new {@link DefinitionEditor} wrapped in a listener that
660
   * is used to detect when the active {@link DefinitionEditor} has changed.
661
   * Upon changing, the variables are interpolated and the active text editor
662
   * is refreshed.
663
   *
664
   * @param textEditor Text editor to update with the revised resolved map.
665
   * @return A newly configured property that represents the active
666
   * {@link DefinitionEditor}, never null.
667
   */
668
  private ObjectProperty<TextDefinition> createActiveDefinitionEditor(
669
    final ObjectProperty<TextEditor> textEditor ) {
670
    final var defEditor = new SimpleObjectProperty<>(
671
      createDefinitionEditor()
672
    );
673
674
    defEditor.addListener( ( c, o, n ) -> {
675
      final var editor = textEditor.get();
676
677
      if( editor.isMediaType( TEXT_R_MARKDOWN ) ) {
678
        // Initialize R before the editor is added.
679
        mRBootstrapController.update();
680
      }
681
682
      process( editor );
683
    } );
684
685
    return defEditor;
686
  }
687
688
  private Tab createTab( final String filename, final Node node ) {
689
    return new DetachableTab( filename, node );
690
  }
691
692
  private Tab createTab( final File file ) {
693
    final var r = createTextResource( file );
694
    final var tab = createTab( r.getFilename(), r.getNode() );
695
696
    r.modifiedProperty().addListener(
697
      ( c, o, n ) -> tab.setText( r.getFilename() + (n ? "*" : "") )
698
    );
699
700
    // This is called when either the tab is closed by the user clicking on
701
    // the tab's close icon or when closing (all) from the file menu.
702
    tab.setOnClosed(
703
      ( __ ) -> getRecentFiles().remove( file.getAbsolutePath() )
704
    );
705
706
    // When closing a tab, give focus to the newly revealed tab.
707
    tab.selectedProperty().addListener( ( c, o, n ) -> {
708
      if( n != null && n ) {
709
        final var pane = tab.getTabPane();
710
711
        if( pane != null ) {
712
          pane.requestFocus();
713
        }
714
      }
715
    } );
716
717
    tab.tabPaneProperty().addListener( ( cPane, oPane, nPane ) -> {
718
      if( nPane != null ) {
719
        nPane.focusedProperty().addListener( ( c, o, n ) -> {
720
          if( n != null && n ) {
721
            final var selected = nPane.getSelectionModel().getSelectedItem();
722
            final var node = selected.getContent();
723
            node.requestFocus();
724
          }
725
        } );
726
      }
727
    } );
728
729
    return tab;
730
  }
731
732
  /**
733
   * Creates bins for the different {@link MediaType}s, which eventually are
734
   * added to the UI as separate tab panes. If ever a general-purpose scene
735
   * exporter is developed to serialize a scene to an FXML file, this could
736
   * be replaced by such a class.
737
   * <p>
738
   * When binning the files, this makes sure that at least one file exists
739
   * for every type. If the user has opted to close a particular type (such
740
   * as the definition pane), the view will suppressed elsewhere.
741
   * </p>
742
   * <p>
743
   * The order that the binned files are returned will be reflected in the
744
   * order that the corresponding panes are rendered in the UI.
745
   * </p>
746
   *
747
   * @param paths The file paths to bin according to their type.
748
   * @return An in-order list of files, first by structured definition files,
749
   * then by plain text documents.
750
   */
751
  private List<File> collect( final SetProperty<String> paths ) {
752
    // Treat all files destined for the text editor as plain text documents
753
    // so that they are added to the same pane. Grouping by TEXT_PLAIN is a
754
    // bit arbitrary, but means explicitly capturing TEXT_PLAIN isn't needed.
755
    final Function<MediaType, MediaType> bin =
756
      m -> PLAIN_TEXT_FORMAT.contains( m ) ? TEXT_PLAIN : m;
757
758
    // Create two groups: YAML files and plain text files. The order that
759
    // the elements are listed in the enumeration for media types determines
760
    // what files are loaded first. Variable definitions come before all other
761
    // plain text documents.
762
    final var bins = paths
763
      .stream()
764
      .collect(
765
        groupingBy(
766
          path -> bin.apply( MediaType.fromFilename( path ) ),
767
          () -> new TreeMap<>( Enum::compareTo ),
768
          Collectors.toList()
769
        )
770
      );
771
772
    bins.putIfAbsent( TEXT_YAML, List.of( DEFINITION_DEFAULT.toString() ) );
773
    bins.putIfAbsent( TEXT_PLAIN, List.of( DOCUMENT_DEFAULT.toString() ) );
774
775
    final var result = new LinkedList<File>();
776
777
    // Ensure that the same types are listed together (keep insertion order).
778
    bins.forEach( ( mediaType, files ) -> result.addAll(
779
      files.stream().map( File::new ).toList() )
780
    );
781
782
    return result;
783
  }
784
785
  /**
786
   * Force the active editor to update, which will cause the processor
787
   * to re-evaluate the interpolated definition map thereby updating the
788
   * preview pane.
789
   *
790
   * @param editor Contains the source document to update in the preview pane.
791
   */
792
  private void process( final TextEditor editor ) {
793
    // Ensure processing does not run on the JavaFX thread, which frees the
794
    // text editor immediately for caret movement. The preview will have a
795
    // slight delay when catching up to the caret position.
796
    final var task = new Task<Void>() {
797
      @Override
798
      public Void call() {
799
        try {
800
          final var p = mProcessors.getOrDefault( editor, IDENTITY );
801
          p.apply( editor == null ? "" : editor.getText() );
802
        } catch( final Exception ex ) {
803
          clue( ex );
804
        }
805
806
        return null;
807
      }
808
    };
809
810
    // TODO: Each time the editor successfully runs the processor the task is
811
    //   considered successful. Due to the rapid-fire nature of processing
812
    //   (e.g., keyboard navigation, fast typing), it isn't necessary to
813
    //   scroll each time.
814
    //   The algorithm:
815
    //   1. Peek at the oldest time.
816
    //   2. If the difference between the oldest time and current time exceeds
817
    //      250 milliseconds, then invoke the scrolling.
818
    //   3. Insert the current time into the circular queue.
819
    task.setOnSucceeded(
820
      e -> invokeLater( () -> mPreview.scrollTo( CARET_ID ) )
821
    );
822
823
    // Prevents multiple process requests from executing simultaneously (due
824
    // to having a restricted queue size).
825
    sExecutor.execute( task );
826
  }
827
828
  /**
829
   * Lazily creates a {@link TabPane} configured to listen for tab select
830
   * events. The tab pane is associated with a given media type so that
831
   * similar files can be grouped together.
832
   *
833
   * @param mediaType The media type to associate with the tab pane.
834
   * @return An instance of {@link TabPane} that will handle tab docking.
835
   */
836
  private TabPane obtainTabPane( final MediaType mediaType ) {
837
    for( final var pane : mTabPanes ) {
838
      for( final var tab : pane.getTabs() ) {
839
        final var node = tab.getContent();
840
841
        if( node instanceof TextResource r && r.supports( mediaType ) ) {
842
          return pane;
843
        }
844
      }
845
    }
846
847
    final var pane = createTabPane();
848
    mTabPanes.add( pane );
849
    return pane;
850
  }
851
852
  /**
853
   * Creates an initialized {@link TabPane} instance.
854
   *
855
   * @return A new {@link TabPane} with all listeners configured.
856
   */
857
  private TabPane createTabPane() {
858
    final var tabPane = new DetachableTabPane();
859
860
    initStageOwnerFactory( tabPane );
861
    initTabListener( tabPane );
862
863
    return tabPane;
864
  }
865
866
  /**
867
   * When any {@link DetachableTabPane} is detached from the main window,
868
   * the stage owner factory must be given its parent window, which will
869
   * own the child window. The parent window is the {@link MainPane}'s
870
   * {@link Scene}'s {@link Window} instance.
871
   *
872
   * <p>
873
   * This will derives the new title from the main window title, incrementing
874
   * the window count to help uniquely identify the child windows.
875
   * </p>
876
   *
877
   * @param tabPane A new {@link DetachableTabPane} to configure.
878
   */
879
  private void initStageOwnerFactory( final DetachableTabPane tabPane ) {
880
    tabPane.setStageOwnerFactory( ( stage ) -> {
881
      final var title = get(
882
        "Detach.tab.title",
883
        ((Stage) getWindow()).getTitle(), ++mWindowCount
884
      );
885
      stage.setTitle( title );
886
887
      return getScene().getWindow();
888
    } );
889
  }
890
891
  /**
892
   * Responsible for configuring the content of each {@link DetachableTab} when
893
   * it is added to the given {@link DetachableTabPane} instance.
894
   * <p>
895
   * For {@link TextEditor} contents, an instance of {@link ScrollEventHandler}
896
   * is initialized to perform synchronized scrolling between the editor and
897
   * its preview window. Additionally, the last tab in the tab pane's list of
898
   * tabs is given focus.
899
   * </p>
900
   * <p>
901
   * Note that multiple tabs can be added simultaneously.
902
   * </p>
903
   *
904
   * @param tabPane A new {@link TabPane} to configure.
905
   */
906
  private void initTabListener( final TabPane tabPane ) {
907
    tabPane.getTabs().addListener(
908
      ( final ListChangeListener.Change<? extends Tab> listener ) -> {
909
        while( listener.next() ) {
910
          if( listener.wasAdded() ) {
911
            final var tabs = listener.getAddedSubList();
912
913
            tabs.forEach( tab -> {
914
              final var node = tab.getContent();
915
916
              if( node instanceof TextEditor ) {
917
                initScrollEventListener( tab );
918
              }
919
            } );
920
921
            // Select and give focus to the last tab opened.
922
            final var index = tabs.size() - 1;
923
            if( index >= 0 ) {
924
              final var tab = tabs.get( index );
925
              tabPane.getSelectionModel().select( tab );
926
              tab.getContent().requestFocus();
927
            }
928
          }
929
        }
930
      }
931
    );
932
  }
933
934
  /**
935
   * Synchronizes scrollbar positions between the given {@link Tab} that
936
   * contains an instance of {@link TextEditor} and {@link HtmlPreview} pane.
937
   *
938
   * @param tab The container for an instance of {@link TextEditor}.
939
   */
940
  private void initScrollEventListener( final Tab tab ) {
941
    final var editor = (TextEditor) tab.getContent();
942
    final var scrollPane = editor.getScrollPane();
943
    final var scrollBar = mPreview.getVerticalScrollBar();
944
    final var handler = new ScrollEventHandler( scrollPane, scrollBar );
945
946
    handler.enabledProperty().bind( tab.selectedProperty() );
947
  }
948
949
  private void addTabPane( final int index, final TabPane tabPane ) {
950
    final var items = getItems();
951
952
    if( !items.contains( tabPane ) ) {
953
      items.add( index, tabPane );
954
    }
955
  }
956
957
  private void addTabPane( final TabPane tabPane ) {
958
    addTabPane( getItems().size(), tabPane );
959
  }
960
961
  private GenericBuilder<Mutator, ProcessorContext> createProcessorContextBuilder() {
962
    final var w = getWorkspace();
963
964
    return builder()
965
      .with( Mutator::setDefinitions, this::getDefinitions )
966
      .with( Mutator::setLocale, w::getLocale )
967
      .with( Mutator::setMetadata, w::getMetadata )
968
      .with( Mutator::setThemePath, w::getThemePath )
969
      .with( Mutator::setCaret,
970
             () -> getTextEditor().getCaret() )
971
      .with( Mutator::setImageDir,
972
             () -> w.getFile( KEY_IMAGES_DIR ) )
973
      .with( Mutator::setImageOrder,
974
             () -> w.getString( KEY_IMAGES_ORDER ) )
975
      .with( Mutator::setImageServer,
976
             () -> w.getString( KEY_IMAGES_SERVER ) )
977
      .with( Mutator::setSigilBegan,
978
             () -> w.getString( KEY_DEF_DELIM_BEGAN ) )
979
      .with( Mutator::setSigilEnded,
980
             () -> w.getString( KEY_DEF_DELIM_ENDED ) )
981
      .with( Mutator::setRScript,
982
             () -> w.getString( KEY_R_SCRIPT ) )
983
      .with( Mutator::setRWorkingDir,
984
             () -> w.getFile( KEY_R_DIR ).toPath() )
985
      .with( Mutator::setCurlQuotes,
986
             () -> w.getBoolean( KEY_TYPESET_TYPOGRAPHY_QUOTES ) )
987
      .with( Mutator::setAutoClean,
988
             () -> w.getBoolean( KEY_TYPESET_CONTEXT_CLEAN ) );
989
  }
990
991
  public ProcessorContext createProcessorContext() {
992
    return createProcessorContext( null, NONE );
993
  }
994
995
  /**
996
   * @param outputPath Used when exporting to a PDF file (binary).
997
   * @param format     Used when processors export to a new text format.
998
   * @return A new {@link ProcessorContext} to use when creating an instance of
999
   * {@link Processor}.
1000
   */
1001
  public ProcessorContext createProcessorContext(
1002
    final Path outputPath, final ExportFormat format ) {
1003
    final var textEditor = getTextEditor();
1004
    final var inputPath = textEditor.getPath();
1005
1006
    return createProcessorContextBuilder()
1007
      .with( Mutator::setInputPath, inputPath )
1008
      .with( Mutator::setOutputPath, outputPath )
1009
      .with( Mutator::setExportFormat, format )
1010
      .build();
1011
  }
1012
1013
  /**
1014
   * @param inputPath Used by {@link ProcessorFactory} to determine
1015
   *                  {@link Processor} type to create based on file type.
1016
   * @return A new {@link ProcessorContext} to use when creating an instance of
1017
   * {@link Processor}.
1018
   */
1019
  private ProcessorContext createProcessorContext( final Path inputPath ) {
1020
    return createProcessorContextBuilder()
1021
      .with( Mutator::setInputPath, inputPath )
1022
      .with( Mutator::setExportFormat, NONE )
1023
      .build();
1024
  }
1025
1026
  private TextResource createTextResource( final File file ) {
1027
    // TODO: Create PlainTextEditor that's returned by default.
1028
    return MediaType.valueFrom( file ) == TEXT_YAML
1029
      ? createDefinitionEditor( file )
1030
      : createMarkdownEditor( file );
1031
  }
1032
1033
  /**
1034
   * Creates an instance of {@link MarkdownEditor} that listens for both
1035
   * caret change events and text change events. Text change events must
1036
   * take priority over caret change events because it's possible to change
1037
   * the text without moving the caret (e.g., delete selected text).
1038
   *
1039
   * @param inputFile The file containing contents for the text editor.
1040
   * @return A non-null text editor.
1041
   */
1042
  private TextResource createMarkdownEditor( final File inputFile ) {
1043
    final var editor = new MarkdownEditor( inputFile, getWorkspace() );
1044
1045
    mProcessors.computeIfAbsent(
1046
      editor, p -> createProcessors(
1047
        createProcessorContext( inputFile.toPath() ),
1048
        createHtmlPreviewProcessor()
1049
      )
1050
    );
1051
1052
    // Listener for editor modifications or caret position changes.
1053
    editor.addDirtyListener( ( c, o, n ) -> {
1054
      if( n ) {
1055
        // Reset the status bar after changing the text.
1056
        clue();
1057
1058
        // Processing the text may update the status bar.
1059
        process( getTextEditor() );
1060
1061
        // Update the caret position in the status bar.
1062
        CaretMovedEvent.fire( editor.getCaret() );
1063
      }
1064
    } );
1065
1066
    editor.addEventListener(
1067
      keyPressed( SPACE, CONTROL_DOWN ), this::autoinsert
1068
    );
1069
1070
    // Set the active editor, which refreshes the preview panel.
1071
    mTextEditor.set( editor );
1072
1073
    return editor;
1074
  }
1075
1076
  /**
1077
   * Creates a {@link Processor} capable of rendering an HTML document onto
1078
   * a GUI widget.
1079
   *
1080
   * @return The {@link Processor} for rendering an HTML document.
1081
   */
1082
  private Processor<String> createHtmlPreviewProcessor() {
1083
    return new HtmlPreviewProcessor( getPreview() );
1084
  }
1085
1086
  /**
1087
   * Delegates to {@link #autoinsert()}.
1088
   *
1089
   * @param keyEvent Ignored.
1090
   */
1091
  private void autoinsert( final KeyEvent keyEvent ) {
1092
    autoinsert();
1093
  }
1094
1095
  /**
1096
   * Finds a node that matches the word at the caret, then inserts the
1097
   * corresponding definition. The definition token delimiters depend on
1098
   * the type of file being edited.
1099
   */
1100
  public void autoinsert() {
1101
    mVariableNameInjector.autoinsert( getTextEditor(), getTextDefinition() );
11271102
  }
11281103
M src/main/java/com/keenwrite/editors/common/VariableNameInjector.java
55
import com.keenwrite.editors.TextEditor;
66
import com.keenwrite.editors.definition.DefinitionTreeItem;
7
import com.keenwrite.io.MediaType;
8
import com.keenwrite.preferences.Key;
9
import com.keenwrite.preferences.Workspace;
10
import com.keenwrite.processors.r.RInlineEvaluator;
11
import com.keenwrite.sigils.PropertyKeyOperator;
12
import com.keenwrite.sigils.RKeyOperator;
713
814
import java.util.function.UnaryOperator;
915
1016
import static com.keenwrite.constants.Constants.*;
1117
import static com.keenwrite.events.StatusEvent.clue;
18
import static com.keenwrite.preferences.AppKeys.*;
1219
1320
/**
1421
 * Provides the logic for injecting variable names within the editor.
1522
 */
1623
public final class VariableNameInjector {
24
  private final Workspace mWorkspace;
25
26
  public VariableNameInjector( final Workspace workspace ) {
27
    assert workspace != null;
28
29
    mWorkspace = workspace;
30
  }
1731
1832
  /**
1933
   * Find a node that matches the current word and substitute the definition
20
   * reference.
34
   * reference. After calling this method the document being edited will have
35
   * the word under the caret replaced with a corresponding variable name
36
   * bracketed by sigils according to the document's media type.
37
   *
38
   * @param editor      The editor having a caret and a word under that caret.
39
   * @param definitions The list of variable definitions to search for a value
40
   *                    that matches the word under the caret.
2141
   */
22
  public static void autoinsert(
42
  public void autoinsert(
2343
    final TextEditor editor,
24
    final TextDefinition definitions,
25
    final UnaryOperator<String> operator ) {
44
    final TextDefinition definitions ) {
2645
    assert editor != null;
2746
    assert definitions != null;
28
    assert operator != null;
2947
3048
    try {
...
4664
          }
4765
          else {
66
            final var mediaType = editor.getMediaType();
67
            final var operator = createOperator( mediaType );
68
4869
            editor.replaceText( indexes, operator.apply( leaf.toPath() ) );
4970
            definitions.expand( leaf );
5071
          }
5172
        }
5273
      }
5374
    } catch( final Exception ex ) {
5475
      clue( STATUS_DEFINITION_BLANK, ex );
76
    }
77
  }
78
79
  /**
80
   * Creates an instance of {@link UnaryOperator} that can wrap a value with
81
   * sigils.
82
   *
83
   * @param mediaType The type of document with variables to sigilize.
84
   * @return An operator that produces sigilized variable names.
85
   */
86
  private UnaryOperator<String> createOperator( final MediaType mediaType ) {
87
    final String began;
88
    final String ended;
89
    final UnaryOperator<String> operator;
90
91
    switch( mediaType ) {
92
      case TEXT_MARKDOWN -> {
93
        began = getString( KEY_DEF_DELIM_BEGAN );
94
        ended = getString( KEY_DEF_DELIM_ENDED );
95
        operator = s -> s;
96
      }
97
      case TEXT_R_MARKDOWN -> {
98
        began = RInlineEvaluator.PREFIX + getString( KEY_R_DELIM_BEGAN );
99
        ended = getString( KEY_R_DELIM_ENDED ) + RInlineEvaluator.SUFFIX;
100
        operator = new RKeyOperator();
101
      }
102
      case TEXT_PROPERTIES -> {
103
        began = PropertyKeyOperator.BEGAN;
104
        ended = PropertyKeyOperator.ENDED;
105
        operator = s -> s;
106
      }
107
      default -> {
108
        began = "";
109
        ended = "";
110
        operator = s -> s;
111
      }
55112
    }
113
114
    return s -> began + operator.apply( s ) + ended;
115
  }
116
117
  private String getString( final Key key ) {
118
    assert key != null;
119
120
    return mWorkspace.getString( key );
56121
  }
57122
...
77142
    return leaf;
78143
  }
79
80
  /**
81
   * Prevent instantiation.
82
   */
83
  private VariableNameInjector() {}
84144
}
85145
M src/main/java/com/keenwrite/events/TextEditorFocusEvent.java
44
import com.keenwrite.editors.TextEditor;
55
6
/**
7
 * Collates information about the text editor that has gained focus.
8
 */
69
public class TextEditorFocusEvent extends FocusEvent<TextEditor> {
710
  protected TextEditorFocusEvent( final TextEditor editor ) {
A src/main/java/com/keenwrite/events/workspace/WorkspaceEvent.java
1
/* Copyright 2022 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.events.workspace;
3
4
import com.keenwrite.events.AppEvent;
5
6
/**
7
 * Superclass of all events related to the workspace.
8
 */
9
public abstract class WorkspaceEvent implements AppEvent {
10
}
111
A src/main/java/com/keenwrite/events/workspace/WorkspaceLoadedEvent.java
1
/* Copyright 2022 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.events.workspace;
3
4
import com.keenwrite.preferences.Workspace;
5
6
/**
7
 * Indicates that the {@link Workspace} has been loaded.
8
 */
9
public class WorkspaceLoadedEvent extends WorkspaceEvent {
10
  private final Workspace mWorkspace;
11
12
  private WorkspaceLoadedEvent( final Workspace workspace ) {
13
    assert workspace != null;
14
15
    mWorkspace = workspace;
16
  }
17
18
  /**
19
   * Publishes an event that indicates a new {@link Workspace} has been loaded.
20
   */
21
  public static void fire( final Workspace workspace ) {
22
    new WorkspaceLoadedEvent( workspace ).publish();
23
  }
24
25
  /**
26
   * Returns a reference to the {@link Workspace} that was loaded.
27
   *
28
   * @return The {@link Workspace} that has loaded user preferences.
29
   */
30
  public Workspace getWorkspace() {
31
    return mWorkspace;
32
  }
33
}
134
M src/main/java/com/keenwrite/preferences/Workspace.java
22
package com.keenwrite.preferences;
33
4
import com.keenwrite.events.workspace.WorkspaceLoadedEvent;
45
import javafx.application.Platform;
56
import javafx.beans.property.*;
...
305306
      property.setValue( observableArrayList( list ) );
306307
    } );
308
309
    WorkspaceLoadedEvent.fire( this );
307310
  }
308311
M src/main/java/com/keenwrite/processors/ProcessorFactory.java
114114
115115
  /**
116
   * Instantiates a processor capable of executing R statements (along with
117
   * R variable references) and embedding the result into the document. This
118
   * is useful for converting R Markdown documents into plain Markdown.
119
   *
120
   * @param successor {@link Processor} invoked after {@link RInlineEvaluator}.
121
   * @param context   {@link Processor} configuration settings.
122
   * @return An instance of {@link Processor} that performs variable
123
   * interpolation, replacement, and execution of R statements.
124
   */
125
  public static Processor<String> createRProcessor(
126
    final Processor<String> successor, final ProcessorContext context ) {
127
    RBootstrapController.init( context );
128
    final var rvp = new RVariableProcessor( successor, context );
129
    return createVariableProcessor( rvp, context );
130
  }
131
132
  /**
133116
   * Instantiates a new {@link Processor} that wraps an HTML document into
134117
   * its final, well-formed state (including head and body tags). This is
M src/main/java/com/keenwrite/processors/markdown/extensions/fences/FencedBlockExtension.java
6868
    assert context != null;
6969
    mContext = context;
70
    mRChunkEvaluator = new RChunkEvaluator( context );
70
    mRChunkEvaluator = new RChunkEvaluator();
7171
    mInlineEvaluator = evaluator;
7272
    mRVariableProcessor = new VerbatimRVariableProcessor( IDENTITY, context );
M src/main/java/com/keenwrite/processors/markdown/extensions/r/RInlineExtension.java
55
import com.keenwrite.processors.ProcessorContext;
66
import com.keenwrite.processors.markdown.BaseMarkdownProcessor;
7
import com.keenwrite.processors.r.RBootstrapController;
87
import com.keenwrite.processors.r.RInlineEvaluator;
98
import com.vladsch.flexmark.ast.Paragraph;
...
3433
  private final RInlineEvaluator mEvaluator;
3534
  private final BaseMarkdownProcessor mMarkdownProcessor;
36
  private final ProcessorContext mContext;
3735
3836
  private RInlineExtension( final ProcessorContext context ) {
39
    mContext = context;
4037
    mEvaluator = new RInlineEvaluator( context );
4138
    mMarkdownProcessor = new BaseMarkdownProcessor( IDENTITY, context );
...
111108
          if( mEvaluator.test( code ) ) {
112109
            codeNode.unlink();
113
114
            RBootstrapController.init( mContext );
115110
116111
            final var rText = mEvaluator.apply( code );
M src/main/java/com/keenwrite/processors/r/RBootstrapController.java
22
package com.keenwrite.processors.r;
33
4
import com.keenwrite.processors.ProcessorContext;
4
import com.keenwrite.preferences.Workspace;
55
import com.keenwrite.sigils.RKeyOperator;
66
77
import java.util.HashMap;
8
import java.util.Map;
9
import java.util.function.Supplier;
810
911
import static com.keenwrite.events.StatusEvent.clue;
12
import static com.keenwrite.preferences.AppKeys.KEY_R_DIR;
13
import static com.keenwrite.preferences.AppKeys.KEY_R_SCRIPT;
1014
import static com.keenwrite.processors.r.RVariableProcessor.escape;
1115
import static com.keenwrite.processors.text.TextReplacementFactory.replace;
...
1822
  private final static RKeyOperator KEY_OPERATOR = new RKeyOperator();
1923
20
  private RBootstrapController() {}
24
  private final Workspace mWorkspace;
25
  private final Supplier<Map<String, String>> mDefinitions;
26
27
  public RBootstrapController(
28
    final Workspace workspace,
29
    final Supplier<Map<String, String>> supplier ) {
30
    mWorkspace = workspace;
31
    mDefinitions = supplier;
32
33
    mWorkspace.stringProperty( KEY_R_SCRIPT )
34
              .addListener( ( c, o, n ) -> update() );
35
    mWorkspace.fileProperty( KEY_R_DIR )
36
              .addListener( ( c, o, n ) -> update() );
37
  }
2138
2239
  /**
23
   * Initializes the R code so that R can find imported libraries. Note that
40
   * Updates the R code so that R can find imported libraries. Note that
2441
   * any existing R functionality will not be overwritten if this method is
2542
   * 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}.
2943
   */
30
  public static void init( final ProcessorContext context ) {
31
    final var bootstrap = context.getRScript();
44
  public void update() {
45
    final var bootstrap = getRScript();
3246
3347
    if( !bootstrap.isBlank() ) {
34
      final var wd = context.getRWorkingDir();
35
      final var dir = wd.toString().replace( '\\', '/' );
36
      final var definitions = context.getDefinitions();
48
      final var dir = getRWorkingDirectory();
49
      final var definitions = mDefinitions.get();
3750
      final var map = new HashMap<String, String>( definitions.size() + 1 );
3851
...
5467
      }
5568
    }
69
  }
70
71
  private String getRScript() {
72
    return mWorkspace.getString( KEY_R_SCRIPT );
73
  }
74
75
  private String getRWorkingDirectory() {
76
    final var wd = mWorkspace.getFile( KEY_R_DIR );
77
    return wd.toString().replace( '\\', '/' );
5678
  }
5779
}
M src/main/java/com/keenwrite/processors/r/RChunkEvaluator.java
22
package com.keenwrite.processors.r;
33
4
import com.keenwrite.processors.ProcessorContext;
5
64
import java.util.function.Function;
75
...
1412
 */
1513
public final class RChunkEvaluator implements Function<String, String> {
16
17
  private final ProcessorContext mContext;
1814
1915
  /**
2016
   * Constructs an evaluator capable of executing R statements.
21
   *
22
   * @param context Used to initialize the {@link RBootstrapController}.
2317
   */
24
  public RChunkEvaluator( final ProcessorContext context ) {
25
    mContext = context;
26
  }
18
  public RChunkEvaluator() {}
2719
2820
  /**
...
3729
  public String apply( final String r ) {
3830
    try {
39
      RBootstrapController.init( mContext );
4031
      return Engine.eval( r );
4132
    } catch( final Exception ex ) {
M src/main/java/com/keenwrite/processors/r/RInlineEvaluator.java
2323
2424
  private final Processor<String> mProcessor;
25
  private final ProcessorContext mContext;
2625
2726
  /**
2827
   * Constructs an evaluator capable of executing R statements.
2928
   */
3029
  public RInlineEvaluator( final ProcessorContext context ) {
3130
    mProcessor = new RVariableProcessor( IDENTITY, context );
32
    mContext = context;
3331
  }
3432
...
5048
      int began;
5149
      int ended;
52
53
      RBootstrapController.init( mContext );
5450
5551
      while( (began = text.indexOf( PREFIX, index )) >= 0 ) {