Dave Jarvis' Repositories

git clone https://repo.autonoma.ca/repo/keenwrite.git
M R/conversion.R
433433
434434
# -----------------------------------------------------------------------------
435
# Rounds the given value to the nearest integer.
436
#
437
# @param n The value round.
438
# -----------------------------------------------------------------------------
439
round.int <- function( n ) {
440
  format( round( n ) )
441
}
442
443
# -----------------------------------------------------------------------------
435444
# Removes common accents from letters.
436445
#
M README.md
3535
3636
1. Download the *Full version* of the Java Runtime Environment, [JRE 20](https://bell-sw.com/pages/downloads).
37
  * Note that both Java 20+ and JavaFX are required. The *Full version* of
38
    BellSoft's JRE satisifies these requirements.
37
   * JavaFX, which is bundled with BellSoft's *Full version*, is required.
3938
1. Install the JRE (include JRE's `bin` directory in the `PATH` environment variable).
4039
1. Open a new terminal.
M src/main/java/com/keenwrite/ExportFormat.java
6161
    assert path != null;
6262
63
    return valueFrom( MediaType.valueFrom( path ), modifier );
63
    return valueFrom( MediaType.fromFilename( path ), modifier );
6464
  }
6565
M src/main/java/com/keenwrite/MainPane.java
148148
   */
149149
  private final ObjectProperty<TextEditor> mTextEditor =
150
    createActiveTextEditor();
151
152
  /**
153
   * Changing the active definition editor fires the value changed event. This
154
   * allows refreshes to happen when external definitions are modified and need
155
   * to trigger the processing chain.
156
   */
157
  private final ObjectProperty<TextDefinition> mDefinitionEditor;
158
159
  private final ObjectProperty<SpellChecker> mSpellChecker;
160
161
  private final TextEditorSpellChecker mEditorSpeller;
162
163
  /**
164
   * Called when the definition data is changed.
165
   */
166
  private final EventHandler<TreeModificationEvent<Event>> mTreeHandler =
167
    event -> {
168
      process( getTextEditor() );
169
      save( getTextDefinition() );
170
    };
171
172
  /**
173
   * Tracks the number of detached tab panels opened into their own windows,
174
   * which allows unique identification of subordinate windows by their title.
175
   * It is doubtful more than 128 windows, much less 256, will be created.
176
   */
177
  private byte mWindowCount;
178
179
  private final VariableNameInjector mVariableNameInjector;
180
181
  private final RBootstrapController mRBootstrapController;
182
183
  private final DocumentStatistics mStatistics;
184
185
  @SuppressWarnings( {"FieldCanBeLocal", "unused"} )
186
  private final TypesetterInstaller mInstallWizard;
187
188
  /**
189
   * Adds all content panels to the main user interface. This will load the
190
   * configuration settings from the workspace to reproduce the settings from
191
   * a previous session.
192
   */
193
  public MainPane( final Workspace workspace ) {
194
    mWorkspace = workspace;
195
    mSpellChecker = createSpellChecker();
196
    mEditorSpeller = createTextEditorSpellChecker( mSpellChecker );
197
    mPreview = new HtmlPreview( workspace );
198
    mStatistics = new DocumentStatistics( workspace );
199
    mTextEditor.set( createMarkdownEditor( DOCUMENT_DEFAULT ) );
200
    mDefinitionEditor = createActiveDefinitionEditor( mTextEditor );
201
    mVariableNameInjector = new VariableNameInjector( mWorkspace );
202
    mRBootstrapController = new RBootstrapController(
203
      mWorkspace, this::getDefinitions );
204
205
    open( collect( getRecentFiles() ) );
206
    viewPreview();
207
    setDividerPositions( calculateDividerPositions() );
208
209
    // Once the main scene's window regains focus, update the active definition
210
    // editor to the currently selected tab.
211
    runLater( () -> getWindow().setOnCloseRequest( event -> {
212
      // Order matters: Open file names must be persisted before closing all.
213
      mWorkspace.save();
214
215
      if( closeAll() ) {
216
        exit();
217
        terminate( 0 );
218
      }
219
220
      event.consume();
221
    } ) );
222
223
    register( this );
224
    initAutosave( workspace );
225
226
    restoreSession();
227
    runLater( this::restoreFocus );
228
229
    mInstallWizard = new TypesetterInstaller( workspace );
230
  }
231
232
  /**
233
   * Called when spellchecking can be run. This will reload the dictionary
234
   * into memory once, and then re-use it for all the existing text editors.
235
   *
236
   * @param event The event to process, having a populated word-frequency map.
237
   */
238
  @Subscribe
239
  public void handle( final LexiconLoadedEvent event ) {
240
    final var lexicon = event.getLexicon();
241
242
    try {
243
      final var checker = SymSpellSpeller.forLexicon( lexicon );
244
      mSpellChecker.set( checker );
245
    } catch( final Exception ex ) {
246
      clue( ex );
247
    }
248
  }
249
250
  @Subscribe
251
  public void handle( final TextEditorFocusEvent event ) {
252
    mTextEditor.set( event.get() );
253
  }
254
255
  @Subscribe
256
  public void handle( final TextDefinitionFocusEvent event ) {
257
    mDefinitionEditor.set( event.get() );
258
  }
259
260
  /**
261
   * Typically called when a file name is clicked in the preview panel.
262
   *
263
   * @param event The event to process, must contain a valid file reference.
264
   */
265
  @Subscribe
266
  public void handle( final FileOpenEvent event ) {
267
    final File eventFile;
268
    final var eventUri = event.getUri();
269
270
    if( eventUri.isAbsolute() ) {
271
      eventFile = new File( eventUri.getPath() );
272
    }
273
    else {
274
      final var activeFile = getTextEditor().getFile();
275
      final var parent = activeFile.getParentFile();
276
277
      if( parent == null ) {
278
        clue( new FileNotFoundException( eventUri.getPath() ) );
279
        return;
280
      }
281
      else {
282
        final var parentPath = parent.getAbsolutePath();
283
        eventFile = Path.of( parentPath, eventUri.getPath() ).toFile();
284
      }
285
    }
286
287
    final var mediaType = MediaTypeExtension.fromFile( eventFile );
288
289
    runLater( () -> {
290
      // Open text files locally.
291
      if( mediaType.isType( TEXT ) ) {
292
        open( eventFile );
293
      }
294
      else {
295
        try {
296
          // Delegate opening all other file types to the operating system.
297
          getDesktop().open( eventFile );
298
        } catch( final Exception ex ) {
299
          clue( ex );
300
        }
301
      }
302
    } );
303
  }
304
305
  @Subscribe
306
  public void handle( final CaretNavigationEvent event ) {
307
    runLater( () -> {
308
      final var textArea = getTextEditor();
309
      textArea.moveTo( event.getOffset() );
310
      textArea.requestFocus();
311
    } );
312
  }
313
314
  @Subscribe
315
  public void handle( final InsertDefinitionEvent<String> event ) {
316
    final var leaf = event.getLeaf();
317
    final var editor = mTextEditor.get();
318
319
    mVariableNameInjector.insert( editor, leaf );
320
  }
321
322
  private void initAutosave( final Workspace workspace ) {
323
    final var rate = workspace.integerProperty( KEY_EDITOR_AUTOSAVE );
324
325
    rate.addListener(
326
      ( c, o, n ) -> {
327
        final var taskRef = mSaveTask.get();
328
329
        // Prevent multiple autosaves from running.
330
        if( taskRef != null ) {
331
          taskRef.cancel( false );
332
        }
333
334
        initAutosave( rate );
335
      }
336
    );
337
338
    // Start the save listener (avoids duplicating some code).
339
    initAutosave( rate );
340
  }
341
342
  private void initAutosave( final IntegerProperty rate ) {
343
    mSaveTask.set(
344
      mSaver.scheduleAtFixedRate(
345
        () -> {
346
          if( getTextEditor().isModified() ) {
347
            // Ensure the modified indicator is cleared by running on EDT.
348
            runLater( this::save );
349
          }
350
        }, 0, rate.intValue(), SECONDS
351
      )
352
    );
353
  }
354
355
  /**
356
   * TODO: Load divider positions from exported settings, see
357
   *   {@link #collect(SetProperty)} comment.
358
   */
359
  private double[] calculateDividerPositions() {
360
    final var ratio = 100f / getItems().size() / 100;
361
    final var positions = getDividerPositions();
362
363
    for( int i = 0; i < positions.length; i++ ) {
364
      positions[ i ] = ratio * i;
365
    }
366
367
    return positions;
368
  }
369
370
  /**
371
   * Opens all the files into the application, provided the paths are unique.
372
   * This may only be called for any type of files that a user can edit
373
   * (i.e., update and persist), such as definitions and text files.
374
   *
375
   * @param files The list of files to open.
376
   */
377
  public void open( final List<File> files ) {
378
    files.forEach( this::open );
379
  }
380
381
  /**
382
   * This opens the given file. Since the preview pane is not a file that
383
   * can be opened, it is safe to add a listener to the detachable pane.
384
   * This will exit early if the given file is not a regular file (i.e., a
385
   * directory).
386
   *
387
   * @param inputFile The file to open.
388
   */
389
  private void open( final File inputFile ) {
390
    // Prevent opening directories (a non-existent "untitled.md" is fine).
391
    if( !inputFile.isFile() && inputFile.exists() ) {
392
      return;
393
    }
394
395
    final var tab = createTab( inputFile );
396
    final var node = tab.getContent();
397
    final var mediaType = MediaType.valueFrom( inputFile );
398
    final var tabPane = obtainTabPane( mediaType );
399
400
    tab.setTooltip( createTooltip( inputFile ) );
401
    tabPane.setFocusTraversable( false );
402
    tabPane.setTabClosingPolicy( ALL_TABS );
403
    tabPane.getTabs().add( tab );
404
405
    // Attach the tab scene factory for new tab panes.
406
    if( !getItems().contains( tabPane ) ) {
407
      addTabPane(
408
        node instanceof TextDefinition ? 0 : getItems().size(), tabPane
409
      );
410
    }
411
412
    if( inputFile.isFile() ) {
413
      getRecentFiles().add( inputFile.getAbsolutePath() );
414
    }
415
  }
416
417
  /**
418
   * Gives focus to the most recently edited document and attempts to move
419
   * the caret to the most recently known offset into said document.
420
   */
421
  private void restoreSession() {
422
    final var workspace = getWorkspace();
423
    final var file = workspace.fileProperty( KEY_UI_RECENT_DOCUMENT );
424
    final var offset = workspace.integerProperty( KEY_UI_RECENT_OFFSET );
425
426
    for( final var pane : mTabPanes ) {
427
      for( final var tab : pane.getTabs() ) {
428
        final var tooltip = tab.getTooltip();
429
430
        if( tooltip != null ) {
431
          final var tabName = tooltip.getText();
432
          final var fileName = file.getValue().toString();
433
434
          if( tabName.equalsIgnoreCase( fileName ) ) {
435
            final var node = tab.getContent();
436
437
            pane.getSelectionModel().select( tab );
438
            node.requestFocus();
439
440
            if( node instanceof TextEditor editor ) {
441
              runLater( () -> editor.moveTo( offset.getValue() ) );
442
            }
443
444
            break;
445
          }
446
        }
447
      }
448
    }
449
  }
450
451
  /**
452
   * Sets the focus to the middle pane, which contains the text editor tabs.
453
   */
454
  private void restoreFocus() {
455
    // Work around a bug where focusing directly on the middle pane results
456
    // in the R engine not loading variables properly.
457
    mTabPanes.get( 0 ).requestFocus();
458
459
    // This is the only line that should be required.
460
    mTabPanes.get( 1 ).requestFocus();
461
  }
462
463
  /**
464
   * Opens a new text editor document using the default document file name.
465
   */
466
  public void newTextEditor() {
467
    open( DOCUMENT_DEFAULT );
468
  }
469
470
  /**
471
   * Opens a new definition editor document using the default definition
472
   * file name.
473
   */
474
  @SuppressWarnings( "unused" )
475
  public void newDefinitionEditor() {
476
    open( DEFINITION_DEFAULT );
477
  }
478
479
  /**
480
   * Iterates over all tab panes to find all {@link TextEditor}s and request
481
   * that they save themselves.
482
   */
483
  public void saveAll() {
484
    iterateEditors( this::save );
485
  }
486
487
  /**
488
   * Requests that the active {@link TextEditor} saves itself. Don't bother
489
   * checking if modified first because if the user swaps external media from
490
   * an external source (e.g., USB thumb drive), save should not second-guess
491
   * the user: save always re-saves. Also, it's less code.
492
   */
493
  public void save() {
494
    save( getTextEditor() );
495
  }
496
497
  /**
498
   * Saves the active {@link TextEditor} under a new name.
499
   *
500
   * @param files The new active editor {@link File} reference, must contain
501
   *              at least one element.
502
   */
503
  public void saveAs( final List<File> files ) {
504
    assert files != null;
505
    assert !files.isEmpty();
506
    final var editor = getTextEditor();
507
    final var tab = getTab( editor );
508
    final var file = files.get( 0 );
509
510
    // If the file type has changed, refresh the processors.
511
    final var mediaType = MediaType.valueFrom( file );
512
    final var typeChanged = !editor.isMediaType( mediaType );
513
514
    if( typeChanged ) {
515
      removeProcessor( editor );
516
    }
517
518
    editor.rename( file );
519
    tab.ifPresent( t -> {
520
      t.setText( editor.getFilename() );
521
      t.setTooltip( createTooltip( file ) );
522
    } );
523
524
    if( typeChanged ) {
525
      updateProcessors( editor );
526
      process( editor );
527
    }
528
529
    save();
530
  }
531
532
  /**
533
   * Saves the given {@link TextResource} to a file. This is typically used
534
   * to save either an instance of {@link TextEditor} or {@link TextDefinition}.
535
   *
536
   * @param resource The resource to export.
537
   */
538
  private void save( final TextResource resource ) {
539
    try {
540
      resource.save();
541
    } catch( final Exception ex ) {
542
      clue( ex );
543
      sNotifier.alert(
544
        getWindow(), resource.getPath(), "TextResource.saveFailed", ex
545
      );
546
    }
547
  }
548
549
  /**
550
   * Closes all open {@link TextEditor}s; all {@link TextDefinition}s stay open.
551
   *
552
   * @return {@code true} when all editors, modified or otherwise, were
553
   * permitted to close; {@code false} when one or more editors were modified
554
   * and the user requested no closing.
555
   */
556
  public boolean closeAll() {
557
    var closable = true;
558
559
    for( final var tabPane : mTabPanes ) {
560
      final var tabIterator = tabPane.getTabs().iterator();
561
562
      while( tabIterator.hasNext() ) {
563
        final var tab = tabIterator.next();
564
        final var resource = tab.getContent();
565
566
        // The definition panes auto-save, so being specific here prevents
567
        // closing the definitions in the situation where the user wants to
568
        // continue editing (i.e., possibly save unsaved work).
569
        if( !(resource instanceof TextEditor) ) {
570
          continue;
571
        }
572
573
        if( canClose( (TextEditor) resource ) ) {
574
          tabIterator.remove();
575
          close( tab );
576
        }
577
        else {
578
          closable = false;
579
        }
580
      }
581
    }
582
583
    return closable;
584
  }
585
586
  /**
587
   * Calls the tab's {@link Tab#getOnClosed()} handler to carry out a close
588
   * event.
589
   *
590
   * @param tab The {@link Tab} that was closed.
591
   */
592
  private void close( final Tab tab ) {
593
    assert tab != null;
594
595
    final var handler = tab.getOnClosed();
596
597
    if( handler != null ) {
598
      handler.handle( new ActionEvent() );
599
    }
600
  }
601
602
  /**
603
   * Closes the active tab; delegates to {@link #canClose(TextResource)}.
604
   */
605
  public void close() {
606
    final var editor = getTextEditor();
607
608
    if( canClose( editor ) ) {
609
      close( editor );
610
    }
611
  }
612
613
  /**
614
   * Closes the given {@link TextResource}. This must not be called from within
615
   * a loop that iterates over the tab panes using {@code forEach}, lest a
616
   * concurrent modification exception be thrown.
617
   *
618
   * @param resource The {@link TextResource} to close, without confirming with
619
   *                 the user.
620
   */
621
  private void close( final TextResource resource ) {
622
    getTab( resource ).ifPresent(
623
      tab -> {
624
        close( tab );
625
        tab.getTabPane().getTabs().remove( tab );
626
      }
627
    );
628
  }
629
630
  /**
631
   * Answers whether the given {@link TextResource} may be closed.
632
   *
633
   * @param editor The {@link TextResource} to try closing.
634
   * @return {@code true} when the editor may be closed; {@code false} when
635
   * the user has requested to keep the editor open.
636
   */
637
  private boolean canClose( final TextResource editor ) {
638
    final var editorTab = getTab( editor );
639
    final var canClose = new AtomicBoolean( true );
640
641
    if( editor.isModified() ) {
642
      final var filename = new StringBuilder();
643
      editorTab.ifPresent( tab -> filename.append( tab.getText() ) );
644
645
      final var message = sNotifier.createNotification(
646
        Messages.get( "Alert.file.close.title" ),
647
        Messages.get( "Alert.file.close.text" ),
648
        filename.toString()
649
      );
650
651
      final var dialog = sNotifier.createConfirmation( getWindow(), message );
652
653
      dialog.showAndWait().ifPresent(
654
        save -> canClose.set( save == YES ? editor.save() : save == NO )
655
      );
656
    }
657
658
    return canClose.get();
659
  }
660
661
  private void iterateEditors( final Consumer<TextEditor> consumer ) {
662
    mTabPanes.forEach(
663
      tp -> tp.getTabs().forEach( tab -> {
664
        final var node = tab.getContent();
665
666
        if( node instanceof final TextEditor editor ) {
667
          consumer.accept( editor );
668
        }
669
      } )
670
    );
671
  }
672
673
  private ObjectProperty<TextEditor> createActiveTextEditor() {
674
    final var editor = new SimpleObjectProperty<TextEditor>();
675
676
    editor.addListener( ( c, o, n ) -> {
677
      if( n != null ) {
678
        mPreview.setBaseUri( n.getPath() );
679
        process( n );
680
      }
681
    } );
682
683
    return editor;
684
  }
685
686
  /**
687
   * Adds the HTML preview tab to its own, singular tab pane.
688
   */
689
  public void viewPreview() {
690
    viewTab( mPreview, TEXT_HTML, "Pane.preview.title" );
691
  }
692
693
  /**
694
   * Adds the document outline tab to its own, singular tab pane.
695
   */
696
  public void viewOutline() {
697
    viewTab( mOutline, APP_DOCUMENT_OUTLINE, "Pane.outline.title" );
698
  }
699
700
  public void viewStatistics() {
701
    viewTab( mStatistics, APP_DOCUMENT_STATISTICS, "Pane.statistics.title" );
702
  }
703
704
  public void viewFiles() {
705
    try {
706
      final var factory = new FilePickerFactory( getWorkspace() );
707
      final var fileManager = factory.createModeless();
708
      viewTab( fileManager, APP_FILE_MANAGER, "Pane.files.title" );
709
    } catch( final Exception ex ) {
710
      clue( ex );
711
    }
712
  }
713
714
  private void viewTab(
715
    final Node node, final MediaType mediaType, final String key ) {
716
    final var tabPane = obtainTabPane( mediaType );
717
718
    for( final var tab : tabPane.getTabs() ) {
719
      if( tab.getContent() == node ) {
720
        return;
721
      }
722
    }
723
724
    tabPane.getTabs().add( createTab( get( key ), node ) );
725
    addTabPane( tabPane );
726
  }
727
728
  public void viewRefresh() {
729
    mPreview.refresh();
730
    Engine.clear();
731
    mRBootstrapController.update();
732
  }
733
734
  /**
735
   * Returns the tab that contains the given {@link TextEditor}.
736
   *
737
   * @param editor The {@link TextEditor} instance to find amongst the tabs.
738
   * @return The first tab having content that matches the given tab.
739
   */
740
  private Optional<Tab> getTab( final TextResource editor ) {
741
    return mTabPanes.stream()
742
                    .flatMap( pane -> pane.getTabs().stream() )
743
                    .filter( tab -> editor.equals( tab.getContent() ) )
744
                    .findFirst();
745
  }
746
747
  /**
748
   * Creates a new {@link DefinitionEditor} wrapped in a listener that
749
   * is used to detect when the active {@link DefinitionEditor} has changed.
750
   * Upon changing, the variables are interpolated and the active text editor
751
   * is refreshed.
752
   *
753
   * @param textEditor Text editor to update with the revised resolved map.
754
   * @return A newly configured property that represents the active
755
   * {@link DefinitionEditor}, never null.
756
   */
757
  private ObjectProperty<TextDefinition> createActiveDefinitionEditor(
758
    final ObjectProperty<TextEditor> textEditor ) {
759
    final var defEditor = new SimpleObjectProperty<>(
760
      createDefinitionEditor()
761
    );
762
763
    defEditor.addListener( ( c, o, n ) -> {
764
      final var editor = textEditor.get();
765
766
      if( editor.isMediaType( TEXT_R_MARKDOWN ) ) {
767
        // Initialize R before the editor is added.
768
        mRBootstrapController.update();
769
      }
770
771
      process( editor );
772
    } );
773
774
    return defEditor;
775
  }
776
777
  private Tab createTab( final String filename, final Node node ) {
778
    return new DetachableTab( filename, node );
779
  }
780
781
  private Tab createTab( final File file ) {
782
    final var r = createTextResource( file );
783
    final var tab = createTab( r.getFilename(), r.getNode() );
784
785
    r.modifiedProperty().addListener(
786
      ( c, o, n ) -> tab.setText( r.getFilename() + (n ? "*" : "") )
787
    );
788
789
    // This is called when either the tab is closed by the user clicking on
790
    // the tab's close icon or when closing (all) from the file menu.
791
    tab.setOnClosed(
792
      __ -> getRecentFiles().remove( file.getAbsolutePath() )
793
    );
794
795
    // When closing a tab, give focus to the newly revealed tab.
796
    tab.selectedProperty().addListener( ( c, o, n ) -> {
797
      if( n != null && n ) {
798
        final var pane = tab.getTabPane();
799
800
        if( pane != null ) {
801
          pane.requestFocus();
802
        }
803
      }
804
    } );
805
806
    tab.tabPaneProperty().addListener( ( cPane, oPane, nPane ) -> {
807
      if( nPane != null ) {
808
        nPane.focusedProperty().addListener( ( c, o, n ) -> {
809
          if( n != null && n ) {
810
            final var selected = nPane.getSelectionModel().getSelectedItem();
811
            final var node = selected.getContent();
812
            node.requestFocus();
813
          }
814
        } );
815
      }
816
    } );
817
818
    return tab;
819
  }
820
821
  /**
822
   * Creates bins for the different {@link MediaType}s, which eventually are
823
   * added to the UI as separate tab panes. If ever a general-purpose scene
824
   * exporter is developed to serialize a scene to an FXML file, this could
825
   * be replaced by such a class.
826
   * <p>
827
   * When binning the files, this makes sure that at least one file exists
828
   * for every type. If the user has opted to close a particular type (such
829
   * as the definition pane), the view will suppressed elsewhere.
830
   * </p>
831
   * <p>
832
   * The order that the binned files are returned will be reflected in the
833
   * order that the corresponding panes are rendered in the UI.
834
   * </p>
835
   *
836
   * @param paths The file paths to bin according to their type.
837
   * @return An in-order list of files, first by structured definition files,
838
   * then by plain text documents.
839
   */
840
  private List<File> collect( final SetProperty<String> paths ) {
841
    // Treat all files destined for the text editor as plain text documents
842
    // so that they are added to the same pane. Grouping by TEXT_PLAIN is a
843
    // bit arbitrary, but means explicitly capturing TEXT_PLAIN isn't needed.
844
    final Function<MediaType, MediaType> bin =
845
      m -> PLAIN_TEXT_FORMAT.contains( m ) ? TEXT_PLAIN : m;
846
847
    // Create two groups: YAML files and plain text files. The order that
848
    // the elements are listed in the enumeration for media types determines
849
    // what files are loaded first. Variable definitions come before all other
850
    // plain text documents.
851
    final var bins = paths
852
      .stream()
853
      .collect(
854
        groupingBy(
855
          path -> bin.apply( MediaType.fromFilename( path ) ),
856
          () -> new TreeMap<>( Enum::compareTo ),
857
          Collectors.toList()
858
        )
859
      );
860
861
    bins.putIfAbsent( TEXT_YAML, List.of( DEFINITION_DEFAULT.toString() ) );
862
    bins.putIfAbsent( TEXT_PLAIN, List.of( DOCUMENT_DEFAULT.toString() ) );
863
864
    final var result = new LinkedList<File>();
865
866
    // Ensure that the same types are listed together (keep insertion order).
867
    bins.forEach( ( mediaType, files ) -> result.addAll(
868
      files.stream().map( File::new ).toList() )
869
    );
870
871
    return result;
872
  }
873
874
  /**
875
   * Force the active editor to update, which will cause the processor
876
   * to re-evaluate the interpolated definition map thereby updating the
877
   * preview pane.
878
   *
879
   * @param editor Contains the source document to update in the preview pane.
880
   */
881
  private void process( final TextEditor editor ) {
882
    // Ensure processing does not run on the JavaFX thread, which frees the
883
    // text editor immediately for caret movement. The preview will have a
884
    // slight delay when catching up to the caret position.
885
    final var task = new Task<Void>() {
886
      @Override
887
      public Void call() {
888
        try {
889
          final var p = mProcessors.getOrDefault( editor, IDENTITY );
890
          p.apply( editor == null ? "" : editor.getText() );
891
        } catch( final Exception ex ) {
892
          clue( ex );
893
        }
894
895
        return null;
896
      }
897
    };
898
899
    // TODO: Each time the editor successfully runs the processor the task is
900
    //   considered successful. Due to the rapid-fire nature of processing
901
    //   (e.g., keyboard navigation, fast typing), it isn't necessary to
902
    //   scroll each time.
903
    //   The algorithm:
904
    //   1. Peek at the oldest time.
905
    //   2. If the difference between the oldest time and current time exceeds
906
    //      250 milliseconds, then invoke the scrolling.
907
    //   3. Insert the current time into the circular queue.
908
    task.setOnSucceeded(
909
      e -> invokeLater( () -> mPreview.scrollTo( CARET_ID ) )
910
    );
911
912
    // Prevents multiple process requests from executing simultaneously (due
913
    // to having a restricted queue size).
914
    sExecutor.execute( task );
915
  }
916
917
  /**
918
   * Lazily creates a {@link TabPane} configured to listen for tab select
919
   * events. The tab pane is associated with a given media type so that
920
   * similar files can be grouped together.
921
   *
922
   * @param mediaType The media type to associate with the tab pane.
923
   * @return An instance of {@link TabPane} that will handle tab docking.
924
   */
925
  private TabPane obtainTabPane( final MediaType mediaType ) {
926
    for( final var pane : mTabPanes ) {
927
      for( final var tab : pane.getTabs() ) {
928
        final var node = tab.getContent();
929
930
        if( node instanceof TextResource r && r.supports( mediaType ) ) {
931
          return pane;
932
        }
933
      }
934
    }
935
936
    final var pane = createTabPane();
937
    mTabPanes.add( pane );
938
    return pane;
939
  }
940
941
  /**
942
   * Creates an initialized {@link TabPane} instance.
943
   *
944
   * @return A new {@link TabPane} with all listeners configured.
945
   */
946
  private TabPane createTabPane() {
947
    final var tabPane = new DetachableTabPane();
948
949
    initStageOwnerFactory( tabPane );
950
    initTabListener( tabPane );
951
952
    return tabPane;
953
  }
954
955
  /**
956
   * When any {@link DetachableTabPane} is detached from the main window,
957
   * the stage owner factory must be given its parent window, which will
958
   * own the child window. The parent window is the {@link MainPane}'s
959
   * {@link Scene}'s {@link Window} instance.
960
   *
961
   * <p>
962
   * This will derives the new title from the main window title, incrementing
963
   * the window count to help uniquely identify the child windows.
964
   * </p>
965
   *
966
   * @param tabPane A new {@link DetachableTabPane} to configure.
967
   */
968
  private void initStageOwnerFactory( final DetachableTabPane tabPane ) {
969
    tabPane.setStageOwnerFactory( stage -> {
970
      final var title = get(
971
        "Detach.tab.title",
972
        ((Stage) getWindow()).getTitle(), ++mWindowCount
973
      );
974
      stage.setTitle( title );
975
976
      return getScene().getWindow();
977
    } );
978
  }
979
980
  /**
981
   * Responsible for configuring the content of each {@link DetachableTab} when
982
   * it is added to the given {@link DetachableTabPane} instance.
983
   * <p>
984
   * For {@link TextEditor} contents, an instance of {@link ScrollEventHandler}
985
   * is initialized to perform synchronized scrolling between the editor and
986
   * its preview window. Additionally, the last tab in the tab pane's list of
987
   * tabs is given focus.
988
   * </p>
989
   * <p>
990
   * Note that multiple tabs can be added simultaneously.
991
   * </p>
992
   *
993
   * @param tabPane A new {@link TabPane} to configure.
994
   */
995
  private void initTabListener( final TabPane tabPane ) {
996
    tabPane.getTabs().addListener(
997
      ( final ListChangeListener.Change<? extends Tab> listener ) -> {
998
        while( listener.next() ) {
999
          if( listener.wasAdded() ) {
1000
            final var tabs = listener.getAddedSubList();
1001
1002
            tabs.forEach( tab -> {
1003
              final var node = tab.getContent();
1004
1005
              if( node instanceof TextEditor ) {
1006
                initScrollEventListener( tab );
1007
              }
1008
            } );
1009
1010
            // Select and give focus to the last tab opened.
1011
            final var index = tabs.size() - 1;
1012
            if( index >= 0 ) {
1013
              final var tab = tabs.get( index );
1014
              tabPane.getSelectionModel().select( tab );
1015
              tab.getContent().requestFocus();
1016
            }
1017
          }
1018
        }
1019
      }
1020
    );
1021
  }
1022
1023
  /**
1024
   * Synchronizes scrollbar positions between the given {@link Tab} that
1025
   * contains an instance of {@link TextEditor} and {@link HtmlPreview} pane.
1026
   *
1027
   * @param tab The container for an instance of {@link TextEditor}.
1028
   */
1029
  private void initScrollEventListener( final Tab tab ) {
1030
    final var editor = (TextEditor) tab.getContent();
1031
    final var scrollPane = editor.getScrollPane();
1032
    final var scrollBar = mPreview.getVerticalScrollBar();
1033
    final var handler = new ScrollEventHandler( scrollPane, scrollBar );
1034
1035
    handler.enabledProperty().bind( tab.selectedProperty() );
1036
  }
1037
1038
  private void addTabPane( final int index, final TabPane tabPane ) {
1039
    final var items = getItems();
1040
1041
    if( !items.contains( tabPane ) ) {
1042
      items.add( index, tabPane );
1043
    }
1044
  }
1045
1046
  private void addTabPane( final TabPane tabPane ) {
1047
    addTabPane( getItems().size(), tabPane );
1048
  }
1049
1050
  private GenericBuilder<Mutator, ProcessorContext> processorContextBuilder() {
1051
    final var w = getWorkspace();
1052
1053
    return builder()
1054
      .with( Mutator::setDefinitions, this::getDefinitions )
1055
      .with( Mutator::setLocale, w::getLocale )
1056
      .with( Mutator::setMetadata, w::getMetadata )
1057
      .with( Mutator::setThemesDir, w::getThemesPath )
1058
      .with( Mutator::setCachesDir,
1059
             () -> w.getFile( KEY_CACHES_DIR ) )
1060
      .with( Mutator::setImagesDir,
1061
             () -> w.getFile( KEY_IMAGES_DIR ) )
1062
      .with( Mutator::setImageOrder,
1063
             () -> w.getString( KEY_IMAGES_ORDER ) )
1064
      .with( Mutator::setImageServer,
1065
             () -> w.getString( KEY_IMAGES_SERVER ) )
1066
      .with( Mutator::setFontsDir,
1067
             () -> w.getFile( KEY_TYPESET_CONTEXT_FONTS_DIR ) )
1068
      .with( Mutator::setCaret,
1069
             () -> getTextEditor().getCaret() )
1070
      .with( Mutator::setSigilBegan,
1071
             () -> w.getString( KEY_DEF_DELIM_BEGAN ) )
1072
      .with( Mutator::setSigilEnded,
1073
             () -> w.getString( KEY_DEF_DELIM_ENDED ) )
1074
      .with( Mutator::setRScript,
1075
             () -> w.getString( KEY_R_SCRIPT ) )
1076
      .with( Mutator::setRWorkingDir,
1077
             () -> w.getFile( KEY_R_DIR ).toPath() )
1078
      .with( Mutator::setCurlQuotes,
1079
             () -> w.getBoolean( KEY_TYPESET_TYPOGRAPHY_QUOTES ) )
1080
      .with( Mutator::setAutoRemove,
1081
             () -> w.getBoolean( KEY_TYPESET_CONTEXT_CLEAN ) );
1082
  }
1083
1084
  public ProcessorContext createProcessorContext() {
1085
    return createProcessorContextBuilder( NONE ).build();
1086
  }
1087
1088
  private GenericBuilder<Mutator, ProcessorContext> createProcessorContextBuilder(
1089
    final ExportFormat format ) {
1090
    final var textEditor = getTextEditor();
1091
    final var sourcePath = textEditor.getPath();
1092
1093
    return processorContextBuilder()
1094
      .with( Mutator::setSourcePath, sourcePath )
1095
      .with( Mutator::setExportFormat, format );
1096
  }
1097
1098
  /**
1099
   * @param targetPath Used when exporting to a PDF file (binary).
1100
   * @param format     Used when processors export to a new text format.
1101
   * @return A new {@link ProcessorContext} to use when creating an instance of
1102
   * {@link Processor}.
1103
   */
1104
  public ProcessorContext createProcessorContext(
1105
    final Path targetPath, final ExportFormat format ) {
1106
    assert targetPath != null;
1107
    assert format != null;
1108
1109
    return createProcessorContextBuilder( format )
1110
      .with( Mutator::setTargetPath, targetPath )
1111
      .build();
1112
  }
1113
1114
  /**
1115
   * @param sourcePath Used by {@link ProcessorFactory} to determine
1116
   *                   {@link Processor} type to create based on file type.
1117
   * @return A new {@link ProcessorContext} to use when creating an instance of
1118
   * {@link Processor}.
1119
   */
1120
  private ProcessorContext createProcessorContext( final Path sourcePath ) {
1121
    return processorContextBuilder()
1122
      .with( Mutator::setSourcePath, sourcePath )
1123
      .with( Mutator::setExportFormat, NONE )
1124
      .build();
1125
  }
1126
1127
  private TextResource createTextResource( final File file ) {
1128
    // TODO: Create PlainTextEditor that's returned by default.
1129
    return MediaType.valueFrom( file ) == TEXT_YAML
1130
      ? createDefinitionEditor( file )
1131
      : createMarkdownEditor( file );
1132
  }
1133
1134
  /**
1135
   * Creates an instance of {@link MarkdownEditor} that listens for both
1136
   * caret change events and text change events. Text change events must
1137
   * take priority over caret change events because it's possible to change
1138
   * the text without moving the caret (e.g., delete selected text).
1139
   *
1140
   * @param inputFile The file containing contents for the text editor.
1141
   * @return A non-null text editor.
1142
   */
1143
  private MarkdownEditor createMarkdownEditor( final File inputFile ) {
1144
    final var editor = new MarkdownEditor( inputFile, getWorkspace() );
1145
1146
    updateProcessors( editor );
1147
1148
    // Listener for editor modifications or caret position changes.
1149
    editor.addDirtyListener( ( c, o, n ) -> {
1150
      if( n ) {
1151
        // Reset the status bar after changing the text.
1152
        clue();
1153
1154
        // Processing the text may update the status bar.
1155
        process( getTextEditor() );
1156
1157
        // Update the caret position in the status bar.
1158
        CaretMovedEvent.fire( editor.getCaret() );
1159
      }
1160
    } );
1161
1162
    editor.addEventListener(
1163
      keyPressed( SPACE, CONTROL_DOWN ), this::autoinsert
1164
    );
1165
1166
    editor.addEventListener(
1167
      keyPressed( ENTER, ALT_DOWN ), event -> mEditorSpeller.autofix( editor )
1168
    );
1169
1170
    final var textArea = editor.getTextArea();
1171
1172
    // Spell check when the paragraph changes.
1173
    textArea
1174
      .plainTextChanges()
1175
      .filter( p -> !p.isIdentity() )
1176
      .subscribe( change -> mEditorSpeller.checkParagraph( textArea, change ) );
1177
1178
    // Store the caret position to restore it after restarting the application.
1179
    textArea.caretPositionProperty().addListener(
1180
      ( c, o, n ) ->
1181
        getWorkspace().integerProperty( KEY_UI_RECENT_OFFSET ).setValue( n )
1182
    );
1183
1184
    // Set the active editor, which refreshes the preview panel.
1185
    mTextEditor.set( editor );
1186
1187
    // Check the entire document after the spellchecker is initialized (with
1188
    // a valid lexicon) so that only the current paragraph need be scanned
1189
    // while editing. (Technically, only the most recently modified word must
1190
    // be scanned.)
1191
    mSpellChecker.addListener(
1192
      ( c, o, n ) -> runLater(
1193
        () -> iterateEditors( mEditorSpeller::checkDocument )
1194
      )
1195
    );
1196
1197
    // Check the entire document after it has been loaded.
1198
    mEditorSpeller.checkDocument( mTextEditor.get() );
1199
1200
    return editor;
1201
  }
1202
1203
  /**
1204
   * Creates a processor for an editor, provided one doesn't already exist.
1205
   *
1206
   * @param editor The editor that potentially requires an associated processor.
1207
   */
1208
  private void updateProcessors( final TextEditor editor ) {
1209
    final var path = editor.getFile().toPath();
1210
1211
    mProcessors.computeIfAbsent(
1212
      editor, p -> createProcessors(
1213
        createProcessorContext( path ),
1214
        createHtmlPreviewProcessor()
1215
      )
1216
    );
1217
  }
1218
1219
  /**
1220
   * Removes a processor for an editor. This is required because a file may
1221
   * change type while editing (e.g., from plain Markdown to R Markdown).
1222
   * In the case that an editor's type changes, its associated processor must
1223
   * be changed accordingly.
1224
   *
1225
   * @param editor The editor that potentially requires an associated processor.
1226
   */
1227
  private void removeProcessor( final TextEditor editor ) {
1228
    mProcessors.remove( editor );
1229
  }
1230
1231
  /**
1232
   * Creates a {@link Processor} capable of rendering an HTML document onto
1233
   * a GUI widget.
1234
   *
1235
   * @return The {@link Processor} for rendering an HTML document.
1236
   */
1237
  private Processor<String> createHtmlPreviewProcessor() {
1238
    return new HtmlPreviewProcessor( getPreview() );
1239
  }
1240
1241
  /**
1242
   * Creates a spellchecker that accepts all words as correct. This allows
1243
   * the spellchecker property to be initialized to a known valid value.
1244
   *
1245
   * @return A wrapped {@link PermissiveSpeller}.
1246
   */
1247
  private ObjectProperty<SpellChecker> createSpellChecker() {
1248
    return new SimpleObjectProperty<>( new PermissiveSpeller() );
1249
  }
1250
1251
  private TextEditorSpellChecker createTextEditorSpellChecker(
1252
    final ObjectProperty<SpellChecker> spellChecker ) {
1253
    return new TextEditorSpellChecker( spellChecker );
1254
  }
1255
1256
  /**
1257
   * Delegates to {@link #autoinsert()}.
1258
   *
1259
   * @param keyEvent Ignored.
1260
   */
1261
  private void autoinsert( final KeyEvent keyEvent ) {
1262
    autoinsert();
1263
  }
1264
1265
  /**
1266
   * Finds a node that matches the word at the caret, then inserts the
1267
   * corresponding definition. The definition token delimiters depend on
1268
   * the type of file being edited.
1269
   */
1270
  public void autoinsert() {
1271
    mVariableNameInjector.autoinsert( getTextEditor(), getTextDefinition() );
1272
  }
1273
1274
  private TextDefinition createDefinitionEditor() {
1275
    return createDefinitionEditor( DEFINITION_DEFAULT );
1276
  }
1277
1278
  private TextDefinition createDefinitionEditor( final File file ) {
1279
    final var editor = new DefinitionEditor( file, createTreeTransformer() );
1280
1281
    editor.addTreeChangeHandler( mTreeHandler );
1282
1283
    return editor;
1284
  }
1285
1286
  private TreeTransformer createTreeTransformer() {
1287
    return new YamlTreeTransformer();
1288
  }
1289
1290
  private Tooltip createTooltip( final File file ) {
1291
    final var path = file.toPath();
1292
    final var tooltip = new Tooltip( path.toString() );
1293
1294
    tooltip.setShowDelay( millis( 200 ) );
1295
1296
    return tooltip;
1297
  }
1298
1299
  public HtmlPreview getPreview() {
1300
    return mPreview;
1301
  }
1302
1303
  /**
1304
   * Returns the active text editor.
1305
   *
1306
   * @return The text editor that currently has focus.
1307
   */
1308
  public TextEditor getTextEditor() {
1309
    return mTextEditor.get();
1310
  }
1311
1312
  /**
1313
   * Returns the active text editor property.
1314
   *
1315
   * @return The property container for the active text editor.
1316
   */
1317
  public ReadOnlyObjectProperty<TextEditor> textEditorProperty() {
1318
    return mTextEditor;
1319
  }
1320
1321
  /**
1322
   * Returns the active text definition editor.
1323
   *
1324
   * @return The property container for the active definition editor.
1325
   */
1326
  public TextDefinition getTextDefinition() {
1327
    return mDefinitionEditor == null ? null : mDefinitionEditor.get();
1328
  }
1329
1330
  /**
1331
   * Returns the active variable definitions, without any interpolation.
1332
   * Interpolation is a responsibility of {@link Processor} instances.
1333
   *
1334
   * @return The key-value pairs, not interpolated.
1335
   */
1336
  private Map<String, String> getDefinitions() {
1337
    final var definitions = getTextDefinition();
1338
    return definitions == null ? new HashMap<>() : definitions.getDefinitions();
150
    new SimpleObjectProperty<>();
151
152
  /**
153
   * Changing the active definition editor fires the value changed event. This
154
   * allows refreshes to happen when external definitions are modified and need
155
   * to trigger the processing chain.
156
   */
157
  private final ObjectProperty<TextDefinition> mDefinitionEditor =
158
    new SimpleObjectProperty<>();
159
160
  private final ObjectProperty<SpellChecker> mSpellChecker;
161
162
  private final TextEditorSpellChecker mEditorSpeller;
163
164
  /**
165
   * Called when the definition data is changed.
166
   */
167
  private final EventHandler<TreeModificationEvent<Event>> mTreeHandler =
168
    event -> {
169
      process( getTextEditor() );
170
      save( getTextDefinition() );
171
    };
172
173
  /**
174
   * Tracks the number of detached tab panels opened into their own windows,
175
   * which allows unique identification of subordinate windows by their title.
176
   * It is doubtful more than 128 windows, much less 256, will be created.
177
   */
178
  private byte mWindowCount;
179
180
  private final VariableNameInjector mVariableNameInjector;
181
182
  private final RBootstrapController mRBootstrapController;
183
184
  private final DocumentStatistics mStatistics;
185
186
  @SuppressWarnings( {"FieldCanBeLocal", "unused"} )
187
  private final TypesetterInstaller mInstallWizard;
188
189
  /**
190
   * Adds all content panels to the main user interface. This will load the
191
   * configuration settings from the workspace to reproduce the settings from
192
   * a previous session.
193
   */
194
  public MainPane( final Workspace workspace ) {
195
    mWorkspace = workspace;
196
    mSpellChecker = createSpellChecker();
197
    mEditorSpeller = createTextEditorSpellChecker( mSpellChecker );
198
    mPreview = new HtmlPreview( workspace );
199
    mStatistics = new DocumentStatistics( workspace );
200
201
    mTextEditor.addListener( ( c, o, n ) -> {
202
      if( o != null ) {
203
        removeProcessor( o );
204
      }
205
206
      if( n != null ) {
207
        mPreview.setBaseUri( n.getPath() );
208
        updateProcessors( n );
209
        process( n );
210
      }
211
    } );
212
213
    mTextEditor.set( createMarkdownEditor( DOCUMENT_DEFAULT ) );
214
    mDefinitionEditor.set( createDefinitionEditor( workspace ) );
215
    mVariableNameInjector = new VariableNameInjector( workspace );
216
    mRBootstrapController = new RBootstrapController(
217
      workspace, mDefinitionEditor.get()::getDefinitions
218
    );
219
220
    // If the user modifies the definitions, re-process the variables.
221
    mDefinitionEditor.addListener( ( c, o, n ) -> {
222
      final var textEditor = getTextEditor();
223
224
      if( textEditor.isMediaType( TEXT_R_MARKDOWN ) ) {
225
        mRBootstrapController.update();
226
      }
227
228
      process( textEditor );
229
    } );
230
231
    open( collect( getRecentFiles() ) );
232
    viewPreview();
233
    setDividerPositions( calculateDividerPositions() );
234
235
    // Once the main scene's window regains focus, update the active definition
236
    // editor to the currently selected tab.
237
    runLater( () -> getWindow().setOnCloseRequest( event -> {
238
      // Order matters: Open file names must be persisted before closing all.
239
      mWorkspace.save();
240
241
      if( closeAll() ) {
242
        exit();
243
        terminate( 0 );
244
      }
245
246
      event.consume();
247
    } ) );
248
249
    register( this );
250
    initAutosave( workspace );
251
252
    restoreSession();
253
    runLater( this::restoreFocus );
254
255
    mInstallWizard = new TypesetterInstaller( workspace );
256
  }
257
258
  /**
259
   * Called when spellchecking can be run. This will reload the dictionary
260
   * into memory once, and then re-use it for all the existing text editors.
261
   *
262
   * @param event The event to process, having a populated word-frequency map.
263
   */
264
  @Subscribe
265
  public void handle( final LexiconLoadedEvent event ) {
266
    final var lexicon = event.getLexicon();
267
268
    try {
269
      final var checker = SymSpellSpeller.forLexicon( lexicon );
270
      mSpellChecker.set( checker );
271
    } catch( final Exception ex ) {
272
      clue( ex );
273
    }
274
  }
275
276
  @Subscribe
277
  public void handle( final TextEditorFocusEvent event ) {
278
    mTextEditor.set( event.get() );
279
  }
280
281
  @Subscribe
282
  public void handle( final TextDefinitionFocusEvent event ) {
283
    mDefinitionEditor.set( event.get() );
284
  }
285
286
  /**
287
   * Typically called when a file name is clicked in the preview panel.
288
   *
289
   * @param event The event to process, must contain a valid file reference.
290
   */
291
  @Subscribe
292
  public void handle( final FileOpenEvent event ) {
293
    final File eventFile;
294
    final var eventUri = event.getUri();
295
296
    if( eventUri.isAbsolute() ) {
297
      eventFile = new File( eventUri.getPath() );
298
    }
299
    else {
300
      final var activeFile = getTextEditor().getFile();
301
      final var parent = activeFile.getParentFile();
302
303
      if( parent == null ) {
304
        clue( new FileNotFoundException( eventUri.getPath() ) );
305
        return;
306
      }
307
      else {
308
        final var parentPath = parent.getAbsolutePath();
309
        eventFile = Path.of( parentPath, eventUri.getPath() ).toFile();
310
      }
311
    }
312
313
    final var mediaType = MediaTypeExtension.fromFile( eventFile );
314
315
    runLater( () -> {
316
      // Open text files locally.
317
      if( mediaType.isType( TEXT ) ) {
318
        open( eventFile );
319
      }
320
      else {
321
        try {
322
          // Delegate opening all other file types to the operating system.
323
          getDesktop().open( eventFile );
324
        } catch( final Exception ex ) {
325
          clue( ex );
326
        }
327
      }
328
    } );
329
  }
330
331
  @Subscribe
332
  public void handle( final CaretNavigationEvent event ) {
333
    runLater( () -> {
334
      final var textArea = getTextEditor();
335
      textArea.moveTo( event.getOffset() );
336
      textArea.requestFocus();
337
    } );
338
  }
339
340
  @Subscribe
341
  public void handle( final InsertDefinitionEvent<String> event ) {
342
    final var leaf = event.getLeaf();
343
    final var editor = mTextEditor.get();
344
345
    mVariableNameInjector.insert( editor, leaf );
346
  }
347
348
  private void initAutosave( final Workspace workspace ) {
349
    final var rate = workspace.integerProperty( KEY_EDITOR_AUTOSAVE );
350
351
    rate.addListener(
352
      ( c, o, n ) -> {
353
        final var taskRef = mSaveTask.get();
354
355
        // Prevent multiple autosaves from running.
356
        if( taskRef != null ) {
357
          taskRef.cancel( false );
358
        }
359
360
        initAutosave( rate );
361
      }
362
    );
363
364
    // Start the save listener (avoids duplicating some code).
365
    initAutosave( rate );
366
  }
367
368
  private void initAutosave( final IntegerProperty rate ) {
369
    mSaveTask.set(
370
      mSaver.scheduleAtFixedRate(
371
        () -> {
372
          if( getTextEditor().isModified() ) {
373
            // Ensure the modified indicator is cleared by running on EDT.
374
            runLater( this::save );
375
          }
376
        }, 0, rate.intValue(), SECONDS
377
      )
378
    );
379
  }
380
381
  /**
382
   * TODO: Load divider positions from exported settings, see
383
   *   {@link #collect(SetProperty)} comment.
384
   */
385
  private double[] calculateDividerPositions() {
386
    final var ratio = 100f / getItems().size() / 100;
387
    final var positions = getDividerPositions();
388
389
    for( int i = 0; i < positions.length; i++ ) {
390
      positions[ i ] = ratio * i;
391
    }
392
393
    return positions;
394
  }
395
396
  /**
397
   * Opens all the files into the application, provided the paths are unique.
398
   * This may only be called for any type of files that a user can edit
399
   * (i.e., update and persist), such as definitions and text files.
400
   *
401
   * @param files The list of files to open.
402
   */
403
  public void open( final List<File> files ) {
404
    files.forEach( this::open );
405
  }
406
407
  /**
408
   * This opens the given file. Since the preview pane is not a file that
409
   * can be opened, it is safe to add a listener to the detachable pane.
410
   * This will exit early if the given file is not a regular file (i.e., a
411
   * directory).
412
   *
413
   * @param inputFile The file to open.
414
   */
415
  private void open( final File inputFile ) {
416
    // Prevent opening directories (a non-existent "untitled.md" is fine).
417
    if( !inputFile.isFile() && inputFile.exists() ) {
418
      return;
419
    }
420
421
    final var mediaType = fromFilename( inputFile );
422
423
    // Only allow opening text files.
424
    if( !mediaType.isType( TEXT ) ) {
425
      return;
426
    }
427
428
    final var tab = createTab( inputFile );
429
    final var node = tab.getContent();
430
    final var tabPane = obtainTabPane( mediaType );
431
432
    tab.setTooltip( createTooltip( inputFile ) );
433
    tabPane.setFocusTraversable( false );
434
    tabPane.setTabClosingPolicy( ALL_TABS );
435
    tabPane.getTabs().add( tab );
436
437
    // Attach the tab scene factory for new tab panes.
438
    if( !getItems().contains( tabPane ) ) {
439
      addTabPane(
440
        node instanceof TextDefinition ? 0 : getItems().size(), tabPane
441
      );
442
    }
443
444
    if( inputFile.isFile() ) {
445
      getRecentFiles().add( inputFile.getAbsolutePath() );
446
    }
447
  }
448
449
  /**
450
   * Gives focus to the most recently edited document and attempts to move
451
   * the caret to the most recently known offset into said document.
452
   */
453
  private void restoreSession() {
454
    final var workspace = getWorkspace();
455
    final var file = workspace.fileProperty( KEY_UI_RECENT_DOCUMENT );
456
    final var offset = workspace.integerProperty( KEY_UI_RECENT_OFFSET );
457
458
    for( final var pane : mTabPanes ) {
459
      for( final var tab : pane.getTabs() ) {
460
        final var tooltip = tab.getTooltip();
461
462
        if( tooltip != null ) {
463
          final var tabName = tooltip.getText();
464
          final var fileName = file.get().toString();
465
466
          if( tabName.equalsIgnoreCase( fileName ) ) {
467
            final var node = tab.getContent();
468
469
            pane.getSelectionModel().select( tab );
470
            node.requestFocus();
471
472
            if( node instanceof TextEditor editor ) {
473
              runLater( () -> editor.moveTo( offset.getValue() ) );
474
            }
475
476
            break;
477
          }
478
        }
479
      }
480
    }
481
  }
482
483
  /**
484
   * Sets the focus to the middle pane, which contains the text editor tabs.
485
   */
486
  private void restoreFocus() {
487
    // Work around a bug where focusing directly on the middle pane results
488
    // in the R engine not loading variables properly.
489
    mTabPanes.get( 0 ).requestFocus();
490
491
    // This is the only line that should be required.
492
    mTabPanes.get( 1 ).requestFocus();
493
  }
494
495
  /**
496
   * Opens a new text editor document using the default document file name.
497
   */
498
  public void newTextEditor() {
499
    open( DOCUMENT_DEFAULT );
500
  }
501
502
  /**
503
   * Opens a new definition editor document using the default definition
504
   * file name.
505
   */
506
  @SuppressWarnings( "unused" )
507
  public void newDefinitionEditor() {
508
    open( DEFINITION_DEFAULT );
509
  }
510
511
  /**
512
   * Iterates over all tab panes to find all {@link TextEditor}s and request
513
   * that they save themselves.
514
   */
515
  public void saveAll() {
516
    iterateEditors( this::save );
517
  }
518
519
  /**
520
   * Requests that the active {@link TextEditor} saves itself. Don't bother
521
   * checking if modified first because if the user swaps external media from
522
   * an external source (e.g., USB thumb drive), save should not second-guess
523
   * the user: save always re-saves. Also, it's less code.
524
   */
525
  public void save() {
526
    save( getTextEditor() );
527
  }
528
529
  /**
530
   * Saves the active {@link TextEditor} under a new name.
531
   *
532
   * @param files The new active editor {@link File} reference, must contain
533
   *              at least one element.
534
   */
535
  public void saveAs( final List<File> files ) {
536
    assert files != null;
537
    assert !files.isEmpty();
538
    final var editor = getTextEditor();
539
    final var tab = getTab( editor );
540
    final var file = files.get( 0 );
541
542
    // If the file type has changed, refresh the processors.
543
    final var mediaType = fromFilename( file );
544
    final var typeChanged = !editor.isMediaType( mediaType );
545
546
    if( typeChanged ) {
547
      removeProcessor( editor );
548
    }
549
550
    editor.rename( file );
551
    tab.ifPresent( t -> {
552
      t.setText( editor.getFilename() );
553
      t.setTooltip( createTooltip( file ) );
554
    } );
555
556
    if( typeChanged ) {
557
      updateProcessors( editor );
558
      process( editor );
559
    }
560
561
    save();
562
  }
563
564
  /**
565
   * Saves the given {@link TextResource} to a file. This is typically used
566
   * to save either an instance of {@link TextEditor} or {@link TextDefinition}.
567
   *
568
   * @param resource The resource to export.
569
   */
570
  private void save( final TextResource resource ) {
571
    try {
572
      resource.save();
573
    } catch( final Exception ex ) {
574
      clue( ex );
575
      sNotifier.alert(
576
        getWindow(), resource.getPath(), "TextResource.saveFailed", ex
577
      );
578
    }
579
  }
580
581
  /**
582
   * Closes all open {@link TextEditor}s; all {@link TextDefinition}s stay open.
583
   *
584
   * @return {@code true} when all editors, modified or otherwise, were
585
   * permitted to close; {@code false} when one or more editors were modified
586
   * and the user requested no closing.
587
   */
588
  public boolean closeAll() {
589
    var closable = true;
590
591
    for( final var tabPane : mTabPanes ) {
592
      final var tabIterator = tabPane.getTabs().iterator();
593
594
      while( tabIterator.hasNext() ) {
595
        final var tab = tabIterator.next();
596
        final var resource = tab.getContent();
597
598
        // The definition panes auto-save, so being specific here prevents
599
        // closing the definitions in the situation where the user wants to
600
        // continue editing (i.e., possibly save unsaved work).
601
        if( !(resource instanceof TextEditor) ) {
602
          continue;
603
        }
604
605
        if( canClose( (TextEditor) resource ) ) {
606
          tabIterator.remove();
607
          close( tab );
608
        }
609
        else {
610
          closable = false;
611
        }
612
      }
613
    }
614
615
    return closable;
616
  }
617
618
  /**
619
   * Calls the tab's {@link Tab#getOnClosed()} handler to carry out a close
620
   * event.
621
   *
622
   * @param tab The {@link Tab} that was closed.
623
   */
624
  private void close( final Tab tab ) {
625
    assert tab != null;
626
627
    final var handler = tab.getOnClosed();
628
629
    if( handler != null ) {
630
      handler.handle( new ActionEvent() );
631
    }
632
  }
633
634
  /**
635
   * Closes the active tab; delegates to {@link #canClose(TextResource)}.
636
   */
637
  public void close() {
638
    final var editor = getTextEditor();
639
640
    if( canClose( editor ) ) {
641
      close( editor );
642
      removeProcessor( editor );
643
    }
644
  }
645
646
  /**
647
   * Closes the given {@link TextResource}. This must not be called from within
648
   * a loop that iterates over the tab panes using {@code forEach}, lest a
649
   * concurrent modification exception be thrown.
650
   *
651
   * @param resource The {@link TextResource} to close, without confirming with
652
   *                 the user.
653
   */
654
  private void close( final TextResource resource ) {
655
    getTab( resource ).ifPresent(
656
      tab -> {
657
        close( tab );
658
        tab.getTabPane().getTabs().remove( tab );
659
      }
660
    );
661
  }
662
663
  /**
664
   * Answers whether the given {@link TextResource} may be closed.
665
   *
666
   * @param editor The {@link TextResource} to try closing.
667
   * @return {@code true} when the editor may be closed; {@code false} when
668
   * the user has requested to keep the editor open.
669
   */
670
  private boolean canClose( final TextResource editor ) {
671
    final var editorTab = getTab( editor );
672
    final var canClose = new AtomicBoolean( true );
673
674
    if( editor.isModified() ) {
675
      final var filename = new StringBuilder();
676
      editorTab.ifPresent( tab -> filename.append( tab.getText() ) );
677
678
      final var message = sNotifier.createNotification(
679
        Messages.get( "Alert.file.close.title" ),
680
        Messages.get( "Alert.file.close.text" ),
681
        filename.toString()
682
      );
683
684
      final var dialog = sNotifier.createConfirmation( getWindow(), message );
685
686
      dialog.showAndWait().ifPresent(
687
        save -> canClose.set( save == YES ? editor.save() : save == NO )
688
      );
689
    }
690
691
    return canClose.get();
692
  }
693
694
  private void iterateEditors( final Consumer<TextEditor> consumer ) {
695
    mTabPanes.forEach(
696
      tp -> tp.getTabs().forEach( tab -> {
697
        final var node = tab.getContent();
698
699
        if( node instanceof final TextEditor editor ) {
700
          consumer.accept( editor );
701
        }
702
      } )
703
    );
704
  }
705
706
  /**
707
   * Adds the HTML preview tab to its own, singular tab pane.
708
   */
709
  public void viewPreview() {
710
    addTab( mPreview, TEXT_HTML, "Pane.preview.title" );
711
  }
712
713
  /**
714
   * Adds the document outline tab to its own, singular tab pane.
715
   */
716
  public void viewOutline() {
717
    addTab( mOutline, APP_DOCUMENT_OUTLINE, "Pane.outline.title" );
718
  }
719
720
  public void viewStatistics() {
721
    addTab( mStatistics, APP_DOCUMENT_STATISTICS, "Pane.statistics.title" );
722
  }
723
724
  public void viewFiles() {
725
    try {
726
      final var factory = new FilePickerFactory( getWorkspace() );
727
      final var fileManager = factory.createModeless();
728
      addTab( fileManager, APP_FILE_MANAGER, "Pane.files.title" );
729
    } catch( final Exception ex ) {
730
      clue( ex );
731
    }
732
  }
733
734
  public void viewRefresh() {
735
    mPreview.refresh();
736
    Engine.clear();
737
    mRBootstrapController.update();
738
  }
739
740
  private void addTab(
741
    final Node node, final MediaType mediaType, final String key ) {
742
    final var tabPane = obtainTabPane( mediaType );
743
744
    for( final var tab : tabPane.getTabs() ) {
745
      if( tab.getContent() == node ) {
746
        return;
747
      }
748
    }
749
750
    tabPane.getTabs().add( createTab( get( key ), node ) );
751
    addTabPane( tabPane );
752
  }
753
754
  /**
755
   * Returns the tab that contains the given {@link TextEditor}.
756
   *
757
   * @param editor The {@link TextEditor} instance to find amongst the tabs.
758
   * @return The first tab having content that matches the given tab.
759
   */
760
  private Optional<Tab> getTab( final TextResource editor ) {
761
    return mTabPanes.stream()
762
                    .flatMap( pane -> pane.getTabs().stream() )
763
                    .filter( tab -> editor.equals( tab.getContent() ) )
764
                    .findFirst();
765
  }
766
767
  private TextDefinition createDefinitionEditor( final File file ) {
768
    final var editor = new DefinitionEditor( file, createTreeTransformer() );
769
770
    editor.addTreeChangeHandler( mTreeHandler );
771
772
    return editor;
773
  }
774
775
  /**
776
   * Creates a new {@link DefinitionEditor} wrapped in a listener that
777
   * is used to detect when the active {@link DefinitionEditor} has changed.
778
   * Upon changing, the variables are interpolated and the active text editor
779
   * is refreshed.
780
   *
781
   * @param workspace Has the most recently edited definitions file name.
782
   * @return A newly configured property that represents the active
783
   * {@link DefinitionEditor}, never {@code null}.
784
   */
785
  private TextDefinition createDefinitionEditor(
786
    final Workspace workspace ) {
787
    final var fileProperty = workspace.fileProperty( KEY_UI_RECENT_DEFINITION );
788
    final var filename = fileProperty.get();
789
    final SetProperty<String> recent = workspace.setsProperty(
790
      KEY_UI_RECENT_OPEN_PATH
791
    );
792
793
    // Open the most recently used YAML definition file.
794
    for( final var recentFile : recent.get() ) {
795
      if( recentFile.endsWith( filename.toString() ) ) {
796
        return createDefinitionEditor( new File( recentFile ) );
797
      }
798
    }
799
800
    return createDefaultDefinitionEditor();
801
  }
802
803
  private TextDefinition createDefaultDefinitionEditor() {
804
    final var transformer = createTreeTransformer();
805
    return new DefinitionEditor( transformer );
806
  }
807
808
  private TreeTransformer createTreeTransformer() {
809
    return new YamlTreeTransformer();
810
  }
811
812
  private Tab createTab( final String filename, final Node node ) {
813
    return new DetachableTab( filename, node );
814
  }
815
816
  private Tab createTab( final File file ) {
817
    final var r = createTextResource( file );
818
    final var filename = r.getFilename();
819
    final var tab = createTab( filename, r.getNode() );
820
821
    r.modifiedProperty().addListener(
822
      ( c, o, n ) -> tab.setText( filename + (n ? "*" : "") )
823
    );
824
825
    // This is called when either the tab is closed by the user clicking on
826
    // the tab's close icon or when closing (all) from the file menu.
827
    tab.setOnClosed(
828
      __ -> getRecentFiles().remove( file.getAbsolutePath() )
829
    );
830
831
    // When closing a tab, give focus to the newly revealed tab.
832
    tab.selectedProperty().addListener( ( c, o, n ) -> {
833
      if( n != null && n ) {
834
        final var pane = tab.getTabPane();
835
836
        if( pane != null ) {
837
          pane.requestFocus();
838
        }
839
      }
840
    } );
841
842
    tab.tabPaneProperty().addListener( ( cPane, oPane, nPane ) -> {
843
      if( nPane != null ) {
844
        nPane.focusedProperty().addListener( ( c, o, n ) -> {
845
          if( n != null && n ) {
846
            final var selected = nPane.getSelectionModel().getSelectedItem();
847
            final var node = selected.getContent();
848
            node.requestFocus();
849
          }
850
        } );
851
      }
852
    } );
853
854
    return tab;
855
  }
856
857
  /**
858
   * Creates bins for the different {@link MediaType}s, which eventually are
859
   * added to the UI as separate tab panes. If ever a general-purpose scene
860
   * exporter is developed to serialize a scene to an FXML file, this could
861
   * be replaced by such a class.
862
   * <p>
863
   * When binning the files, this makes sure that at least one file exists
864
   * for every type. If the user has opted to close a particular type (such
865
   * as the definition pane), the view will suppressed elsewhere.
866
   * </p>
867
   * <p>
868
   * The order that the binned files are returned will be reflected in the
869
   * order that the corresponding panes are rendered in the UI.
870
   * </p>
871
   *
872
   * @param paths The file paths to bin according to their type.
873
   * @return An in-order list of files, first by structured definition files,
874
   * then by plain text documents.
875
   */
876
  private List<File> collect( final SetProperty<String> paths ) {
877
    // Treat all files destined for the text editor as plain text documents
878
    // so that they are added to the same pane. Grouping by TEXT_PLAIN is a
879
    // bit arbitrary, but means explicitly capturing TEXT_PLAIN isn't needed.
880
    final Function<MediaType, MediaType> bin =
881
      m -> PLAIN_TEXT_FORMAT.contains( m ) ? TEXT_PLAIN : m;
882
883
    // Create two groups: YAML files and plain text files. The order that
884
    // the elements are listed in the enumeration for media types determines
885
    // what files are loaded first. Variable definitions come before all other
886
    // plain text documents.
887
    final var bins = paths
888
      .stream()
889
      .collect(
890
        groupingBy(
891
          path -> bin.apply( fromFilename( path ) ),
892
          () -> new TreeMap<>( Enum::compareTo ),
893
          Collectors.toList()
894
        )
895
      );
896
897
    bins.putIfAbsent( TEXT_YAML, List.of( DEFINITION_DEFAULT.toString() ) );
898
    bins.putIfAbsent( TEXT_PLAIN, List.of( DOCUMENT_DEFAULT.toString() ) );
899
900
    final var result = new LinkedList<File>();
901
902
    // Ensure that the same types are listed together (keep insertion order).
903
    bins.forEach( ( mediaType, files ) -> result.addAll(
904
      files.stream().map( File::new ).toList() )
905
    );
906
907
    return result;
908
  }
909
910
  /**
911
   * Force the active editor to update, which will cause the processor
912
   * to re-evaluate the interpolated definition map thereby updating the
913
   * preview pane.
914
   *
915
   * @param editor Contains the source document to update in the preview pane.
916
   */
917
  private void process( final TextEditor editor ) {
918
    // Ensure processing does not run on the JavaFX thread, which frees the
919
    // text editor immediately for caret movement. The preview will have a
920
    // slight delay when catching up to the caret position.
921
    final var task = new Task<Void>() {
922
      @Override
923
      public Void call() {
924
        try {
925
          final var p = mProcessors.getOrDefault( editor, IDENTITY );
926
          p.apply( editor == null ? "" : editor.getText() );
927
        } catch( final Exception ex ) {
928
          clue( ex );
929
        }
930
931
        return null;
932
      }
933
    };
934
935
    // TODO: Each time the editor successfully runs the processor, the task is
936
    //   considered successful. Due to the rapid-fire nature of processing
937
    //   (e.g., keyboard navigation, fast typing), it isn't necessary to
938
    //   scroll each time.
939
    //   The algorithm:
940
    //   1. Peek at the oldest time.
941
    //   2. If the difference between the oldest time and current time exceeds
942
    //      250 milliseconds, then invoke the scrolling.
943
    //   3. Insert the current time into the circular queue.
944
    task.setOnSucceeded(
945
      e -> invokeLater( () -> mPreview.scrollTo( CARET_ID ) )
946
    );
947
948
    // Prevents multiple process requests from executing simultaneously (due
949
    // to having a restricted queue size).
950
    sExecutor.execute( task );
951
  }
952
953
  /**
954
   * Lazily creates a {@link TabPane} configured to listen for tab select
955
   * events. The tab pane is associated with a given media type so that
956
   * similar files can be grouped together.
957
   *
958
   * @param mediaType The media type to associate with the tab pane.
959
   * @return An instance of {@link TabPane} that will handle tab docking.
960
   */
961
  private TabPane obtainTabPane( final MediaType mediaType ) {
962
    for( final var pane : mTabPanes ) {
963
      for( final var tab : pane.getTabs() ) {
964
        final var node = tab.getContent();
965
966
        if( node instanceof TextResource r && r.supports( mediaType ) ) {
967
          return pane;
968
        }
969
      }
970
    }
971
972
    final var pane = createTabPane();
973
    mTabPanes.add( pane );
974
    return pane;
975
  }
976
977
  /**
978
   * Creates an initialized {@link TabPane} instance.
979
   *
980
   * @return A new {@link TabPane} with all listeners configured.
981
   */
982
  private TabPane createTabPane() {
983
    final var tabPane = new DetachableTabPane();
984
985
    initStageOwnerFactory( tabPane );
986
    initTabListener( tabPane );
987
988
    return tabPane;
989
  }
990
991
  /**
992
   * When any {@link DetachableTabPane} is detached from the main window,
993
   * the stage owner factory must be given its parent window, which will
994
   * own the child window. The parent window is the {@link MainPane}'s
995
   * {@link Scene}'s {@link Window} instance.
996
   *
997
   * <p>
998
   * This will derives the new title from the main window title, incrementing
999
   * the window count to help uniquely identify the child windows.
1000
   * </p>
1001
   *
1002
   * @param tabPane A new {@link DetachableTabPane} to configure.
1003
   */
1004
  private void initStageOwnerFactory( final DetachableTabPane tabPane ) {
1005
    tabPane.setStageOwnerFactory( stage -> {
1006
      final var title = get(
1007
        "Detach.tab.title",
1008
        ((Stage) getWindow()).getTitle(), ++mWindowCount
1009
      );
1010
      stage.setTitle( title );
1011
1012
      return getScene().getWindow();
1013
    } );
1014
  }
1015
1016
  /**
1017
   * Responsible for configuring the content of each {@link DetachableTab} when
1018
   * it is added to the given {@link DetachableTabPane} instance.
1019
   * <p>
1020
   * For {@link TextEditor} contents, an instance of {@link ScrollEventHandler}
1021
   * is initialized to perform synchronized scrolling between the editor and
1022
   * its preview window. Additionally, the last tab in the tab pane's list of
1023
   * tabs is given focus.
1024
   * </p>
1025
   * <p>
1026
   * Note that multiple tabs can be added simultaneously.
1027
   * </p>
1028
   *
1029
   * @param tabPane A new {@link TabPane} to configure.
1030
   */
1031
  private void initTabListener( final TabPane tabPane ) {
1032
    tabPane.getTabs().addListener(
1033
      ( final ListChangeListener.Change<? extends Tab> listener ) -> {
1034
        while( listener.next() ) {
1035
          if( listener.wasAdded() ) {
1036
            final var tabs = listener.getAddedSubList();
1037
1038
            tabs.forEach( tab -> {
1039
              final var node = tab.getContent();
1040
1041
              if( node instanceof TextEditor ) {
1042
                initScrollEventListener( tab );
1043
              }
1044
            } );
1045
1046
            // Select and give focus to the last tab opened.
1047
            final var index = tabs.size() - 1;
1048
            if( index >= 0 ) {
1049
              final var tab = tabs.get( index );
1050
              tabPane.getSelectionModel().select( tab );
1051
              tab.getContent().requestFocus();
1052
            }
1053
          }
1054
        }
1055
      }
1056
    );
1057
  }
1058
1059
  /**
1060
   * Synchronizes scrollbar positions between the given {@link Tab} that
1061
   * contains an instance of {@link TextEditor} and {@link HtmlPreview} pane.
1062
   *
1063
   * @param tab The container for an instance of {@link TextEditor}.
1064
   */
1065
  private void initScrollEventListener( final Tab tab ) {
1066
    final var editor = (TextEditor) tab.getContent();
1067
    final var scrollPane = editor.getScrollPane();
1068
    final var scrollBar = mPreview.getVerticalScrollBar();
1069
    final var handler = new ScrollEventHandler( scrollPane, scrollBar );
1070
1071
    handler.enabledProperty().bind( tab.selectedProperty() );
1072
  }
1073
1074
  private void addTabPane( final int index, final TabPane tabPane ) {
1075
    final var items = getItems();
1076
1077
    if( !items.contains( tabPane ) ) {
1078
      items.add( index, tabPane );
1079
    }
1080
  }
1081
1082
  private void addTabPane( final TabPane tabPane ) {
1083
    addTabPane( getItems().size(), tabPane );
1084
  }
1085
1086
  private GenericBuilder<Mutator, ProcessorContext> processorContextBuilder() {
1087
    final var w = getWorkspace();
1088
1089
    return builder()
1090
      .with( Mutator::setDefinitions, this::getDefinitions )
1091
      .with( Mutator::setLocale, w::getLocale )
1092
      .with( Mutator::setMetadata, w::getMetadata )
1093
      .with( Mutator::setThemesDir, w::getThemesPath )
1094
      .with( Mutator::setCachesDir,
1095
             () -> w.getFile( KEY_CACHES_DIR ) )
1096
      .with( Mutator::setImagesDir,
1097
             () -> w.getFile( KEY_IMAGES_DIR ) )
1098
      .with( Mutator::setImageOrder,
1099
             () -> w.getString( KEY_IMAGES_ORDER ) )
1100
      .with( Mutator::setImageServer,
1101
             () -> w.getString( KEY_IMAGES_SERVER ) )
1102
      .with( Mutator::setFontsDir,
1103
             () -> w.getFile( KEY_TYPESET_CONTEXT_FONTS_DIR ) )
1104
      .with( Mutator::setCaret,
1105
             () -> getTextEditor().getCaret() )
1106
      .with( Mutator::setSigilBegan,
1107
             () -> w.getString( KEY_DEF_DELIM_BEGAN ) )
1108
      .with( Mutator::setSigilEnded,
1109
             () -> w.getString( KEY_DEF_DELIM_ENDED ) )
1110
      .with( Mutator::setRScript,
1111
             () -> w.getString( KEY_R_SCRIPT ) )
1112
      .with( Mutator::setRWorkingDir,
1113
             () -> w.getFile( KEY_R_DIR ).toPath() )
1114
      .with( Mutator::setCurlQuotes,
1115
             () -> w.getBoolean( KEY_TYPESET_TYPOGRAPHY_QUOTES ) )
1116
      .with( Mutator::setAutoRemove,
1117
             () -> w.getBoolean( KEY_TYPESET_CONTEXT_CLEAN ) );
1118
  }
1119
1120
  public ProcessorContext createProcessorContext() {
1121
    return createProcessorContextBuilder( NONE ).build();
1122
  }
1123
1124
  private GenericBuilder<Mutator, ProcessorContext> createProcessorContextBuilder(
1125
    final ExportFormat format ) {
1126
    final var textEditor = getTextEditor();
1127
    final var sourcePath = textEditor.getPath();
1128
1129
    return processorContextBuilder()
1130
      .with( Mutator::setSourcePath, sourcePath )
1131
      .with( Mutator::setExportFormat, format );
1132
  }
1133
1134
  /**
1135
   * @param targetPath Used when exporting to a PDF file (binary).
1136
   * @param format     Used when processors export to a new text format.
1137
   * @return A new {@link ProcessorContext} to use when creating an instance of
1138
   * {@link Processor}.
1139
   */
1140
  public ProcessorContext createProcessorContext(
1141
    final Path targetPath, final ExportFormat format ) {
1142
    assert targetPath != null;
1143
    assert format != null;
1144
1145
    return createProcessorContextBuilder( format )
1146
      .with( Mutator::setTargetPath, targetPath )
1147
      .build();
1148
  }
1149
1150
  /**
1151
   * @param sourcePath Used by {@link ProcessorFactory} to determine
1152
   *                   {@link Processor} type to create based on file type.
1153
   * @return A new {@link ProcessorContext} to use when creating an instance of
1154
   * {@link Processor}.
1155
   */
1156
  private ProcessorContext createProcessorContext( final Path sourcePath ) {
1157
    return processorContextBuilder()
1158
      .with( Mutator::setSourcePath, sourcePath )
1159
      .with( Mutator::setExportFormat, NONE )
1160
      .build();
1161
  }
1162
1163
  private TextResource createTextResource( final File file ) {
1164
    if( fromFilename( file ) == TEXT_YAML ) {
1165
      final var editor = createDefinitionEditor( file );
1166
      mDefinitionEditor.set( editor );
1167
      return editor;
1168
    }
1169
    else {
1170
      final var editor = createMarkdownEditor( file );
1171
      mTextEditor.set( editor );
1172
      return editor;
1173
    }
1174
  }
1175
1176
  /**
1177
   * Creates an instance of {@link MarkdownEditor} that listens for both
1178
   * caret change events and text change events. Text change events must
1179
   * take priority over caret change events because it's possible to change
1180
   * the text without moving the caret (e.g., delete selected text).
1181
   *
1182
   * @param inputFile The file containing contents for the text editor.
1183
   * @return A non-null text editor.
1184
   */
1185
  private MarkdownEditor createMarkdownEditor( final File inputFile ) {
1186
    final var editor = new MarkdownEditor( inputFile, getWorkspace() );
1187
1188
    // Listener for editor modifications or caret position changes.
1189
    editor.addDirtyListener( ( c, o, n ) -> {
1190
      if( n ) {
1191
        // Reset the status bar after changing the text.
1192
        clue();
1193
1194
        // Processing the text may update the status bar.
1195
        process( editor );
1196
1197
        // Update the caret position in the status bar.
1198
        CaretMovedEvent.fire( editor.getCaret() );
1199
      }
1200
    } );
1201
1202
    editor.addEventListener(
1203
      keyPressed( SPACE, CONTROL_DOWN ), this::autoinsert
1204
    );
1205
1206
    editor.addEventListener(
1207
      keyPressed( ENTER, ALT_DOWN ), event -> mEditorSpeller.autofix( editor )
1208
    );
1209
1210
    final var textArea = editor.getTextArea();
1211
1212
    // Spell check when the paragraph changes.
1213
    textArea
1214
      .plainTextChanges()
1215
      .filter( p -> !p.isIdentity() )
1216
      .subscribe( change -> mEditorSpeller.checkParagraph( textArea, change ) );
1217
1218
    // Store the caret position to restore it after restarting the application.
1219
    textArea.caretPositionProperty().addListener(
1220
      ( c, o, n ) ->
1221
        getWorkspace().integerProperty( KEY_UI_RECENT_OFFSET ).setValue( n )
1222
    );
1223
1224
    // Check the entire document after the spellchecker is initialized (with
1225
    // a valid lexicon) so that only the current paragraph need be scanned
1226
    // while editing. (Technically, only the most recently modified word must
1227
    // be scanned.)
1228
    mSpellChecker.addListener(
1229
      ( c, o, n ) -> runLater(
1230
        () -> iterateEditors( mEditorSpeller::checkDocument )
1231
      )
1232
    );
1233
1234
    // Check the entire document after it has been loaded.
1235
    mEditorSpeller.checkDocument( editor );
1236
1237
    return editor;
1238
  }
1239
1240
  /**
1241
   * Creates a processor for an editor, provided one doesn't already exist.
1242
   *
1243
   * @param editor The editor that potentially requires an associated processor.
1244
   */
1245
  private void updateProcessors( final TextEditor editor ) {
1246
    final var path = editor.getFile().toPath();
1247
1248
    mProcessors.computeIfAbsent(
1249
      editor, p -> createProcessors(
1250
        createProcessorContext( path ),
1251
        createHtmlPreviewProcessor()
1252
      )
1253
    );
1254
  }
1255
1256
  /**
1257
   * Removes a processor for an editor. This is required because a file may
1258
   * change type while editing (e.g., from plain Markdown to R Markdown).
1259
   * In the case that an editor's type changes, its associated processor must
1260
   * be changed accordingly.
1261
   *
1262
   * @param editor The editor that potentially requires an associated processor.
1263
   */
1264
  private void removeProcessor( final TextEditor editor ) {
1265
    mProcessors.remove( editor );
1266
  }
1267
1268
  /**
1269
   * Creates a {@link Processor} capable of rendering an HTML document onto
1270
   * a GUI widget.
1271
   *
1272
   * @return The {@link Processor} for rendering an HTML document.
1273
   */
1274
  private Processor<String> createHtmlPreviewProcessor() {
1275
    return new HtmlPreviewProcessor( getPreview() );
1276
  }
1277
1278
  /**
1279
   * Creates a spellchecker that accepts all words as correct. This allows
1280
   * the spellchecker property to be initialized to a known valid value.
1281
   *
1282
   * @return A wrapped {@link PermissiveSpeller}.
1283
   */
1284
  private ObjectProperty<SpellChecker> createSpellChecker() {
1285
    return new SimpleObjectProperty<>( new PermissiveSpeller() );
1286
  }
1287
1288
  private TextEditorSpellChecker createTextEditorSpellChecker(
1289
    final ObjectProperty<SpellChecker> spellChecker ) {
1290
    return new TextEditorSpellChecker( spellChecker );
1291
  }
1292
1293
  /**
1294
   * Delegates to {@link #autoinsert()}.
1295
   *
1296
   * @param keyEvent Ignored.
1297
   */
1298
  private void autoinsert( final KeyEvent keyEvent ) {
1299
    autoinsert();
1300
  }
1301
1302
  /**
1303
   * Finds a node that matches the word at the caret, then inserts the
1304
   * corresponding definition. The definition token delimiters depend on
1305
   * the type of file being edited.
1306
   */
1307
  public void autoinsert() {
1308
    mVariableNameInjector.autoinsert( getTextEditor(), getTextDefinition() );
1309
  }
1310
1311
  private Tooltip createTooltip( final File file ) {
1312
    final var path = file.toPath();
1313
    final var tooltip = new Tooltip( path.toString() );
1314
1315
    tooltip.setShowDelay( millis( 200 ) );
1316
1317
    return tooltip;
1318
  }
1319
1320
  public HtmlPreview getPreview() {
1321
    return mPreview;
1322
  }
1323
1324
  /**
1325
   * Returns the active text editor.
1326
   *
1327
   * @return The text editor that currently has focus.
1328
   */
1329
  public TextEditor getTextEditor() {
1330
    return mTextEditor.get();
1331
  }
1332
1333
  /**
1334
   * Returns the active text editor property.
1335
   *
1336
   * @return The property container for the active text editor.
1337
   */
1338
  public ReadOnlyObjectProperty<TextEditor> textEditorProperty() {
1339
    return mTextEditor;
1340
  }
1341
1342
  /**
1343
   * Returns the active text definition editor.
1344
   *
1345
   * @return The property container for the active definition editor.
1346
   */
1347
  public TextDefinition getTextDefinition() {
1348
    return mDefinitionEditor.get();
1349
  }
1350
1351
  /**
1352
   * Returns the active variable definitions, without any interpolation.
1353
   * Interpolation is a responsibility of {@link Processor} instances.
1354
   *
1355
   * @return The key-value pairs, not interpolated.
1356
   */
1357
  private Map<String, String> getDefinitions() {
1358
    return getTextDefinition().getDefinitions();
13391359
  }
13401360
M src/main/java/com/keenwrite/editors/TextResource.java
8080
   */
8181
  default MediaType getMediaType() {
82
    return MediaType.valueFrom( getFile() );
82
    return MediaType.fromFilename( getFile() );
8383
  }
8484
M src/main/java/com/keenwrite/io/MediaType.java
173173
   * {@link File}'s file name extension.
174174
   */
175
  public static MediaType valueFrom( final File file ) {
175
  public static MediaType fromFilename( final File file ) {
176176
    assert file != null;
177177
    return fromFilename( file.getName() );
...
201201
   * {@link File}'s file name extension.
202202
   */
203
  public static MediaType valueFrom( final Path path ) {
203
  public static MediaType fromFilename( final Path path ) {
204204
    assert path != null;
205
    return valueFrom( path.toFile() );
205
    return fromFilename( path.toFile() );
206206
  }
207207
M src/main/java/com/keenwrite/processors/ProcessorContext.java
431431
    assert path != null;
432432
433
    return valueFrom( path ) == TEXT_PROPERTIES
433
    return MediaType.fromFilename( path ) == TEXT_PROPERTIES
434434
      ? createPropertyKeyOperator()
435435
      : createDefinitionKeyOperator();
M src/main/java/com/keenwrite/processors/markdown/BaseMarkdownProcessor.java
8080
  @Override
8181
  public String apply( final String markdown ) {
82
    return toXhtml( toHtml( parse( markdown ) ) );
82
    return toXhtml( toHtml( toNode( markdown ) ) );
8383
  }
8484
...
101101
   * @return The given {@link Node} as an HTML string.
102102
   */
103
  public String toHtml( final Node node ) {
103
  private String toHtml( final Node node ) {
104104
    return getRenderer().render( node );
105105
  }
M src/main/java/com/keenwrite/processors/markdown/MarkdownProcessor.java
6262
  List<Extension> createExtensions( final ProcessorContext context ) {
6363
    final var inputPath = context.getSourcePath();
64
    final var mediaType = MediaType.valueFrom( inputPath );
64
    final var mediaType = MediaType.fromFilename( inputPath );
6565
    final Processor<String> processor;
6666
    final Function<String, String> evaluator;
M src/main/java/com/keenwrite/processors/r/Engine.java
2121
   */
2222
  private static final Map<String, String> sCache =
23
    new BoundedCache<>( 512 );
23
    new BoundedCache<>( 768 );
2424
2525
  /**
M src/main/java/com/keenwrite/processors/r/RBootstrapController.java
2626
2727
  private final Workspace mWorkspace;
28
  private final Supplier<Map<String, String>> mDefinitions;
28
  private final Supplier<Map<String, String>> mSupplier;
2929
3030
  public RBootstrapController(
3131
    final Workspace workspace,
3232
    final Supplier<Map<String, String>> supplier ) {
33
    assert workspace != null;
34
    assert supplier != null;
35
3336
    mWorkspace = workspace;
34
    mDefinitions = supplier;
37
    mSupplier = supplier;
3538
3639
    mWorkspace.stringProperty( KEY_R_SCRIPT )
3740
              .addListener( ( c, o, n ) -> update() );
3841
    mWorkspace.fileProperty( KEY_R_DIR )
3942
              .addListener( ( c, o, n ) -> update() );
43
44
    // Add the definitions immediately upon loading them.
45
    update();
4046
  }
4147
...
5056
    if( !bootstrap.isBlank() ) {
5157
      final var dir = getRWorkingDirectory();
52
      final var definitions = mDefinitions.get();
58
      final var definitions = mSupplier.get();
5359
54
      // A problem with the bootstrap script is likely caused by variables
55
      // not being loaded. This implies that the R processor is being invoked
56
      // too soon.
5760
      update( bootstrap, dir, definitions );
5861
    }
M src/main/java/com/keenwrite/processors/r/RInlineEvaluator.java
4545
      int index = 0;
4646
      int began;
47
      int ended;
47
      int ended = 0;
4848
49
      while( (began = text.indexOf( PREFIX, index )) >= 0 ) {
49
      while( (began = text.indexOf( PREFIX, index )) >= 0 && ended > -1 ) {
5050
        buffer.append( text, index, began );
5151
52
        // If the R expression has no definite end, this returns -1.
5253
        ended = text.indexOf( SUFFIX, began + 1 );
5354
M src/main/java/com/keenwrite/ui/fonts/IconFactory.java
9797
    }
9898
    else {
99
      final var mediaType = MediaType.valueFrom( path );
99
      final var mediaType = MediaType.fromFilename( path );
100100
      final var mte = MediaTypeExtension.valueFrom( mediaType );
101101