Dave Jarvis' Repositories

M .gitignore
1111
.idea
1212
themes
13
quotes
1314
M R/pluralize.R
222222
    "octopus" = "octopuses",
223223
    "ox" = "oxen",
224
    "passerby" = "passersby",
224225
    "soliloquy" = "soliloquies",
225226
    "trilby" = "trilbys"
M R/possessive.R
6060
}
6161
62
pro.sub <- function( s ) {
63
  if( s == 'm' ) {
64
    s <- 'he'
65
  }
66
  else if( s == 'f' ) {
67
    s <- 'she'
68
  }
69
  else {
70
    s <- 'their'
71
  }
72
73
  s
74
}
75
76
pro.obj <- function( s ) {
77
  if( s == 'm' ) {
78
    s <- 'him'
79
  }
80
  else if( s == 'f' ) {
81
    s <- 'her'
82
  }
83
  else {
84
    s <- 'their'
85
  }
86
87
  s
88
}
89
90
pro.ref <- function( s ) {
91
  if( s == 'm' ) {
92
    s <- 'himself'
93
  }
94
  else if( s == 'f' ) {
95
    s <- 'herself'
96
  }
97
  else {
98
    s <- 'themselves'
99
  }
100
101
  s
102
}
103
104
pro.pos <- function( s ) {
105
  if( s == 'm' ) {
106
    s = 'his'
107
  }
108
  else if( s == 'f' ) {
109
    s <- 'her'
110
  }
111
  else {
112
    s <- 'theirs'
113
  }
114
115
  s
116
}
117
118
pro.noun <- function( s ) {
119
  if( s == 'm' ) {
120
    s = 'man'
121
  }
122
  else if( s == 'f' ) {
123
    s <- 'woman'
124
  }
125
126
  s
127
}
128
62129
M build.gradle
3939
    "--add-exports=javafx.graphics/com.sun.javafx.geom=ALL-UNNAMED",
4040
    "--add-exports=javafx.graphics/com.sun.javafx.scene.traversal=ALL-UNNAMED",
41
    "--add-exports=javafx.graphics/com.sun.javafx.scene.traversal=ALL-UNNAMED",
4241
    "--add-exports=javafx.graphics/com.sun.javafx.text=ALL-UNNAMED",
4342
]
...
5251
  def v_junit = '5.7.2'
5352
  def v_flexmark = '0.62.2'
54
  def v_jackson = '2.12.3'
53
  def v_jackson = '2.12.5'
5554
  def v_batik = '1.14'
5655
  def v_wheatsheaf = '2.0.1'
...
8786
8887
  // HTML parsing and rendering
89
  implementation 'org.jsoup:jsoup:1.13.1'
90
  implementation 'org.xhtmlrenderer:flying-saucer-core:9.1.20'
88
  implementation 'org.jsoup:jsoup:1.14.2'
89
  implementation 'org.xhtmlrenderer:flying-saucer-core:9.1.22'
9190
9291
  // R
M gradle.properties
1
org.gradle.jvmargs=-Xmx1G -XX:MaxPermSize=512m
1
org.gradle.jvmargs=-Xmx1G
22
org.gradle.daemon=true
33
org.gradle.parallel=true
M libs/keenquotes.jar
Binary file
M src/main/java/com/keenwrite/MainPane.java
5151
import java.util.*;
5252
import java.util.concurrent.ExecutorService;
53
import java.util.concurrent.atomic.AtomicBoolean;
54
import java.util.function.Function;
55
import java.util.stream.Collectors;
56
57
import static com.keenwrite.ExportFormat.NONE;
58
import static com.keenwrite.Messages.get;
59
import static com.keenwrite.constants.Constants.*;
60
import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG_NODE;
61
import static com.keenwrite.events.Bus.register;
62
import static com.keenwrite.events.HyperlinkOpenEvent.fireHyperlinkOpenEvent;
63
import static com.keenwrite.events.StatusEvent.clue;
64
import static com.keenwrite.io.MediaType.*;
65
import static com.keenwrite.preferences.WorkspaceKeys.*;
66
import static com.keenwrite.processors.IdentityProcessor.IDENTITY;
67
import static com.keenwrite.processors.ProcessorFactory.createProcessors;
68
import static java.lang.String.format;
69
import static java.lang.System.getProperty;
70
import static java.util.concurrent.Executors.newFixedThreadPool;
71
import static java.util.stream.Collectors.groupingBy;
72
import static javafx.application.Platform.runLater;
73
import static javafx.scene.control.Alert.AlertType.ERROR;
74
import static javafx.scene.control.ButtonType.*;
75
import static javafx.scene.control.TabPane.TabClosingPolicy.ALL_TABS;
76
import static javafx.scene.input.KeyCode.SPACE;
77
import static javafx.scene.input.KeyCombination.CONTROL_DOWN;
78
import static javafx.util.Duration.millis;
79
import static javax.swing.SwingUtilities.invokeLater;
80
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
81
82
/**
83
 * Responsible for wiring together the main application components for a
84
 * particular workspace (project). These include the definition views,
85
 * text editors, and preview pane along with any corresponding controllers.
86
 */
87
public final class MainPane extends SplitPane {
88
  private static final ExecutorService sExecutor = newFixedThreadPool( 1 );
89
90
  private static final Notifier sNotifier = Services.load( Notifier.class );
91
92
  /**
93
   * Used when opening files to determine how each file should be binned and
94
   * therefore what tab pane to be opened within.
95
   */
96
  private static final Set<MediaType> PLAIN_TEXT_FORMAT = Set.of(
97
    TEXT_MARKDOWN, TEXT_R_MARKDOWN, UNDEFINED
98
  );
99
100
  /**
101
   * Prevents re-instantiation of processing classes.
102
   */
103
  private final Map<TextResource, Processor<String>> mProcessors =
104
    new HashMap<>();
105
106
  private final Workspace mWorkspace;
107
108
  /**
109
   * Groups similar file type tabs together.
110
   */
111
  private final List<TabPane> mTabPanes = new ArrayList<>();
112
113
  /**
114
   * Stores definition names and values.
115
   */
116
  private final Map<String, String> mResolvedMap =
117
    new HashMap<>( MAP_SIZE_DEFAULT );
118
119
  /**
120
   * Renders the actively selected plain text editor tab.
121
   */
122
  private final HtmlPreview mPreview;
123
124
  /**
125
   * Provides an interactive document outline.
126
   */
127
  private final DocumentOutline mOutline = new DocumentOutline();
128
129
  /**
130
   * Changing the active editor fires the value changed event. This allows
131
   * refreshes to happen when external definitions are modified and need to
132
   * trigger the processing chain.
133
   */
134
  private final ObjectProperty<TextEditor> mActiveTextEditor =
135
    createActiveTextEditor();
136
137
  /**
138
   * Changing the active definition editor fires the value changed event. This
139
   * allows refreshes to happen when external definitions are modified and need
140
   * to trigger the processing chain.
141
   */
142
  private final ObjectProperty<TextDefinition> mActiveDefinitionEditor =
143
    createActiveDefinitionEditor( mActiveTextEditor );
144
145
  /**
146
   * Tracks the number of detached tab panels opened into their own windows,
147
   * which allows unique identification of subordinate windows by their title.
148
   * It is doubtful more than 128 windows, much less 256, will be created.
149
   */
150
  private byte mWindowCount;
151
152
  /**
153
   * Called when the definition data is changed.
154
   */
155
  private final EventHandler<TreeModificationEvent<Event>> mTreeHandler =
156
    event -> {
157
      final var editor = mActiveDefinitionEditor.get();
158
159
      resolve( editor );
160
      process( getActiveTextEditor() );
161
      save( editor );
162
    };
163
164
  private final DocumentStatistics mStatistics;
165
166
  /**
167
   * Adds all content panels to the main user interface. This will load the
168
   * configuration settings from the workspace to reproduce the settings from
169
   * a previous session.
170
   */
171
  public MainPane( final Workspace workspace ) {
172
    mWorkspace = workspace;
173
    mPreview = new HtmlPreview( workspace );
174
    mStatistics = new DocumentStatistics( workspace );
175
    mActiveTextEditor.set( new MarkdownEditor( workspace ) );
176
177
    open( bin( getRecentFiles() ) );
178
    viewPreview();
179
    setDividerPositions( calculateDividerPositions() );
180
181
    // Once the main scene's window regains focus, update the active definition
182
    // editor to the currently selected tab.
183
    runLater( () -> getWindow().setOnCloseRequest( ( event ) -> {
184
      // Order matters here. We want to close all the tabs to ensure each
185
      // is saved, but after they are closed, the workspace should still
186
      // retain the list of files that were open. If this line came after
187
      // closing, then restarting the application would list no files.
188
      mWorkspace.save();
189
190
      if( closeAll() ) {
191
        Platform.exit();
192
        System.exit( 0 );
193
      }
194
      else {
195
        event.consume();
196
      }
197
    } ) );
198
199
    register( this );
200
  }
201
202
  @Subscribe
203
  public void handle( final TextEditorFocusEvent event ) {
204
    mActiveTextEditor.set( event.get() );
205
  }
206
207
  @Subscribe
208
  public void handle( final TextDefinitionFocusEvent event ) {
209
    mActiveDefinitionEditor.set( event.get() );
210
  }
211
212
  /**
213
   * Typically called when a file name is clicked in the {@link HtmlPanel}.
214
   *
215
   * @param event The event to process, must contain a valid file reference.
216
   */
217
  @Subscribe
218
  public void handle( final FileOpenEvent event ) {
219
    final File eventFile;
220
    final var eventUri = event.getUri();
221
222
    if( eventUri.isAbsolute() ) {
223
      eventFile = new File( eventUri.getPath() );
224
    }
225
    else {
226
      final var activeFile = getActiveTextEditor().getFile();
227
      final var parent = activeFile.getParentFile();
228
229
      if( parent == null ) {
230
        clue( new FileNotFoundException( eventUri.getPath() ) );
231
        return;
232
      }
233
      else {
234
        final var parentPath = parent.getAbsolutePath();
235
        eventFile = Path.of( parentPath, eventUri.getPath() ).toFile();
236
      }
237
    }
238
239
    runLater( () -> open( eventFile ) );
240
  }
241
242
  @Subscribe
243
  public void handle( final CaretNavigationEvent event ) {
244
    runLater( () -> {
245
      final var textArea = getActiveTextEditor().getTextArea();
246
      textArea.moveTo( event.getOffset() );
247
      textArea.requestFollowCaret();
248
      textArea.requestFocus();
249
    } );
250
  }
251
252
  @Subscribe
253
  @SuppressWarnings( "unused" )
254
  public void handle( final ExportFailedEvent event ) {
255
    final var os = getProperty( "os.name" );
256
    final var arch = getProperty( "os.arch" ).toLowerCase();
257
    final var bits = getProperty( "sun.arch.data.model" );
258
259
    final var title = Messages.get( "Alert.typesetter.missing.title" );
260
    final var header = Messages.get( "Alert.typesetter.missing.header" );
261
    final var version = Messages.get(
262
      "Alert.typesetter.missing.version",
263
      os,
264
      arch
265
        .replaceAll( "amd.*|i.*|x86.*", "X86" )
266
        .replaceAll( "mips.*", "MIPS" )
267
        .replaceAll( "armv.*", "ARM" ),
268
      bits );
269
    final var text = Messages.get( "Alert.typesetter.missing.installer.text" );
270
271
    // Download and install ConTeXt for {0} {1} {2}-bit
272
    final var content = format( "%s %s", text, version );
273
    final var flowPane = new FlowPane();
274
    final var link = new Hyperlink( text );
275
    final var label = new Label( version );
276
    flowPane.getChildren().addAll( link, label );
277
278
    final var alert = new Alert( ERROR, content, OK );
279
    alert.setTitle( title );
280
    alert.setHeaderText( header );
281
    alert.getDialogPane().contentProperty().set( flowPane );
282
    alert.setGraphic( ICON_DIALOG_NODE );
283
284
    link.setOnAction( ( e ) -> {
285
      alert.close();
286
      final var url = Messages.get( "Alert.typesetter.missing.installer.url" );
287
      runLater( () -> fireHyperlinkOpenEvent( url ) );
288
    } );
289
290
    alert.showAndWait();
291
  }
292
293
  /**
294
   * TODO: Load divider positions from exported settings, see bin() comment.
295
   */
296
  private double[] calculateDividerPositions() {
297
    final var ratio = 100f / getItems().size() / 100;
298
    final var positions = getDividerPositions();
299
300
    for( int i = 0; i < positions.length; i++ ) {
301
      positions[ i ] = ratio * i;
302
    }
303
304
    return positions;
305
  }
306
307
  /**
308
   * Opens all the files into the application, provided the paths are unique.
309
   * This may only be called for any type of files that a user can edit
310
   * (i.e., update and persist), such as definitions and text files.
311
   *
312
   * @param files The list of files to open.
313
   */
314
  public void open( final List<File> files ) {
315
    files.forEach( this::open );
316
  }
317
318
  /**
319
   * This opens the given file. Since the preview pane is not a file that
320
   * can be opened, it is safe to add a listener to the detachable pane.
321
   *
322
   * @param file The file to open.
323
   */
324
  private void open( final File file ) {
325
    final var tab = createTab( file );
326
    final var node = tab.getContent();
327
    final var mediaType = MediaType.valueFrom( file );
328
    final var tabPane = obtainTabPane( mediaType );
329
330
    tab.setTooltip( createTooltip( file ) );
331
    tabPane.setFocusTraversable( false );
332
    tabPane.setTabClosingPolicy( ALL_TABS );
333
    tabPane.getTabs().add( tab );
334
335
    // Attach the tab scene factory for new tab panes.
336
    if( !getItems().contains( tabPane ) ) {
337
      addTabPane(
338
        node instanceof TextDefinition ? 0 : getItems().size(), tabPane
339
      );
340
    }
341
342
    getRecentFiles().add( file.getAbsolutePath() );
343
  }
344
345
  /**
346
   * Opens a new text editor document using the default document file name.
347
   */
348
  public void newTextEditor() {
349
    open( DOCUMENT_DEFAULT );
350
  }
351
352
  /**
353
   * Opens a new definition editor document using the default definition
354
   * file name.
355
   */
356
  public void newDefinitionEditor() {
357
    open( DEFINITION_DEFAULT );
358
  }
359
360
  /**
361
   * Iterates over all tab panes to find all {@link TextEditor}s and request
362
   * that they save themselves.
363
   */
364
  public void saveAll() {
365
    mTabPanes.forEach(
366
      ( tp ) -> tp.getTabs().forEach( ( tab ) -> {
367
        final var node = tab.getContent();
368
        if( node instanceof final TextEditor editor ) {
369
          save( editor );
370
        }
371
      } )
372
    );
373
  }
374
375
  /**
376
   * Requests that the active {@link TextEditor} saves itself. Don't bother
377
   * checking if modified first because if the user swaps external media from
378
   * an external source (e.g., USB thumb drive), save should not second-guess
379
   * the user: save always re-saves. Also, it's less code.
380
   */
381
  public void save() {
382
    save( getActiveTextEditor() );
383
  }
384
385
  /**
386
   * Saves the active {@link TextEditor} under a new name.
387
   *
388
   * @param files The new active editor {@link File} reference, must contain
389
   *              at least one element.
390
   */
391
  public void saveAs( final List<File> files ) {
392
    assert files != null;
393
    assert !files.isEmpty();
394
    final var editor = getActiveTextEditor();
395
    final var tab = getTab( editor );
396
    final var file = files.get( 0 );
397
398
    editor.rename( file );
399
    tab.ifPresent( t -> {
400
      t.setText( editor.getFilename() );
401
      t.setTooltip( createTooltip( file ) );
402
    } );
403
404
    save();
405
  }
406
407
  /**
408
   * Saves the given {@link TextResource} to a file. This is typically used
409
   * to save either an instance of {@link TextEditor} or {@link TextDefinition}.
410
   *
411
   * @param resource The resource to export.
412
   */
413
  private void save( final TextResource resource ) {
414
    try {
415
      resource.save();
416
    } catch( final Exception ex ) {
417
      clue( ex );
418
      sNotifier.alert(
419
        getWindow(), resource.getPath(), "TextResource.saveFailed", ex
420
      );
421
    }
422
  }
423
424
  /**
425
   * Closes all open {@link TextEditor}s; all {@link TextDefinition}s stay open.
426
   *
427
   * @return {@code true} when all editors, modified or otherwise, were
428
   * permitted to close; {@code false} when one or more editors were modified
429
   * and the user requested no closing.
430
   */
431
  public boolean closeAll() {
432
    var closable = true;
433
434
    for( final var tabPane : mTabPanes ) {
435
      final var tabIterator = tabPane.getTabs().iterator();
436
437
      while( tabIterator.hasNext() ) {
438
        final var tab = tabIterator.next();
439
        final var resource = tab.getContent();
440
441
        // The definition panes auto-save, so being specific here prevents
442
        // closing the definitions in the situation where the user wants to
443
        // continue editing (i.e., possibly save unsaved work).
444
        if( !(resource instanceof TextEditor) ) {
445
          continue;
446
        }
447
448
        if( canClose( (TextEditor) resource ) ) {
449
          tabIterator.remove();
450
          close( tab );
451
        }
452
        else {
453
          closable = false;
454
        }
455
      }
456
    }
457
458
    return closable;
459
  }
460
461
  /**
462
   * Calls the tab's {@link Tab#getOnClosed()} handler to carry out a close
463
   * event.
464
   *
465
   * @param tab The {@link Tab} that was closed.
466
   */
467
  private void close( final Tab tab ) {
468
    final var handler = tab.getOnClosed();
469
470
    if( handler != null ) {
471
      handler.handle( new ActionEvent() );
472
    }
473
  }
474
475
  /**
476
   * Closes the active tab; delegates to {@link #canClose(TextResource)}.
477
   */
478
  public void close() {
479
    final var editor = getActiveTextEditor();
480
481
    if( canClose( editor ) ) {
482
      close( editor );
483
    }
484
  }
485
486
  /**
487
   * Closes the given {@link TextResource}. This must not be called from within
488
   * a loop that iterates over the tab panes using {@code forEach}, lest a
489
   * concurrent modification exception be thrown.
490
   *
491
   * @param resource The {@link TextResource} to close, without confirming with
492
   *                 the user.
493
   */
494
  private void close( final TextResource resource ) {
495
    getTab( resource ).ifPresent(
496
      ( tab ) -> {
497
        tab.getTabPane().getTabs().remove( tab );
498
        close( tab );
499
      }
500
    );
501
  }
502
503
  /**
504
   * Answers whether the given {@link TextResource} may be closed.
505
   *
506
   * @param editor The {@link TextResource} to try closing.
507
   * @return {@code true} when the editor may be closed; {@code false} when
508
   * the user has requested to keep the editor open.
509
   */
510
  private boolean canClose( final TextResource editor ) {
511
    final var editorTab = getTab( editor );
512
    final var canClose = new AtomicBoolean( true );
513
514
    if( editor.isModified() ) {
515
      final var filename = new StringBuilder();
516
      editorTab.ifPresent( ( tab ) -> filename.append( tab.getText() ) );
517
518
      final var message = sNotifier.createNotification(
519
        Messages.get( "Alert.file.close.title" ),
520
        Messages.get( "Alert.file.close.text" ),
521
        filename.toString()
522
      );
523
524
      final var dialog = sNotifier.createConfirmation( getWindow(), message );
525
526
      dialog.showAndWait().ifPresent(
527
        save -> canClose.set( save == YES ? editor.save() : save == NO )
528
      );
529
    }
530
531
    return canClose.get();
532
  }
533
534
  private ObjectProperty<TextEditor> createActiveTextEditor() {
535
    final var editor = new SimpleObjectProperty<TextEditor>();
536
537
    editor.addListener( ( c, o, n ) -> {
538
      if( n != null ) {
539
        mPreview.setBaseUri( n.getPath() );
540
        process( n );
541
      }
542
    } );
543
544
    return editor;
545
  }
546
547
  /**
548
   * Adds the HTML preview tab to its own, singular tab pane.
549
   */
550
  public void viewPreview() {
551
    viewTab( mPreview, TEXT_HTML, "Pane.preview.title" );
552
  }
553
554
  /**
555
   * Adds the document outline tab to its own, singular tab pane.
556
   */
557
  public void viewOutline() {
558
    viewTab( mOutline, APP_DOCUMENT_OUTLINE, "Pane.outline.title" );
559
  }
560
561
  public void viewStatistics() {
562
    viewTab( mStatistics, APP_DOCUMENT_STATISTICS, "Pane.statistics.title" );
563
  }
564
565
  public void viewFiles() {
566
    try {
567
      final var factory = new FilePickerFactory( mWorkspace );
568
      final var fileManager = factory.createModeless();
569
      viewTab( fileManager, APP_FILE_MANAGER, "Pane.files.title" );
570
    } catch( final Exception ex ) {
571
      clue( ex );
572
    }
573
  }
574
575
  private void viewTab(
576
    final Node node, final MediaType mediaType, final String key ) {
577
    final var tabPane = obtainTabPane( mediaType );
578
579
    for( final var tab : tabPane.getTabs() ) {
580
      if( tab.getContent() == node ) {
581
        return;
582
      }
583
    }
584
585
    tabPane.getTabs().add( createTab( get( key ), node ) );
586
    addTabPane( tabPane );
587
  }
588
589
  public void viewRefresh() {
590
    mPreview.refresh();
591
  }
592
593
  /**
594
   * Returns the tab that contains the given {@link TextEditor}.
595
   *
596
   * @param editor The {@link TextEditor} instance to find amongst the tabs.
597
   * @return The first tab having content that matches the given tab.
598
   */
599
  private Optional<Tab> getTab( final TextResource editor ) {
600
    return mTabPanes.stream()
601
                    .flatMap( pane -> pane.getTabs().stream() )
602
                    .filter( tab -> editor.equals( tab.getContent() ) )
603
                    .findFirst();
604
  }
605
606
  /**
607
   * Creates a new {@link DefinitionEditor} wrapped in a listener that
608
   * is used to detect when the active {@link DefinitionEditor} has changed.
609
   * Upon changing, the {@link #mResolvedMap} is updated and the active
610
   * text editor is refreshed.
611
   *
612
   * @param editor Text editor to update with the revised resolved map.
613
   * @return A newly configured property that represents the active
614
   * {@link DefinitionEditor}, never null.
615
   */
616
  private ObjectProperty<TextDefinition> createActiveDefinitionEditor(
617
    final ObjectProperty<TextEditor> editor ) {
618
    final var definitions = new SimpleObjectProperty<TextDefinition>();
619
    definitions.addListener( ( c, o, n ) -> {
620
      resolve( n == null ? createDefinitionEditor() : n );
621
      process( editor.get() );
622
    } );
623
624
    return definitions;
625
  }
626
627
  private Tab createTab( final String filename, final Node node ) {
628
    return new DetachableTab( filename, node );
629
  }
630
631
  private Tab createTab( final File file ) {
632
    final var r = createTextResource( file );
633
    final var tab = createTab( r.getFilename(), r.getNode() );
634
635
    r.modifiedProperty().addListener(
636
      ( c, o, n ) -> tab.setText( r.getFilename() + (n ? "*" : "") )
637
    );
638
639
    // This is called when either the tab is closed by the user clicking on
640
    // the tab's close icon or when closing (all) from the file menu.
641
    tab.setOnClosed(
642
      ( __ ) -> getRecentFiles().remove( file.getAbsolutePath() )
643
    );
644
645
    tab.tabPaneProperty().addListener( ( cPane, oPane, nPane ) -> {
646
      if( nPane != null ) {
647
        nPane.focusedProperty().addListener( ( c, o, n ) -> {
648
          if( n != null && n ) {
649
            final var selected = nPane.getSelectionModel().getSelectedItem();
650
            final var node = selected.getContent();
651
            node.requestFocus();
652
          }
653
        } );
654
      }
655
    } );
656
657
    return tab;
658
  }
659
660
  /**
661
   * Creates bins for the different {@link MediaType}s, which eventually are
662
   * added to the UI as separate tab panes. If ever a general-purpose scene
663
   * exporter is developed to serialize a scene to an FXML file, this could
664
   * be replaced by such a class.
665
   * <p>
666
   * When binning the files, this makes sure that at least one file exists
667
   * for every type. If the user has opted to close a particular type (such
668
   * as the definition pane), the view will suppressed elsewhere.
669
   * </p>
670
   * <p>
671
   * The order that the binned files are returned will be reflected in the
672
   * order that the corresponding panes are rendered in the UI.
673
   * </p>
674
   *
675
   * @param paths The file paths to bin according to their type.
676
   * @return An in-order list of files, first by structured definition files,
677
   * then by plain text documents.
678
   */
679
  private List<File> bin( final SetProperty<String> paths ) {
680
    // Treat all files destined for the text editor as plain text documents
681
    // so that they are added to the same pane. Grouping by TEXT_PLAIN is a
682
    // bit arbitrary, but means explicitly capturing TEXT_PLAIN isn't needed.
683
    final Function<MediaType, MediaType> bin =
684
      m -> PLAIN_TEXT_FORMAT.contains( m ) ? TEXT_PLAIN : m;
685
686
    // Create two groups: YAML files and plain text files.
687
    final var bins = paths
688
      .stream()
689
      .collect(
690
        groupingBy( path -> bin.apply( MediaType.fromFilename( path ) ) )
691
      );
692
693
    bins.putIfAbsent( TEXT_YAML, List.of( DEFINITION_DEFAULT.toString() ) );
694
    bins.putIfAbsent( TEXT_PLAIN, List.of( DOCUMENT_DEFAULT.toString() ) );
695
696
    final var result = new ArrayList<File>( paths.size() );
697
698
    // Ensure that the same types are listed together (keep insertion order).
699
    bins.forEach( ( mediaType, files ) -> result.addAll(
700
      files.stream().map( File::new ).collect( Collectors.toList() ) )
701
    );
702
703
    return result;
704
  }
705
706
  /**
707
   * Uses the given {@link TextDefinition} instance to update the
708
   * {@link #mResolvedMap}.
709
   *
710
   * @param editor A non-null, possibly empty definition editor.
711
   */
712
  private void resolve( final TextDefinition editor ) {
713
    assert editor != null;
714
715
    final var tokens = createDefinitionTokens();
716
    final var operator = new YamlSigilOperator( tokens );
717
    final var map = new HashMap<String, String>();
718
719
    editor.toMap().forEach( ( k, v ) -> map.put( operator.entoken( k ), v ) );
720
721
    mResolvedMap.clear();
722
    mResolvedMap.putAll( editor.interpolate( map, tokens ) );
723
  }
724
725
  /**
726
   * Force the active editor to update, which will cause the processor
727
   * to re-evaluate the interpolated definition map thereby updating the
728
   * preview pane.
729
   *
730
   * @param editor Contains the source document to update in the preview pane.
731
   */
732
  private void process( final TextEditor editor ) {
733
    // Ensure processing does not run on the JavaFX thread, which frees the
734
    // text editor immediately for caret movement. The preview will have a
735
    // slight delay when catching up to the caret position.
736
    final var task = new Task<Void>() {
737
      @Override
738
      public Void call() {
739
        try {
740
          final var p = mProcessors.getOrDefault( editor, IDENTITY );
741
          p.apply( editor == null ? "" : editor.getText() );
742
        } catch( final Exception ex ) {
743
          clue( ex );
744
        }
745
746
        return null;
747
      }
748
    };
749
750
    task.setOnSucceeded(
751
      e -> invokeLater( () -> mPreview.scrollTo( CARET_ID ) )
752
    );
753
754
    // Prevents multiple process requests from executing simultaneously (due
755
    // to having a restricted queue size).
756
    sExecutor.execute( task );
757
  }
758
759
  /**
760
   * Lazily creates a {@link TabPane} configured to listen for tab select
761
   * events. The tab pane is associated with a given media type so that
762
   * similar files can be grouped together.
763
   *
764
   * @param mediaType The media type to associate with the tab pane.
765
   * @return An instance of {@link TabPane} that will handle tab docking.
766
   */
767
  private TabPane obtainTabPane( final MediaType mediaType ) {
768
    for( final var pane : mTabPanes ) {
769
      for( final var tab : pane.getTabs() ) {
770
        final var node = tab.getContent();
771
772
        if( node instanceof TextResource r && r.supports( mediaType ) ) {
773
          return pane;
774
        }
775
      }
776
    }
777
778
    final var pane = createTabPane();
779
    mTabPanes.add( pane );
780
    return pane;
781
  }
782
783
  /**
784
   * Creates an initialized {@link TabPane} instance.
785
   *
786
   * @return A new {@link TabPane} with all listeners configured.
787
   */
788
  private TabPane createTabPane() {
789
    final var tabPane = new DetachableTabPane();
790
791
    initStageOwnerFactory( tabPane );
792
    initTabListener( tabPane );
793
794
    return tabPane;
795
  }
796
797
  /**
798
   * When any {@link DetachableTabPane} is detached from the main window,
799
   * the stage owner factory must be given its parent window, which will
800
   * own the child window. The parent window is the {@link MainPane}'s
801
   * {@link Scene}'s {@link Window} instance.
802
   *
803
   * <p>
804
   * This will derives the new title from the main window title, incrementing
805
   * the window count to help uniquely identify the child windows.
806
   * </p>
807
   *
808
   * @param tabPane A new {@link DetachableTabPane} to configure.
809
   */
810
  private void initStageOwnerFactory( final DetachableTabPane tabPane ) {
811
    tabPane.setStageOwnerFactory( ( stage ) -> {
812
      final var title = get(
813
        "Detach.tab.title",
814
        ((Stage) getWindow()).getTitle(), ++mWindowCount
815
      );
816
      stage.setTitle( title );
817
818
      return getScene().getWindow();
819
    } );
820
  }
821
822
  /**
823
   * Responsible for configuring the content of each {@link DetachableTab} when
824
   * it is added to the given {@link DetachableTabPane} instance.
825
   * <p>
826
   * For {@link TextEditor} contents, an instance of {@link ScrollEventHandler}
827
   * is initialized to perform synchronized scrolling between the editor and
828
   * its preview window. Additionally, the last tab in the tab pane's list of
829
   * tabs is given focus.
830
   * </p>
831
   * <p>
832
   * Note that multiple tabs can be added simultaneously.
833
   * </p>
834
   *
835
   * @param tabPane A new {@link TabPane} to configure.
836
   */
837
  private void initTabListener( final TabPane tabPane ) {
838
    tabPane.getTabs().addListener(
839
      ( final ListChangeListener.Change<? extends Tab> listener ) -> {
840
        while( listener.next() ) {
841
          if( listener.wasAdded() ) {
842
            final var tabs = listener.getAddedSubList();
843
844
            tabs.forEach( ( tab ) -> {
845
              final var node = tab.getContent();
846
847
              if( node instanceof TextEditor ) {
848
                initScrollEventListener( tab );
849
              }
850
            } );
851
852
            // Select and give focus to the last tab opened.
853
            final var index = tabs.size() - 1;
854
            if( index >= 0 ) {
855
              final var tab = tabs.get( index );
856
              tabPane.getSelectionModel().select( tab );
857
              tab.getContent().requestFocus();
858
            }
859
          }
860
        }
861
      }
862
    );
863
  }
864
865
  /**
866
   * Synchronizes scrollbar positions between the given {@link Tab} that
867
   * contains an instance of {@link TextEditor} and {@link HtmlPreview} pane.
868
   *
869
   * @param tab The container for an instance of {@link TextEditor}.
870
   */
871
  private void initScrollEventListener( final Tab tab ) {
872
    final var editor = (TextEditor) tab.getContent();
873
    final var scrollPane = editor.getScrollPane();
874
    final var scrollBar = mPreview.getVerticalScrollBar();
875
    final var handler = new ScrollEventHandler( scrollPane, scrollBar );
876
    handler.enabledProperty().bind( tab.selectedProperty() );
877
  }
878
879
  private void addTabPane( final int index, final TabPane tabPane ) {
880
    final var items = getItems();
881
    if( !items.contains( tabPane ) ) {
882
      items.add( index, tabPane );
883
    }
884
  }
885
886
  private void addTabPane( final TabPane tabPane ) {
887
    addTabPane( getItems().size(), tabPane );
888
  }
889
890
  public ProcessorContext createProcessorContext() {
891
    return createProcessorContext( null, NONE );
892
  }
893
894
  public ProcessorContext createProcessorContext(
895
    final Path exportPath, final ExportFormat format ) {
896
    final var editor = getActiveTextEditor();
897
    return createProcessorContext(
898
      editor.getPath(), exportPath, format, editor.getCaret() );
899
  }
900
901
  private ProcessorContext createProcessorContext(
902
    final Path path, final Caret caret ) {
903
    return createProcessorContext( path, null, ExportFormat.NONE, caret );
904
  }
905
906
  /**
907
   * @param path       Used by {@link ProcessorFactory} to determine
908
   *                   {@link Processor} type to create based on file type.
909
   * @param exportPath Used when exporting to a PDF file (binary).
910
   * @param format     Used when processors export to a new text format.
911
   * @param caret      Used by {@link CaretExtension} to add ID attribute into
912
   *                   preview document for scrollbar synchronization.
913
   * @return A new {@link ProcessorContext} to use when creating an instance of
914
   * {@link Processor}.
915
   */
916
  private ProcessorContext createProcessorContext(
917
    final Path path, final Path exportPath, final ExportFormat format,
918
    final Caret caret ) {
919
    return new ProcessorContext(
920
      mPreview, mResolvedMap, path, exportPath, format, mWorkspace, caret
921
    );
922
  }
923
924
  private TextResource createTextResource( final File file ) {
925
    // TODO: Create PlainTextEditor that's returned by default.
926
    return MediaType.valueFrom( file ) == TEXT_YAML
927
      ? createDefinitionEditor( file )
928
      : createMarkdownEditor( file );
929
  }
930
931
  /**
932
   * Creates an instance of {@link MarkdownEditor} that listens for both
933
   * caret change events and text change events. Text change events must
934
   * take priority over caret change events because it's possible to change
935
   * the text without moving the caret (e.g., delete selected text).
936
   *
937
   * @param file The file containing contents for the text editor.
938
   * @return A non-null text editor.
939
   */
940
  private TextResource createMarkdownEditor( final File file ) {
941
    final var path = file.toPath();
942
    final var editor = new MarkdownEditor( file, getWorkspace() );
943
    final var caret = editor.getCaret();
944
    final var context = createProcessorContext( path, caret );
945
946
    mProcessors.computeIfAbsent( editor, p -> createProcessors( context ) );
947
948
    editor.addDirtyListener( ( c, o, n ) -> {
949
      if( n ) {
950
        // Reset the status to OK after changing the text.
951
        clue();
952
953
        // Processing the text may update the status bar.
954
        process( getActiveTextEditor() );
955
      }
956
    } );
957
958
    editor.addEventListener(
959
      keyPressed( SPACE, CONTROL_DOWN ), this::autoinsert
960
    );
961
962
    // Set the active editor, which refreshes the preview panel.
963
    mActiveTextEditor.set( editor );
964
965
    return editor;
966
  }
967
968
  /**
969
   * Delegates to {@link #autoinsert()}.
970
   *
971
   * @param event Ignored.
972
   */
53
import java.util.concurrent.ScheduledExecutorService;
54
import java.util.concurrent.ScheduledFuture;
55
import java.util.concurrent.atomic.AtomicBoolean;
56
import java.util.concurrent.atomic.AtomicReference;
57
import java.util.function.Function;
58
import java.util.stream.Collectors;
59
60
import static com.keenwrite.ExportFormat.NONE;
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.HyperlinkOpenEvent.fireHyperlinkOpenEvent;
66
import static com.keenwrite.events.StatusEvent.clue;
67
import static com.keenwrite.io.MediaType.*;
68
import static com.keenwrite.preferences.WorkspaceKeys.*;
69
import static com.keenwrite.processors.IdentityProcessor.IDENTITY;
70
import static com.keenwrite.processors.ProcessorFactory.createProcessors;
71
import static java.lang.String.format;
72
import static java.lang.System.getProperty;
73
import static java.util.concurrent.Executors.newFixedThreadPool;
74
import static java.util.concurrent.Executors.newScheduledThreadPool;
75
import static java.util.concurrent.TimeUnit.SECONDS;
76
import static java.util.stream.Collectors.groupingBy;
77
import static javafx.application.Platform.runLater;
78
import static javafx.scene.control.Alert.AlertType.ERROR;
79
import static javafx.scene.control.ButtonType.*;
80
import static javafx.scene.control.TabPane.TabClosingPolicy.ALL_TABS;
81
import static javafx.scene.input.KeyCode.SPACE;
82
import static javafx.scene.input.KeyCombination.CONTROL_DOWN;
83
import static javafx.util.Duration.millis;
84
import static javax.swing.SwingUtilities.invokeLater;
85
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
86
87
/**
88
 * Responsible for wiring together the main application components for a
89
 * particular workspace (project). These include the definition views,
90
 * text editors, and preview pane along with any corresponding controllers.
91
 */
92
public final class MainPane extends SplitPane {
93
  private static final ExecutorService sExecutor = newFixedThreadPool( 1 );
94
95
  private final ScheduledExecutorService mSaver = newScheduledThreadPool( 1 );
96
  private final AtomicReference<ScheduledFuture<?>> mSaveTask =
97
    new AtomicReference<>();
98
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
  /**
110
   * Prevents re-instantiation of processing classes.
111
   */
112
  private final Map<TextResource, Processor<String>> mProcessors =
113
    new HashMap<>();
114
115
  private final Workspace mWorkspace;
116
117
  /**
118
   * Groups similar file type tabs together.
119
   */
120
  private final List<TabPane> mTabPanes = new ArrayList<>();
121
122
  /**
123
   * Stores definition names and values.
124
   */
125
  private final Map<String, String> mResolvedMap =
126
    new HashMap<>( MAP_SIZE_DEFAULT );
127
128
  /**
129
   * Renders the actively selected plain text editor tab.
130
   */
131
  private final HtmlPreview mPreview;
132
133
  /**
134
   * Provides an interactive document outline.
135
   */
136
  private final DocumentOutline mOutline = new DocumentOutline();
137
138
  /**
139
   * Changing the active editor fires the value changed event. This allows
140
   * refreshes to happen when external definitions are modified and need to
141
   * trigger the processing chain.
142
   */
143
  private final ObjectProperty<TextEditor> mActiveTextEditor =
144
    createActiveTextEditor();
145
146
  /**
147
   * Changing the active definition editor fires the value changed event. This
148
   * allows refreshes to happen when external definitions are modified and need
149
   * to trigger the processing chain.
150
   */
151
  private final ObjectProperty<TextDefinition> mActiveDefinitionEditor =
152
    createActiveDefinitionEditor( mActiveTextEditor );
153
154
  /**
155
   * Tracks the number of detached tab panels opened into their own windows,
156
   * which allows unique identification of subordinate windows by their title.
157
   * It is doubtful more than 128 windows, much less 256, will be created.
158
   */
159
  private byte mWindowCount;
160
161
  /**
162
   * Called when the definition data is changed.
163
   */
164
  private final EventHandler<TreeModificationEvent<Event>> mTreeHandler =
165
    event -> {
166
      final var editor = mActiveDefinitionEditor.get();
167
168
      resolve( editor );
169
      process( getActiveTextEditor() );
170
      save( editor );
171
    };
172
173
  private final DocumentStatistics mStatistics;
174
175
  /**
176
   * Adds all content panels to the main user interface. This will load the
177
   * configuration settings from the workspace to reproduce the settings from
178
   * a previous session.
179
   */
180
  public MainPane( final Workspace workspace ) {
181
    mWorkspace = workspace;
182
    mPreview = new HtmlPreview( workspace );
183
    mStatistics = new DocumentStatistics( workspace );
184
    mActiveTextEditor.set( new MarkdownEditor( workspace ) );
185
186
    open( bin( getRecentFiles() ) );
187
    viewPreview();
188
    setDividerPositions( calculateDividerPositions() );
189
190
    // Once the main scene's window regains focus, update the active definition
191
    // editor to the currently selected tab.
192
    runLater( () -> getWindow().setOnCloseRequest( ( event ) -> {
193
      // Order matters here. We want to close all the tabs to ensure each
194
      // is saved, but after they are closed, the workspace should still
195
      // retain the list of files that were open. If this line came after
196
      // closing, then restarting the application would list no files.
197
      mWorkspace.save();
198
199
      if( closeAll() ) {
200
        Platform.exit();
201
        System.exit( 0 );
202
      }
203
      else {
204
        event.consume();
205
      }
206
    } ) );
207
208
    register( this );
209
    initAutosave( workspace );
210
  }
211
212
  @Subscribe
213
  public void handle( final TextEditorFocusEvent event ) {
214
    mActiveTextEditor.set( event.get() );
215
  }
216
217
  @Subscribe
218
  public void handle( final TextDefinitionFocusEvent event ) {
219
    mActiveDefinitionEditor.set( event.get() );
220
  }
221
222
  /**
223
   * Typically called when a file name is clicked in the {@link HtmlPanel}.
224
   *
225
   * @param event The event to process, must contain a valid file reference.
226
   */
227
  @Subscribe
228
  public void handle( final FileOpenEvent event ) {
229
    final File eventFile;
230
    final var eventUri = event.getUri();
231
232
    if( eventUri.isAbsolute() ) {
233
      eventFile = new File( eventUri.getPath() );
234
    }
235
    else {
236
      final var activeFile = getActiveTextEditor().getFile();
237
      final var parent = activeFile.getParentFile();
238
239
      if( parent == null ) {
240
        clue( new FileNotFoundException( eventUri.getPath() ) );
241
        return;
242
      }
243
      else {
244
        final var parentPath = parent.getAbsolutePath();
245
        eventFile = Path.of( parentPath, eventUri.getPath() ).toFile();
246
      }
247
    }
248
249
    runLater( () -> open( eventFile ) );
250
  }
251
252
  @Subscribe
253
  public void handle( final CaretNavigationEvent event ) {
254
    runLater( () -> {
255
      final var textArea = getActiveTextEditor().getTextArea();
256
      textArea.moveTo( event.getOffset() );
257
      textArea.requestFollowCaret();
258
      textArea.requestFocus();
259
    } );
260
  }
261
262
  @Subscribe
263
  @SuppressWarnings( "unused" )
264
  public void handle( final ExportFailedEvent event ) {
265
    final var os = getProperty( "os.name" );
266
    final var arch = getProperty( "os.arch" ).toLowerCase();
267
    final var bits = getProperty( "sun.arch.data.model" );
268
269
    final var title = Messages.get( "Alert.typesetter.missing.title" );
270
    final var header = Messages.get( "Alert.typesetter.missing.header" );
271
    final var version = Messages.get(
272
      "Alert.typesetter.missing.version",
273
      os,
274
      arch
275
        .replaceAll( "amd.*|i.*|x86.*", "X86" )
276
        .replaceAll( "mips.*", "MIPS" )
277
        .replaceAll( "armv.*", "ARM" ),
278
      bits );
279
    final var text = Messages.get( "Alert.typesetter.missing.installer.text" );
280
281
    // Download and install ConTeXt for {0} {1} {2}-bit
282
    final var content = format( "%s %s", text, version );
283
    final var flowPane = new FlowPane();
284
    final var link = new Hyperlink( text );
285
    final var label = new Label( version );
286
    flowPane.getChildren().addAll( link, label );
287
288
    final var alert = new Alert( ERROR, content, OK );
289
    alert.setTitle( title );
290
    alert.setHeaderText( header );
291
    alert.getDialogPane().contentProperty().set( flowPane );
292
    alert.setGraphic( ICON_DIALOG_NODE );
293
294
    link.setOnAction( ( e ) -> {
295
      alert.close();
296
      final var url = Messages.get( "Alert.typesetter.missing.installer.url" );
297
      runLater( () -> fireHyperlinkOpenEvent( url ) );
298
    } );
299
300
    alert.showAndWait();
301
  }
302
303
  private void initAutosave( final Workspace workspace ) {
304
    final var rate = workspace.integerProperty( KEY_EDITOR_AUTOSAVE );
305
306
    rate.addListener(
307
      ( c, o, n ) -> {
308
        final var taskRef = mSaveTask.get();
309
310
        // Prevent multiple autosaves from running.
311
        if( taskRef != null ) {
312
          taskRef.cancel( false );
313
        }
314
315
        initAutosave( rate );
316
      }
317
    );
318
319
    // Start the save listener (avoids duplicating some code).
320
    initAutosave( rate );
321
  }
322
323
  private void initAutosave( final IntegerProperty rate ) {
324
    mSaveTask.set(
325
      mSaver.scheduleAtFixedRate(
326
        () -> {
327
          if( getActiveTextEditor().isModified() ) {
328
            // Ensure the modified indicator is cleared by running on EDT.
329
            runLater( this::save );
330
          }
331
        }, 0, rate.intValue(), SECONDS
332
      )
333
    );
334
  }
335
336
  /**
337
   * TODO: Load divider positions from exported settings, see bin() comment.
338
   */
339
  private double[] calculateDividerPositions() {
340
    final var ratio = 100f / getItems().size() / 100;
341
    final var positions = getDividerPositions();
342
343
    for( int i = 0; i < positions.length; i++ ) {
344
      positions[ i ] = ratio * i;
345
    }
346
347
    return positions;
348
  }
349
350
  /**
351
   * Opens all the files into the application, provided the paths are unique.
352
   * This may only be called for any type of files that a user can edit
353
   * (i.e., update and persist), such as definitions and text files.
354
   *
355
   * @param files The list of files to open.
356
   */
357
  public void open( final List<File> files ) {
358
    files.forEach( this::open );
359
  }
360
361
  /**
362
   * This opens the given file. Since the preview pane is not a file that
363
   * can be opened, it is safe to add a listener to the detachable pane.
364
   *
365
   * @param file The file to open.
366
   */
367
  private void open( final File file ) {
368
    final var tab = createTab( file );
369
    final var node = tab.getContent();
370
    final var mediaType = MediaType.valueFrom( file );
371
    final var tabPane = obtainTabPane( mediaType );
372
373
    tab.setTooltip( createTooltip( file ) );
374
    tabPane.setFocusTraversable( false );
375
    tabPane.setTabClosingPolicy( ALL_TABS );
376
    tabPane.getTabs().add( tab );
377
378
    // Attach the tab scene factory for new tab panes.
379
    if( !getItems().contains( tabPane ) ) {
380
      addTabPane(
381
        node instanceof TextDefinition ? 0 : getItems().size(), tabPane
382
      );
383
    }
384
385
    getRecentFiles().add( file.getAbsolutePath() );
386
  }
387
388
  /**
389
   * Opens a new text editor document using the default document file name.
390
   */
391
  public void newTextEditor() {
392
    open( DOCUMENT_DEFAULT );
393
  }
394
395
  /**
396
   * Opens a new definition editor document using the default definition
397
   * file name.
398
   */
399
  public void newDefinitionEditor() {
400
    open( DEFINITION_DEFAULT );
401
  }
402
403
  /**
404
   * Iterates over all tab panes to find all {@link TextEditor}s and request
405
   * that they save themselves.
406
   */
407
  public void saveAll() {
408
    mTabPanes.forEach(
409
      ( tp ) -> tp.getTabs().forEach( ( tab ) -> {
410
        final var node = tab.getContent();
411
        if( node instanceof final TextEditor editor ) {
412
          save( editor );
413
        }
414
      } )
415
    );
416
  }
417
418
  /**
419
   * Requests that the active {@link TextEditor} saves itself. Don't bother
420
   * checking if modified first because if the user swaps external media from
421
   * an external source (e.g., USB thumb drive), save should not second-guess
422
   * the user: save always re-saves. Also, it's less code.
423
   */
424
  public void save() {
425
    save( getActiveTextEditor() );
426
  }
427
428
  /**
429
   * Saves the active {@link TextEditor} under a new name.
430
   *
431
   * @param files The new active editor {@link File} reference, must contain
432
   *              at least one element.
433
   */
434
  public void saveAs( final List<File> files ) {
435
    assert files != null;
436
    assert !files.isEmpty();
437
    final var editor = getActiveTextEditor();
438
    final var tab = getTab( editor );
439
    final var file = files.get( 0 );
440
441
    editor.rename( file );
442
    tab.ifPresent( t -> {
443
      t.setText( editor.getFilename() );
444
      t.setTooltip( createTooltip( file ) );
445
    } );
446
447
    save();
448
  }
449
450
  /**
451
   * Saves the given {@link TextResource} to a file. This is typically used
452
   * to save either an instance of {@link TextEditor} or {@link TextDefinition}.
453
   *
454
   * @param resource The resource to export.
455
   */
456
  private void save( final TextResource resource ) {
457
    try {
458
      resource.save();
459
    } catch( final Exception ex ) {
460
      clue( ex );
461
      sNotifier.alert(
462
        getWindow(), resource.getPath(), "TextResource.saveFailed", ex
463
      );
464
    }
465
  }
466
467
  /**
468
   * Closes all open {@link TextEditor}s; all {@link TextDefinition}s stay open.
469
   *
470
   * @return {@code true} when all editors, modified or otherwise, were
471
   * permitted to close; {@code false} when one or more editors were modified
472
   * and the user requested no closing.
473
   */
474
  public boolean closeAll() {
475
    var closable = true;
476
477
    for( final var tabPane : mTabPanes ) {
478
      final var tabIterator = tabPane.getTabs().iterator();
479
480
      while( tabIterator.hasNext() ) {
481
        final var tab = tabIterator.next();
482
        final var resource = tab.getContent();
483
484
        // The definition panes auto-save, so being specific here prevents
485
        // closing the definitions in the situation where the user wants to
486
        // continue editing (i.e., possibly save unsaved work).
487
        if( !(resource instanceof TextEditor) ) {
488
          continue;
489
        }
490
491
        if( canClose( (TextEditor) resource ) ) {
492
          tabIterator.remove();
493
          close( tab );
494
        }
495
        else {
496
          closable = false;
497
        }
498
      }
499
    }
500
501
    return closable;
502
  }
503
504
  /**
505
   * Calls the tab's {@link Tab#getOnClosed()} handler to carry out a close
506
   * event.
507
   *
508
   * @param tab The {@link Tab} that was closed.
509
   */
510
  private void close( final Tab tab ) {
511
    assert tab != null;
512
513
    final var handler = tab.getOnClosed();
514
515
    if( handler != null ) {
516
      handler.handle( new ActionEvent() );
517
    }
518
  }
519
520
  /**
521
   * Closes the active tab; delegates to {@link #canClose(TextResource)}.
522
   */
523
  public void close() {
524
    final var editor = getActiveTextEditor();
525
526
    if( canClose( editor ) ) {
527
      close( editor );
528
    }
529
  }
530
531
  /**
532
   * Closes the given {@link TextResource}. This must not be called from within
533
   * a loop that iterates over the tab panes using {@code forEach}, lest a
534
   * concurrent modification exception be thrown.
535
   *
536
   * @param resource The {@link TextResource} to close, without confirming with
537
   *                 the user.
538
   */
539
  private void close( final TextResource resource ) {
540
    getTab( resource ).ifPresent(
541
      ( tab ) -> {
542
        close( tab );
543
        tab.getTabPane().getTabs().remove( tab );
544
      }
545
    );
546
  }
547
548
  /**
549
   * Answers whether the given {@link TextResource} may be closed.
550
   *
551
   * @param editor The {@link TextResource} to try closing.
552
   * @return {@code true} when the editor may be closed; {@code false} when
553
   * the user has requested to keep the editor open.
554
   */
555
  private boolean canClose( final TextResource editor ) {
556
    final var editorTab = getTab( editor );
557
    final var canClose = new AtomicBoolean( true );
558
559
    if( editor.isModified() ) {
560
      final var filename = new StringBuilder();
561
      editorTab.ifPresent( ( tab ) -> filename.append( tab.getText() ) );
562
563
      final var message = sNotifier.createNotification(
564
        Messages.get( "Alert.file.close.title" ),
565
        Messages.get( "Alert.file.close.text" ),
566
        filename.toString()
567
      );
568
569
      final var dialog = sNotifier.createConfirmation( getWindow(), message );
570
571
      dialog.showAndWait().ifPresent(
572
        save -> canClose.set( save == YES ? editor.save() : save == NO )
573
      );
574
    }
575
576
    return canClose.get();
577
  }
578
579
  private ObjectProperty<TextEditor> createActiveTextEditor() {
580
    final var editor = new SimpleObjectProperty<TextEditor>();
581
582
    editor.addListener( ( c, o, n ) -> {
583
      if( n != null ) {
584
        mPreview.setBaseUri( n.getPath() );
585
        process( n );
586
      }
587
    } );
588
589
    return editor;
590
  }
591
592
  /**
593
   * Adds the HTML preview tab to its own, singular tab pane.
594
   */
595
  public void viewPreview() {
596
    viewTab( mPreview, TEXT_HTML, "Pane.preview.title" );
597
  }
598
599
  /**
600
   * Adds the document outline tab to its own, singular tab pane.
601
   */
602
  public void viewOutline() {
603
    viewTab( mOutline, APP_DOCUMENT_OUTLINE, "Pane.outline.title" );
604
  }
605
606
  public void viewStatistics() {
607
    viewTab( mStatistics, APP_DOCUMENT_STATISTICS, "Pane.statistics.title" );
608
  }
609
610
  public void viewFiles() {
611
    try {
612
      final var factory = new FilePickerFactory( mWorkspace );
613
      final var fileManager = factory.createModeless();
614
      viewTab( fileManager, APP_FILE_MANAGER, "Pane.files.title" );
615
    } catch( final Exception ex ) {
616
      clue( ex );
617
    }
618
  }
619
620
  private void viewTab(
621
    final Node node, final MediaType mediaType, final String key ) {
622
    final var tabPane = obtainTabPane( mediaType );
623
624
    for( final var tab : tabPane.getTabs() ) {
625
      if( tab.getContent() == node ) {
626
        return;
627
      }
628
    }
629
630
    tabPane.getTabs().add( createTab( get( key ), node ) );
631
    addTabPane( tabPane );
632
  }
633
634
  public void viewRefresh() {
635
    mPreview.refresh();
636
  }
637
638
  /**
639
   * Returns the tab that contains the given {@link TextEditor}.
640
   *
641
   * @param editor The {@link TextEditor} instance to find amongst the tabs.
642
   * @return The first tab having content that matches the given tab.
643
   */
644
  private Optional<Tab> getTab( final TextResource editor ) {
645
    return mTabPanes.stream()
646
                    .flatMap( pane -> pane.getTabs().stream() )
647
                    .filter( tab -> editor.equals( tab.getContent() ) )
648
                    .findFirst();
649
  }
650
651
  /**
652
   * Creates a new {@link DefinitionEditor} wrapped in a listener that
653
   * is used to detect when the active {@link DefinitionEditor} has changed.
654
   * Upon changing, the {@link #mResolvedMap} is updated and the active
655
   * text editor is refreshed.
656
   *
657
   * @param editor Text editor to update with the revised resolved map.
658
   * @return A newly configured property that represents the active
659
   * {@link DefinitionEditor}, never null.
660
   */
661
  private ObjectProperty<TextDefinition> createActiveDefinitionEditor(
662
    final ObjectProperty<TextEditor> editor ) {
663
    final var definitions = new SimpleObjectProperty<TextDefinition>();
664
    definitions.addListener( ( c, o, n ) -> {
665
      resolve( n == null ? createDefinitionEditor() : n );
666
      process( editor.get() );
667
    } );
668
669
    return definitions;
670
  }
671
672
  private Tab createTab( final String filename, final Node node ) {
673
    return new DetachableTab( filename, node );
674
  }
675
676
  private Tab createTab( final File file ) {
677
    final var r = createTextResource( file );
678
    final var tab = createTab( r.getFilename(), r.getNode() );
679
680
    r.modifiedProperty().addListener(
681
      ( c, o, n ) -> tab.setText( r.getFilename() + (n ? "*" : "") )
682
    );
683
684
    // This is called when either the tab is closed by the user clicking on
685
    // the tab's close icon or when closing (all) from the file menu.
686
    tab.setOnClosed(
687
      ( __ ) -> getRecentFiles().remove( file.getAbsolutePath() )
688
    );
689
690
    tab.tabPaneProperty().addListener( ( cPane, oPane, nPane ) -> {
691
      if( nPane != null ) {
692
        nPane.focusedProperty().addListener( ( c, o, n ) -> {
693
          if( n != null && n ) {
694
            final var selected = nPane.getSelectionModel().getSelectedItem();
695
            final var node = selected.getContent();
696
            node.requestFocus();
697
          }
698
        } );
699
      }
700
    } );
701
702
    return tab;
703
  }
704
705
  /**
706
   * Creates bins for the different {@link MediaType}s, which eventually are
707
   * added to the UI as separate tab panes. If ever a general-purpose scene
708
   * exporter is developed to serialize a scene to an FXML file, this could
709
   * be replaced by such a class.
710
   * <p>
711
   * When binning the files, this makes sure that at least one file exists
712
   * for every type. If the user has opted to close a particular type (such
713
   * as the definition pane), the view will suppressed elsewhere.
714
   * </p>
715
   * <p>
716
   * The order that the binned files are returned will be reflected in the
717
   * order that the corresponding panes are rendered in the UI.
718
   * </p>
719
   *
720
   * @param paths The file paths to bin according to their type.
721
   * @return An in-order list of files, first by structured definition files,
722
   * then by plain text documents.
723
   */
724
  private List<File> bin( final SetProperty<String> paths ) {
725
    // Treat all files destined for the text editor as plain text documents
726
    // so that they are added to the same pane. Grouping by TEXT_PLAIN is a
727
    // bit arbitrary, but means explicitly capturing TEXT_PLAIN isn't needed.
728
    final Function<MediaType, MediaType> bin =
729
      m -> PLAIN_TEXT_FORMAT.contains( m ) ? TEXT_PLAIN : m;
730
731
    // Create two groups: YAML files and plain text files.
732
    final var bins = paths
733
      .stream()
734
      .collect(
735
        groupingBy( path -> bin.apply( MediaType.fromFilename( path ) ) )
736
      );
737
738
    bins.putIfAbsent( TEXT_YAML, List.of( DEFINITION_DEFAULT.toString() ) );
739
    bins.putIfAbsent( TEXT_PLAIN, List.of( DOCUMENT_DEFAULT.toString() ) );
740
741
    final var result = new ArrayList<File>( paths.size() );
742
743
    // Ensure that the same types are listed together (keep insertion order).
744
    bins.forEach( ( mediaType, files ) -> result.addAll(
745
      files.stream().map( File::new ).collect( Collectors.toList() ) )
746
    );
747
748
    return result;
749
  }
750
751
  /**
752
   * Uses the given {@link TextDefinition} instance to update the
753
   * {@link #mResolvedMap}.
754
   *
755
   * @param editor A non-null, possibly empty definition editor.
756
   */
757
  private void resolve( final TextDefinition editor ) {
758
    assert editor != null;
759
760
    final var tokens = createDefinitionTokens();
761
    final var operator = new YamlSigilOperator( tokens );
762
    final var map = new HashMap<String, String>();
763
764
    editor.toMap().forEach( ( k, v ) -> map.put( operator.entoken( k ), v ) );
765
766
    mResolvedMap.clear();
767
    mResolvedMap.putAll( editor.interpolate( map, tokens ) );
768
  }
769
770
  /**
771
   * Force the active editor to update, which will cause the processor
772
   * to re-evaluate the interpolated definition map thereby updating the
773
   * preview pane.
774
   *
775
   * @param editor Contains the source document to update in the preview pane.
776
   */
777
  private void process( final TextEditor editor ) {
778
    // Ensure processing does not run on the JavaFX thread, which frees the
779
    // text editor immediately for caret movement. The preview will have a
780
    // slight delay when catching up to the caret position.
781
    final var task = new Task<Void>() {
782
      @Override
783
      public Void call() {
784
        try {
785
          final var p = mProcessors.getOrDefault( editor, IDENTITY );
786
          p.apply( editor == null ? "" : editor.getText() );
787
        } catch( final Exception ex ) {
788
          clue( ex );
789
        }
790
791
        return null;
792
      }
793
    };
794
795
    task.setOnSucceeded(
796
      e -> invokeLater( () -> mPreview.scrollTo( CARET_ID ) )
797
    );
798
799
    // Prevents multiple process requests from executing simultaneously (due
800
    // to having a restricted queue size).
801
    sExecutor.execute( task );
802
  }
803
804
  /**
805
   * Lazily creates a {@link TabPane} configured to listen for tab select
806
   * events. The tab pane is associated with a given media type so that
807
   * similar files can be grouped together.
808
   *
809
   * @param mediaType The media type to associate with the tab pane.
810
   * @return An instance of {@link TabPane} that will handle tab docking.
811
   */
812
  private TabPane obtainTabPane( final MediaType mediaType ) {
813
    for( final var pane : mTabPanes ) {
814
      for( final var tab : pane.getTabs() ) {
815
        final var node = tab.getContent();
816
817
        if( node instanceof TextResource r && r.supports( mediaType ) ) {
818
          return pane;
819
        }
820
      }
821
    }
822
823
    final var pane = createTabPane();
824
    mTabPanes.add( pane );
825
    return pane;
826
  }
827
828
  /**
829
   * Creates an initialized {@link TabPane} instance.
830
   *
831
   * @return A new {@link TabPane} with all listeners configured.
832
   */
833
  private TabPane createTabPane() {
834
    final var tabPane = new DetachableTabPane();
835
836
    initStageOwnerFactory( tabPane );
837
    initTabListener( tabPane );
838
839
    return tabPane;
840
  }
841
842
  /**
843
   * When any {@link DetachableTabPane} is detached from the main window,
844
   * the stage owner factory must be given its parent window, which will
845
   * own the child window. The parent window is the {@link MainPane}'s
846
   * {@link Scene}'s {@link Window} instance.
847
   *
848
   * <p>
849
   * This will derives the new title from the main window title, incrementing
850
   * the window count to help uniquely identify the child windows.
851
   * </p>
852
   *
853
   * @param tabPane A new {@link DetachableTabPane} to configure.
854
   */
855
  private void initStageOwnerFactory( final DetachableTabPane tabPane ) {
856
    tabPane.setStageOwnerFactory( ( stage ) -> {
857
      final var title = get(
858
        "Detach.tab.title",
859
        ((Stage) getWindow()).getTitle(), ++mWindowCount
860
      );
861
      stage.setTitle( title );
862
863
      return getScene().getWindow();
864
    } );
865
  }
866
867
  /**
868
   * Responsible for configuring the content of each {@link DetachableTab} when
869
   * it is added to the given {@link DetachableTabPane} instance.
870
   * <p>
871
   * For {@link TextEditor} contents, an instance of {@link ScrollEventHandler}
872
   * is initialized to perform synchronized scrolling between the editor and
873
   * its preview window. Additionally, the last tab in the tab pane's list of
874
   * tabs is given focus.
875
   * </p>
876
   * <p>
877
   * Note that multiple tabs can be added simultaneously.
878
   * </p>
879
   *
880
   * @param tabPane A new {@link TabPane} to configure.
881
   */
882
  private void initTabListener( final TabPane tabPane ) {
883
    tabPane.getTabs().addListener(
884
      ( final ListChangeListener.Change<? extends Tab> listener ) -> {
885
        while( listener.next() ) {
886
          if( listener.wasAdded() ) {
887
            final var tabs = listener.getAddedSubList();
888
889
            tabs.forEach( ( tab ) -> {
890
              final var node = tab.getContent();
891
892
              if( node instanceof TextEditor ) {
893
                initScrollEventListener( tab );
894
              }
895
            } );
896
897
            // Select and give focus to the last tab opened.
898
            final var index = tabs.size() - 1;
899
            if( index >= 0 ) {
900
              final var tab = tabs.get( index );
901
              tabPane.getSelectionModel().select( tab );
902
              tab.getContent().requestFocus();
903
            }
904
          }
905
        }
906
      }
907
    );
908
  }
909
910
  /**
911
   * Synchronizes scrollbar positions between the given {@link Tab} that
912
   * contains an instance of {@link TextEditor} and {@link HtmlPreview} pane.
913
   *
914
   * @param tab The container for an instance of {@link TextEditor}.
915
   */
916
  private void initScrollEventListener( final Tab tab ) {
917
    final var editor = (TextEditor) tab.getContent();
918
    final var scrollPane = editor.getScrollPane();
919
    final var scrollBar = mPreview.getVerticalScrollBar();
920
    final var handler = new ScrollEventHandler( scrollPane, scrollBar );
921
    handler.enabledProperty().bind( tab.selectedProperty() );
922
  }
923
924
  private void addTabPane( final int index, final TabPane tabPane ) {
925
    final var items = getItems();
926
    if( !items.contains( tabPane ) ) {
927
      items.add( index, tabPane );
928
    }
929
  }
930
931
  private void addTabPane( final TabPane tabPane ) {
932
    addTabPane( getItems().size(), tabPane );
933
  }
934
935
  public ProcessorContext createProcessorContext() {
936
    return createProcessorContext( null, NONE );
937
  }
938
939
  public ProcessorContext createProcessorContext(
940
    final Path exportPath, final ExportFormat format ) {
941
    final var editor = getActiveTextEditor();
942
    return createProcessorContext(
943
      editor.getPath(), exportPath, format, editor.getCaret() );
944
  }
945
946
  private ProcessorContext createProcessorContext(
947
    final Path path, final Caret caret ) {
948
    return createProcessorContext( path, null, ExportFormat.NONE, caret );
949
  }
950
951
  /**
952
   * @param path       Used by {@link ProcessorFactory} to determine
953
   *                   {@link Processor} type to create based on file type.
954
   * @param exportPath Used when exporting to a PDF file (binary).
955
   * @param format     Used when processors export to a new text format.
956
   * @param caret      Used by {@link CaretExtension} to add ID attribute into
957
   *                   preview document for scrollbar synchronization.
958
   * @return A new {@link ProcessorContext} to use when creating an instance of
959
   * {@link Processor}.
960
   */
961
  private ProcessorContext createProcessorContext(
962
    final Path path, final Path exportPath, final ExportFormat format,
963
    final Caret caret ) {
964
    return new ProcessorContext(
965
      mPreview, mResolvedMap, path, exportPath, format, mWorkspace, caret
966
    );
967
  }
968
969
  private TextResource createTextResource( final File file ) {
970
    // TODO: Create PlainTextEditor that's returned by default.
971
    return MediaType.valueFrom( file ) == TEXT_YAML
972
      ? createDefinitionEditor( file )
973
      : createMarkdownEditor( file );
974
  }
975
976
  /**
977
   * Creates an instance of {@link MarkdownEditor} that listens for both
978
   * caret change events and text change events. Text change events must
979
   * take priority over caret change events because it's possible to change
980
   * the text without moving the caret (e.g., delete selected text).
981
   *
982
   * @param file The file containing contents for the text editor.
983
   * @return A non-null text editor.
984
   */
985
  private TextResource createMarkdownEditor( final File file ) {
986
    final var path = file.toPath();
987
    final var editor = new MarkdownEditor( file, getWorkspace() );
988
    final var caret = editor.getCaret();
989
    final var context = createProcessorContext( path, caret );
990
991
    mProcessors.computeIfAbsent( editor, p -> createProcessors( context ) );
992
993
    editor.addDirtyListener( ( c, o, n ) -> {
994
      if( n ) {
995
        // Reset the status to OK after changing the text.
996
        clue();
997
998
        // Processing the text may update the status bar.
999
        process( getActiveTextEditor() );
1000
      }
1001
    } );
1002
1003
    editor.addEventListener(
1004
      keyPressed( SPACE, CONTROL_DOWN ), this::autoinsert
1005
    );
1006
1007
    // Set the active editor, which refreshes the preview panel.
1008
    mActiveTextEditor.set( editor );
1009
1010
    return editor;
1011
  }
1012
1013
  /**
1014
   * Delegates to {@link #autoinsert()}.
1015
   *
1016
   * @param event Ignored.
1017
   */
1018
  @SuppressWarnings( "unused" )
9731019
  private void autoinsert( final KeyEvent event ) {
9741020
    autoinsert();
M src/main/java/com/keenwrite/MainScene.java
5656
    appPane.setBottom( mStatusBar );
5757
58
    final var watchThread = new Thread( mFileWatchService );
59
    watchThread.setDaemon( true );
60
    watchThread.start();
58
    final var fileWatcher = new Thread( mFileWatchService );
59
    fileWatcher.setDaemon( true );
60
    fileWatcher.start();
6161
6262
    mScene = createScene( appPane );
M src/main/java/com/keenwrite/constants/Constants.java
194194
195195
  /**
196
   * Custom CSS to apply.
196
   * Custom JavaFX CSS to apply to user interface.
197197
   */
198198
  public static final File SKIN_CUSTOM_DEFAULT = null;
199
200
  /**
201
   * Custom HTML CSS to apply to HTML preview panel.
202
   */
203
  public static final File PREVIEW_CUSTOM_DEFAULT = null;
199204
200205
  /**
M src/main/java/com/keenwrite/dom/DocumentParser.java
9595
      input.setEncoding( UTF_8.toString() );
9696
      input.setCharacterStream( reader );
97
9798
      return sDocumentBuilder.parse( input );
9899
    } catch( final Exception ex ) {
99100
      clue( ex );
101
100102
      return sDocumentBuilder.newDocument();
101103
    }
...
116118
   */
117119
  public static void walk(
118
    final Document document, final String xpath,
120
    final Document document,
121
    final String xpath,
119122
    final Consumer<Node> consumer ) {
120123
    assert document != null;
...
138141
    final Document document, final Map.Entry<String, String> entry ) {
139142
    final var node = document.createElement( "meta" );
143
140144
    node.setAttribute( "name", entry.getKey() );
141145
    node.setAttribute( "content", entry.getValue() );
...
150154
151155
      sTransformer.transform( domSource, result );
156
152157
      return writer.toString();
153158
    } catch( final Exception ex ) {
...
161166
    try( final var writer = new StringWriter() ) {
162167
      sTransformer.transform(
163
        new DOMSource( root ), new StreamResult( writer ) );
168
        new DOMSource( root ), new StreamResult( writer )
169
      );
170
164171
      return writer.toString();
165172
    }
...
191198
  public static String decorate( final String html ) {
192199
    return
193
      "<html><head><title> </title></head><body>" + html + "</body></html>";
200
      "<html><head><title> </title><meta charset='utf8'/></head><body>"
201
        + html
202
        + "</body></html>";
194203
  }
195204
M src/main/java/com/keenwrite/preferences/PreferencesController.java
1010
import com.dlsc.preferencesfx.util.StorageHandler;
1111
import com.dlsc.preferencesfx.view.NavigationView;
12
import javafx.beans.property.BooleanProperty;
13
import javafx.beans.property.DoubleProperty;
14
import javafx.beans.property.ObjectProperty;
15
import javafx.beans.property.StringProperty;
16
import javafx.event.EventHandler;
17
import javafx.scene.Node;
18
import javafx.scene.control.Button;
19
import javafx.scene.control.DialogPane;
20
import javafx.scene.control.Label;
21
import org.controlsfx.control.MasterDetailPane;
22
23
import java.io.File;
24
25
import static com.dlsc.formsfx.model.structure.Field.ofStringType;
26
import static com.dlsc.preferencesfx.PreferencesFxEvent.EVENT_PREFERENCES_SAVED;
27
import static com.keenwrite.Messages.get;
28
import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG;
29
import static com.keenwrite.preferences.LocaleProperty.localeListProperty;
30
import static com.keenwrite.preferences.SkinProperty.skinListProperty;
31
import static com.keenwrite.preferences.WorkspaceKeys.*;
32
import static javafx.scene.control.ButtonType.CANCEL;
33
import static javafx.scene.control.ButtonType.OK;
34
35
/**
36
 * Provides the ability for users to configure their preferences. This links
37
 * the {@link Workspace} model with the {@link PreferencesFx} view, in MVC.
38
 */
39
@SuppressWarnings( "SameParameterValue" )
40
public final class PreferencesController {
41
42
  private final Workspace mWorkspace;
43
  private final PreferencesFx mPreferencesFx;
44
45
  public PreferencesController( final Workspace workspace ) {
46
    mWorkspace = workspace;
47
48
    // All properties must be initialized before creating the dialog.
49
    mPreferencesFx = createPreferencesFx();
50
51
    initKeyEventHandler( mPreferencesFx );
52
  }
53
54
  /**
55
   * Display the user preferences settings dialog (non-modal).
56
   */
57
  public void show() {
58
    getPreferencesFx().show( false );
59
  }
60
61
  /**
62
   * Call to persist the settings. Strictly speaking, this could watch on
63
   * all values for external changes then save automatically.
64
   */
65
  public void save() {
66
    getPreferencesFx().saveSettings();
67
  }
68
69
  /**
70
   * Delegates to the {@link PreferencesFx} event handler for monitoring
71
   * save events.
72
   *
73
   * @param eventHandler The handler to call when the preferences are saved.
74
   */
75
  public void addSaveEventHandler(
76
    final EventHandler<? super PreferencesFxEvent> eventHandler ) {
77
    getPreferencesFx().addEventHandler( EVENT_PREFERENCES_SAVED, eventHandler );
78
  }
79
80
  private StringField createFontNameField(
81
    final StringProperty fontName, final DoubleProperty fontSize ) {
82
    final var control = new SimpleFontControl( "Change" );
83
    control.fontSizeProperty().addListener( ( c, o, n ) -> {
84
      if( n != null ) {
85
        fontSize.set( n.doubleValue() );
86
      }
87
    } );
88
    return ofStringType( fontName ).render( control );
89
  }
90
91
  /**
92
   * Creates the preferences dialog based using {@link XmlStorageHandler} and
93
   * numerous {@link Category} objects.
94
   *
95
   * @return A component for editing preferences.
96
   * @throws RuntimeException Could not construct the {@link PreferencesFx}
97
   *                          object (e.g., illegal access permissions,
98
   *                          unmapped XML resource).
99
   */
100
  private PreferencesFx createPreferencesFx() {
101
    return PreferencesFx.of( createStorageHandler(), createCategories() )
102
                        .instantPersistent( false )
103
                        .dialogIcon( ICON_DIALOG );
104
  }
105
106
  private StorageHandler createStorageHandler() {
107
    return new XmlStorageHandler();
108
  }
109
110
  private Category[] createCategories() {
111
    return new Category[]{
112
      Category.of(
113
        get( KEY_DOC ),
114
        Group.of(
115
          get( KEY_DOC_TITLE ),
116
          Setting.of( label( KEY_DOC_TITLE ) ),
117
          Setting.of( title( KEY_DOC_TITLE ),
118
                      stringProperty( KEY_DOC_TITLE ) )
119
        ),
120
        Group.of(
121
          get( KEY_DOC_AUTHOR ),
122
          Setting.of( label( KEY_DOC_AUTHOR ) ),
123
          Setting.of( title( KEY_DOC_AUTHOR ),
124
                      stringProperty( KEY_DOC_AUTHOR ) )
125
        ),
126
        Group.of(
127
          get( KEY_DOC_BYLINE ),
128
          Setting.of( label( KEY_DOC_BYLINE ) ),
129
          Setting.of( title( KEY_DOC_BYLINE ),
130
                      stringProperty( KEY_DOC_BYLINE ) )
131
        ),
132
        Group.of(
133
          get( KEY_DOC_ADDRESS ),
134
          Setting.of( label( KEY_DOC_ADDRESS ) ),
135
          createMultilineSetting( "Address", KEY_DOC_ADDRESS )
136
        ),
137
        Group.of(
138
          get( KEY_DOC_PHONE ),
139
          Setting.of( label( KEY_DOC_PHONE ) ),
140
          Setting.of( title( KEY_DOC_PHONE ),
141
                      stringProperty( KEY_DOC_PHONE ) )
142
        ),
143
        Group.of(
144
          get( KEY_DOC_EMAIL ),
145
          Setting.of( label( KEY_DOC_EMAIL ) ),
146
          Setting.of( title( KEY_DOC_EMAIL ),
147
                      stringProperty( KEY_DOC_EMAIL ) )
148
        ),
149
        Group.of(
150
          get( KEY_DOC_KEYWORDS ),
151
          Setting.of( label( KEY_DOC_KEYWORDS ) ),
152
          Setting.of( title( KEY_DOC_KEYWORDS ),
153
                      stringProperty( KEY_DOC_KEYWORDS ) )
154
        ),
155
        Group.of(
156
          get( KEY_DOC_COPYRIGHT ),
157
          Setting.of( label( KEY_DOC_COPYRIGHT ) ),
158
          Setting.of( title( KEY_DOC_COPYRIGHT ),
159
                      stringProperty( KEY_DOC_COPYRIGHT ) )
160
        ),
161
        Group.of(
162
          get( KEY_DOC_DATE ),
163
          Setting.of( label( KEY_DOC_DATE ) ),
164
          Setting.of( title( KEY_DOC_DATE ),
165
                      stringProperty( KEY_DOC_DATE ) )
166
        )
167
      ),
168
      Category.of(
169
        get( KEY_TYPESET ),
170
        Group.of(
171
          get( KEY_TYPESET_CONTEXT ),
172
          Setting.of( label( KEY_TYPESET_CONTEXT_THEMES_PATH ) ),
173
          Setting.of( title( KEY_TYPESET_CONTEXT_THEMES_PATH ),
174
                      fileProperty( KEY_TYPESET_CONTEXT_THEMES_PATH ), true ),
175
          Setting.of( label( KEY_TYPESET_CONTEXT_CLEAN ) ),
176
          Setting.of( title( KEY_TYPESET_CONTEXT_CLEAN ),
177
                      booleanProperty( KEY_TYPESET_CONTEXT_CLEAN ) )
178
        ),
179
        Group.of(
180
          get( KEY_TYPESET_TYPOGRAPHY ),
181
          Setting.of( label( KEY_TYPESET_TYPOGRAPHY_QUOTES ) ),
182
          Setting.of( title( KEY_TYPESET_TYPOGRAPHY_QUOTES ),
183
                      booleanProperty( KEY_TYPESET_TYPOGRAPHY_QUOTES ) )
184
        )
185
      ),
186
      Category.of(
187
        get( KEY_R ),
188
        Group.of(
189
          get( KEY_R_DIR ),
190
          Setting.of( label( KEY_R_DIR,
191
                             stringProperty( KEY_DEF_DELIM_BEGAN ).get(),
192
                             stringProperty( KEY_DEF_DELIM_ENDED ).get() ) ),
193
          Setting.of( title( KEY_R_DIR ),
194
                      fileProperty( KEY_R_DIR ), true )
195
        ),
196
        Group.of(
197
          get( KEY_R_SCRIPT ),
198
          Setting.of( label( KEY_R_SCRIPT ) ),
199
          createMultilineSetting( "Script", KEY_R_SCRIPT )
200
        ),
201
        Group.of(
202
          get( KEY_R_DELIM_BEGAN ),
203
          Setting.of( label( KEY_R_DELIM_BEGAN ) ),
204
          Setting.of( title( KEY_R_DELIM_BEGAN ),
205
                      stringProperty( KEY_R_DELIM_BEGAN ) )
206
        ),
207
        Group.of(
208
          get( KEY_R_DELIM_ENDED ),
209
          Setting.of( label( KEY_R_DELIM_ENDED ) ),
210
          Setting.of( title( KEY_R_DELIM_ENDED ),
211
                      stringProperty( KEY_R_DELIM_ENDED ) )
212
        )
213
      ),
214
      Category.of(
215
        get( KEY_IMAGES ),
216
        Group.of(
217
          get( KEY_IMAGES_DIR ),
218
          Setting.of( label( KEY_IMAGES_DIR ) ),
219
          Setting.of( title( KEY_IMAGES_DIR ),
220
                      fileProperty( KEY_IMAGES_DIR ), true )
221
        ),
222
        Group.of(
223
          get( KEY_IMAGES_ORDER ),
224
          Setting.of( label( KEY_IMAGES_ORDER ) ),
225
          Setting.of( title( KEY_IMAGES_ORDER ),
226
                      stringProperty( KEY_IMAGES_ORDER ) )
227
        ),
228
        Group.of(
229
          get( KEY_IMAGES_RESIZE ),
230
          Setting.of( label( KEY_IMAGES_RESIZE ) ),
231
          Setting.of( title( KEY_IMAGES_RESIZE ),
232
                      booleanProperty( KEY_IMAGES_RESIZE ) )
233
        ),
234
        Group.of(
235
          get( KEY_IMAGES_SERVER ),
236
          Setting.of( label( KEY_IMAGES_SERVER ) ),
237
          Setting.of( title( KEY_IMAGES_SERVER ),
238
                      stringProperty( KEY_IMAGES_SERVER ) )
239
        )
240
      ),
241
      Category.of(
242
        get( KEY_DEF ),
243
        Group.of(
244
          get( KEY_DEF_PATH ),
245
          Setting.of( label( KEY_DEF_PATH ) ),
246
          Setting.of( title( KEY_DEF_PATH ),
247
                      fileProperty( KEY_DEF_PATH ), false )
248
        ),
249
        Group.of(
250
          get( KEY_DEF_DELIM_BEGAN ),
251
          Setting.of( label( KEY_DEF_DELIM_BEGAN ) ),
252
          Setting.of( title( KEY_DEF_DELIM_BEGAN ),
253
                      stringProperty( KEY_DEF_DELIM_BEGAN ) )
254
        ),
255
        Group.of(
256
          get( KEY_DEF_DELIM_ENDED ),
257
          Setting.of( label( KEY_DEF_DELIM_ENDED ) ),
258
          Setting.of( title( KEY_DEF_DELIM_ENDED ),
259
                      stringProperty( KEY_DEF_DELIM_ENDED ) )
260
        )
261
      ),
262
      Category.of(
263
        get( KEY_UI_FONT ),
264
        Group.of(
265
          get( KEY_UI_FONT_EDITOR ),
266
          Setting.of( label( KEY_UI_FONT_EDITOR_NAME ) ),
267
          Setting.of( title( KEY_UI_FONT_EDITOR_NAME ),
268
                      createFontNameField(
269
                        stringProperty( KEY_UI_FONT_EDITOR_NAME ),
270
                        doubleProperty( KEY_UI_FONT_EDITOR_SIZE ) ),
271
                      stringProperty( KEY_UI_FONT_EDITOR_NAME ) ),
272
          Setting.of( label( KEY_UI_FONT_EDITOR_SIZE ) ),
273
          Setting.of( title( KEY_UI_FONT_EDITOR_SIZE ),
274
                      doubleProperty( KEY_UI_FONT_EDITOR_SIZE ) )
275
        ),
276
        Group.of(
277
          get( KEY_UI_FONT_PREVIEW ),
278
          Setting.of( label( KEY_UI_FONT_PREVIEW_NAME ) ),
279
          Setting.of( title( KEY_UI_FONT_PREVIEW_NAME ),
280
                      createFontNameField(
281
                        stringProperty( KEY_UI_FONT_PREVIEW_NAME ),
282
                        doubleProperty( KEY_UI_FONT_PREVIEW_SIZE ) ),
283
                      stringProperty( KEY_UI_FONT_PREVIEW_NAME ) ),
284
          Setting.of( label( KEY_UI_FONT_PREVIEW_SIZE ) ),
285
          Setting.of( title( KEY_UI_FONT_PREVIEW_SIZE ),
286
                      doubleProperty( KEY_UI_FONT_PREVIEW_SIZE ) ),
287
          Setting.of( label( KEY_UI_FONT_PREVIEW_MONO_NAME ) ),
288
          Setting.of( title( KEY_UI_FONT_PREVIEW_MONO_NAME ),
289
                      createFontNameField(
290
                        stringProperty( KEY_UI_FONT_PREVIEW_MONO_NAME ),
291
                        doubleProperty( KEY_UI_FONT_PREVIEW_MONO_SIZE ) ),
292
                      stringProperty( KEY_UI_FONT_PREVIEW_MONO_NAME ) ),
293
          Setting.of( label( KEY_UI_FONT_PREVIEW_MONO_SIZE ) ),
294
          Setting.of( title( KEY_UI_FONT_PREVIEW_MONO_SIZE ),
295
                      doubleProperty( KEY_UI_FONT_PREVIEW_MONO_SIZE ) )
296
        )
297
      ),
298
      Category.of(
299
        get( KEY_UI_SKIN ),
300
        Group.of(
301
          get( KEY_UI_SKIN_SELECTION ),
302
          Setting.of( label( KEY_UI_SKIN_SELECTION ) ),
303
          Setting.of( title( KEY_UI_SKIN_SELECTION ),
304
                      skinListProperty(),
305
                      skinProperty( KEY_UI_SKIN_SELECTION ) )
306
        ),
307
        Group.of(
308
          get( KEY_UI_SKIN_CUSTOM ),
309
          Setting.of( label( KEY_UI_SKIN_CUSTOM ) ),
310
          Setting.of( title( KEY_UI_SKIN_CUSTOM ),
311
                      fileProperty( KEY_UI_SKIN_CUSTOM ), false )
312
        )
313
      ),
314
      Category.of(
315
        get( KEY_LANGUAGE ),
316
        Group.of(
317
          get( KEY_LANGUAGE_LOCALE ),
318
          Setting.of( label( KEY_LANGUAGE_LOCALE ) ),
319
          Setting.of( title( KEY_LANGUAGE_LOCALE ),
320
                      localeListProperty(),
321
                      localeProperty( KEY_LANGUAGE_LOCALE ) )
322
        )
323
      )};
324
  }
325
326
  @SuppressWarnings( "unchecked" )
327
  private Setting<StringField, StringProperty> createMultilineSetting(
328
    final String description, final Key property ) {
329
    final Setting<StringField, StringProperty> setting =
330
      Setting.of( description, stringProperty( property ) );
331
    final var field = setting.getElement();
332
    field.multiline( true );
333
334
    return setting;
335
  }
336
337
  private void initKeyEventHandler( final PreferencesFx preferences ) {
338
    final var view = preferences.getView();
339
    final var nodes = view.getChildrenUnmodifiable();
340
    final var master = (MasterDetailPane) nodes.get( 0 );
341
    final var detail = (NavigationView) master.getDetailNode();
342
    final var pane = (DialogPane) view.getParent();
343
344
    detail.setOnKeyReleased( ( key ) -> {
345
      switch( key.getCode() ) {
346
        case ENTER -> ((Button) pane.lookupButton( OK )).fire();
347
        case ESCAPE -> ((Button) pane.lookupButton( CANCEL )).fire();
348
      }
349
    } );
350
  }
351
352
  /**
353
   * Creates a label for the given key after interpolating its value.
354
   *
355
   * @param key The key to find in the resource bundle.
356
   * @return The value of the key as a label.
357
   */
358
  private Node label( final Key key ) {
359
    return label( key, (String[]) null );
360
  }
361
362
  private Node label( final Key key, final String... values ) {
363
    return new Label( get( key.toString() + ".desc", (Object[]) values ) );
364
  }
365
366
  private String title( final Key key ) {
367
    return get( key.toString() + ".title" );
368
  }
369
370
  private ObjectProperty<File> fileProperty( final Key key ) {
371
    return mWorkspace.fileProperty( key );
372
  }
373
374
  private StringProperty stringProperty( final Key key ) {
375
    return mWorkspace.stringProperty( key );
376
  }
377
378
  private BooleanProperty booleanProperty( final Key key ) {
379
    return mWorkspace.booleanProperty( key );
12
import javafx.beans.property.*;
13
import javafx.event.EventHandler;
14
import javafx.scene.Node;
15
import javafx.scene.control.Button;
16
import javafx.scene.control.DialogPane;
17
import javafx.scene.control.Label;
18
import org.controlsfx.control.MasterDetailPane;
19
20
import java.io.File;
21
22
import static com.dlsc.formsfx.model.structure.Field.ofStringType;
23
import static com.dlsc.preferencesfx.PreferencesFxEvent.EVENT_PREFERENCES_SAVED;
24
import static com.keenwrite.Messages.get;
25
import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG;
26
import static com.keenwrite.preferences.LocaleProperty.localeListProperty;
27
import static com.keenwrite.preferences.SkinProperty.skinListProperty;
28
import static com.keenwrite.preferences.WorkspaceKeys.*;
29
import static javafx.scene.control.ButtonType.CANCEL;
30
import static javafx.scene.control.ButtonType.OK;
31
32
/**
33
 * Provides the ability for users to configure their preferences. This links
34
 * the {@link Workspace} model with the {@link PreferencesFx} view, in MVC.
35
 */
36
@SuppressWarnings( "SameParameterValue" )
37
public final class PreferencesController {
38
39
  private final Workspace mWorkspace;
40
  private final PreferencesFx mPreferencesFx;
41
42
  public PreferencesController( final Workspace workspace ) {
43
    mWorkspace = workspace;
44
45
    // All properties must be initialized before creating the dialog.
46
    mPreferencesFx = createPreferencesFx();
47
48
    initKeyEventHandler( mPreferencesFx );
49
  }
50
51
  /**
52
   * Display the user preferences settings dialog (non-modal).
53
   */
54
  public void show() {
55
    getPreferencesFx().show( false );
56
  }
57
58
  /**
59
   * Call to persist the settings. Strictly speaking, this could watch on
60
   * all values for external changes then save automatically.
61
   */
62
  public void save() {
63
    getPreferencesFx().saveSettings();
64
  }
65
66
  /**
67
   * Delegates to the {@link PreferencesFx} event handler for monitoring
68
   * save events.
69
   *
70
   * @param eventHandler The handler to call when the preferences are saved.
71
   */
72
  public void addSaveEventHandler(
73
    final EventHandler<? super PreferencesFxEvent> eventHandler ) {
74
    getPreferencesFx().addEventHandler( EVENT_PREFERENCES_SAVED, eventHandler );
75
  }
76
77
  private StringField createFontNameField(
78
    final StringProperty fontName, final DoubleProperty fontSize ) {
79
    final var control = new SimpleFontControl( "Change" );
80
    control.fontSizeProperty().addListener( ( c, o, n ) -> {
81
      if( n != null ) {
82
        fontSize.set( n.doubleValue() );
83
      }
84
    } );
85
    return ofStringType( fontName ).render( control );
86
  }
87
88
  /**
89
   * Creates the preferences dialog based using {@link XmlStorageHandler} and
90
   * numerous {@link Category} objects.
91
   *
92
   * @return A component for editing preferences.
93
   * @throws RuntimeException Could not construct the {@link PreferencesFx}
94
   *                          object (e.g., illegal access permissions,
95
   *                          unmapped XML resource).
96
   */
97
  private PreferencesFx createPreferencesFx() {
98
    return PreferencesFx.of( createStorageHandler(), createCategories() )
99
                        .instantPersistent( false )
100
                        .dialogIcon( ICON_DIALOG );
101
  }
102
103
  private StorageHandler createStorageHandler() {
104
    return new XmlStorageHandler();
105
  }
106
107
  private Category[] createCategories() {
108
    return new Category[]{
109
      Category.of(
110
        get( KEY_DOC ),
111
        Group.of(
112
          get( KEY_DOC_TITLE ),
113
          Setting.of( label( KEY_DOC_TITLE ) ),
114
          Setting.of( title( KEY_DOC_TITLE ),
115
                      stringProperty( KEY_DOC_TITLE ) )
116
        ),
117
        Group.of(
118
          get( KEY_DOC_AUTHOR ),
119
          Setting.of( label( KEY_DOC_AUTHOR ) ),
120
          Setting.of( title( KEY_DOC_AUTHOR ),
121
                      stringProperty( KEY_DOC_AUTHOR ) )
122
        ),
123
        Group.of(
124
          get( KEY_DOC_BYLINE ),
125
          Setting.of( label( KEY_DOC_BYLINE ) ),
126
          Setting.of( title( KEY_DOC_BYLINE ),
127
                      stringProperty( KEY_DOC_BYLINE ) )
128
        ),
129
        Group.of(
130
          get( KEY_DOC_ADDRESS ),
131
          Setting.of( label( KEY_DOC_ADDRESS ) ),
132
          createMultilineSetting( "Address", KEY_DOC_ADDRESS )
133
        ),
134
        Group.of(
135
          get( KEY_DOC_PHONE ),
136
          Setting.of( label( KEY_DOC_PHONE ) ),
137
          Setting.of( title( KEY_DOC_PHONE ),
138
                      stringProperty( KEY_DOC_PHONE ) )
139
        ),
140
        Group.of(
141
          get( KEY_DOC_EMAIL ),
142
          Setting.of( label( KEY_DOC_EMAIL ) ),
143
          Setting.of( title( KEY_DOC_EMAIL ),
144
                      stringProperty( KEY_DOC_EMAIL ) )
145
        ),
146
        Group.of(
147
          get( KEY_DOC_KEYWORDS ),
148
          Setting.of( label( KEY_DOC_KEYWORDS ) ),
149
          Setting.of( title( KEY_DOC_KEYWORDS ),
150
                      stringProperty( KEY_DOC_KEYWORDS ) )
151
        ),
152
        Group.of(
153
          get( KEY_DOC_COPYRIGHT ),
154
          Setting.of( label( KEY_DOC_COPYRIGHT ) ),
155
          Setting.of( title( KEY_DOC_COPYRIGHT ),
156
                      stringProperty( KEY_DOC_COPYRIGHT ) )
157
        ),
158
        Group.of(
159
          get( KEY_DOC_DATE ),
160
          Setting.of( label( KEY_DOC_DATE ) ),
161
          Setting.of( title( KEY_DOC_DATE ),
162
                      stringProperty( KEY_DOC_DATE ) )
163
        )
164
      ),
165
      Category.of(
166
        get( KEY_TYPESET ),
167
        Group.of(
168
          get( KEY_TYPESET_CONTEXT ),
169
          Setting.of( label( KEY_TYPESET_CONTEXT_THEMES_PATH ) ),
170
          Setting.of( title( KEY_TYPESET_CONTEXT_THEMES_PATH ),
171
                      fileProperty( KEY_TYPESET_CONTEXT_THEMES_PATH ), true ),
172
          Setting.of( label( KEY_TYPESET_CONTEXT_CLEAN ) ),
173
          Setting.of( title( KEY_TYPESET_CONTEXT_CLEAN ),
174
                      booleanProperty( KEY_TYPESET_CONTEXT_CLEAN ) )
175
        ),
176
        Group.of(
177
          get( KEY_TYPESET_TYPOGRAPHY ),
178
          Setting.of( label( KEY_TYPESET_TYPOGRAPHY_QUOTES ) ),
179
          Setting.of( title( KEY_TYPESET_TYPOGRAPHY_QUOTES ),
180
                      booleanProperty( KEY_TYPESET_TYPOGRAPHY_QUOTES ) )
181
        )
182
      ),
183
      Category.of(
184
        get( KEY_EDITOR ),
185
        Group.of(
186
          get( KEY_EDITOR_AUTOSAVE ),
187
          Setting.of( label( KEY_EDITOR_AUTOSAVE ) ),
188
          Setting.of( title( KEY_EDITOR_AUTOSAVE ),
189
                      integerProperty( KEY_EDITOR_AUTOSAVE ) )
190
        )
191
      ),
192
      Category.of(
193
        get( KEY_R ),
194
        Group.of(
195
          get( KEY_R_DIR ),
196
          Setting.of( label( KEY_R_DIR,
197
                             stringProperty( KEY_DEF_DELIM_BEGAN ).get(),
198
                             stringProperty( KEY_DEF_DELIM_ENDED ).get() ) ),
199
          Setting.of( title( KEY_R_DIR ),
200
                      fileProperty( KEY_R_DIR ), true )
201
        ),
202
        Group.of(
203
          get( KEY_R_SCRIPT ),
204
          Setting.of( label( KEY_R_SCRIPT ) ),
205
          createMultilineSetting( "Script", KEY_R_SCRIPT )
206
        ),
207
        Group.of(
208
          get( KEY_R_DELIM_BEGAN ),
209
          Setting.of( label( KEY_R_DELIM_BEGAN ) ),
210
          Setting.of( title( KEY_R_DELIM_BEGAN ),
211
                      stringProperty( KEY_R_DELIM_BEGAN ) )
212
        ),
213
        Group.of(
214
          get( KEY_R_DELIM_ENDED ),
215
          Setting.of( label( KEY_R_DELIM_ENDED ) ),
216
          Setting.of( title( KEY_R_DELIM_ENDED ),
217
                      stringProperty( KEY_R_DELIM_ENDED ) )
218
        )
219
      ),
220
      Category.of(
221
        get( KEY_IMAGES ),
222
        Group.of(
223
          get( KEY_IMAGES_DIR ),
224
          Setting.of( label( KEY_IMAGES_DIR ) ),
225
          Setting.of( title( KEY_IMAGES_DIR ),
226
                      fileProperty( KEY_IMAGES_DIR ), true )
227
        ),
228
        Group.of(
229
          get( KEY_IMAGES_ORDER ),
230
          Setting.of( label( KEY_IMAGES_ORDER ) ),
231
          Setting.of( title( KEY_IMAGES_ORDER ),
232
                      stringProperty( KEY_IMAGES_ORDER ) )
233
        ),
234
        Group.of(
235
          get( KEY_IMAGES_RESIZE ),
236
          Setting.of( label( KEY_IMAGES_RESIZE ) ),
237
          Setting.of( title( KEY_IMAGES_RESIZE ),
238
                      booleanProperty( KEY_IMAGES_RESIZE ) )
239
        ),
240
        Group.of(
241
          get( KEY_IMAGES_SERVER ),
242
          Setting.of( label( KEY_IMAGES_SERVER ) ),
243
          Setting.of( title( KEY_IMAGES_SERVER ),
244
                      stringProperty( KEY_IMAGES_SERVER ) )
245
        )
246
      ),
247
      Category.of(
248
        get( KEY_DEF ),
249
        Group.of(
250
          get( KEY_DEF_PATH ),
251
          Setting.of( label( KEY_DEF_PATH ) ),
252
          Setting.of( title( KEY_DEF_PATH ),
253
                      fileProperty( KEY_DEF_PATH ), false )
254
        ),
255
        Group.of(
256
          get( KEY_DEF_DELIM_BEGAN ),
257
          Setting.of( label( KEY_DEF_DELIM_BEGAN ) ),
258
          Setting.of( title( KEY_DEF_DELIM_BEGAN ),
259
                      stringProperty( KEY_DEF_DELIM_BEGAN ) )
260
        ),
261
        Group.of(
262
          get( KEY_DEF_DELIM_ENDED ),
263
          Setting.of( label( KEY_DEF_DELIM_ENDED ) ),
264
          Setting.of( title( KEY_DEF_DELIM_ENDED ),
265
                      stringProperty( KEY_DEF_DELIM_ENDED ) )
266
        )
267
      ),
268
      Category.of(
269
        get( KEY_UI_FONT ),
270
        Group.of(
271
          get( KEY_UI_FONT_EDITOR ),
272
          Setting.of( label( KEY_UI_FONT_EDITOR_NAME ) ),
273
          Setting.of( title( KEY_UI_FONT_EDITOR_NAME ),
274
                      createFontNameField(
275
                        stringProperty( KEY_UI_FONT_EDITOR_NAME ),
276
                        doubleProperty( KEY_UI_FONT_EDITOR_SIZE ) ),
277
                      stringProperty( KEY_UI_FONT_EDITOR_NAME ) ),
278
          Setting.of( label( KEY_UI_FONT_EDITOR_SIZE ) ),
279
          Setting.of( title( KEY_UI_FONT_EDITOR_SIZE ),
280
                      doubleProperty( KEY_UI_FONT_EDITOR_SIZE ) )
281
        ),
282
        Group.of(
283
          get( KEY_UI_FONT_PREVIEW ),
284
          Setting.of( label( KEY_UI_FONT_PREVIEW_NAME ) ),
285
          Setting.of( title( KEY_UI_FONT_PREVIEW_NAME ),
286
                      createFontNameField(
287
                        stringProperty( KEY_UI_FONT_PREVIEW_NAME ),
288
                        doubleProperty( KEY_UI_FONT_PREVIEW_SIZE ) ),
289
                      stringProperty( KEY_UI_FONT_PREVIEW_NAME ) ),
290
          Setting.of( label( KEY_UI_FONT_PREVIEW_SIZE ) ),
291
          Setting.of( title( KEY_UI_FONT_PREVIEW_SIZE ),
292
                      doubleProperty( KEY_UI_FONT_PREVIEW_SIZE ) ),
293
          Setting.of( label( KEY_UI_FONT_PREVIEW_MONO_NAME ) ),
294
          Setting.of( title( KEY_UI_FONT_PREVIEW_MONO_NAME ),
295
                      createFontNameField(
296
                        stringProperty( KEY_UI_FONT_PREVIEW_MONO_NAME ),
297
                        doubleProperty( KEY_UI_FONT_PREVIEW_MONO_SIZE ) ),
298
                      stringProperty( KEY_UI_FONT_PREVIEW_MONO_NAME ) ),
299
          Setting.of( label( KEY_UI_FONT_PREVIEW_MONO_SIZE ) ),
300
          Setting.of( title( KEY_UI_FONT_PREVIEW_MONO_SIZE ),
301
                      doubleProperty( KEY_UI_FONT_PREVIEW_MONO_SIZE ) )
302
        )
303
      ),
304
      Category.of(
305
        get( KEY_UI_SKIN ),
306
        Group.of(
307
          get( KEY_UI_SKIN_SELECTION ),
308
          Setting.of( label( KEY_UI_SKIN_SELECTION ) ),
309
          Setting.of( title( KEY_UI_SKIN_SELECTION ),
310
                      skinListProperty(),
311
                      skinProperty( KEY_UI_SKIN_SELECTION ) )
312
        ),
313
        Group.of(
314
          get( KEY_UI_SKIN_CUSTOM ),
315
          Setting.of( label( KEY_UI_SKIN_CUSTOM ) ),
316
          Setting.of( title( KEY_UI_SKIN_CUSTOM ),
317
                      fileProperty( KEY_UI_SKIN_CUSTOM ), false )
318
        )
319
      ),
320
      Category.of(
321
        get( KEY_UI_PREVIEW ),
322
        Group.of(
323
          get( KEY_UI_PREVIEW_STYLESHEET ),
324
          Setting.of( label( KEY_UI_PREVIEW_STYLESHEET ) ),
325
          Setting.of( title( KEY_UI_PREVIEW_STYLESHEET ),
326
                      fileProperty( KEY_UI_PREVIEW_STYLESHEET ), false )
327
        )
328
      ),
329
      Category.of(
330
        get( KEY_LANGUAGE ),
331
        Group.of(
332
          get( KEY_LANGUAGE_LOCALE ),
333
          Setting.of( label( KEY_LANGUAGE_LOCALE ) ),
334
          Setting.of( title( KEY_LANGUAGE_LOCALE ),
335
                      localeListProperty(),
336
                      localeProperty( KEY_LANGUAGE_LOCALE ) )
337
        )
338
      )};
339
  }
340
341
  @SuppressWarnings( "unchecked" )
342
  private Setting<StringField, StringProperty> createMultilineSetting(
343
    final String description, final Key property ) {
344
    final Setting<StringField, StringProperty> setting =
345
      Setting.of( description, stringProperty( property ) );
346
    final var field = setting.getElement();
347
    field.multiline( true );
348
349
    return setting;
350
  }
351
352
  private void initKeyEventHandler( final PreferencesFx preferences ) {
353
    final var view = preferences.getView();
354
    final var nodes = view.getChildrenUnmodifiable();
355
    final var master = (MasterDetailPane) nodes.get( 0 );
356
    final var detail = (NavigationView) master.getDetailNode();
357
    final var pane = (DialogPane) view.getParent();
358
359
    detail.setOnKeyReleased( ( key ) -> {
360
      switch( key.getCode() ) {
361
        case ENTER -> ((Button) pane.lookupButton( OK )).fire();
362
        case ESCAPE -> ((Button) pane.lookupButton( CANCEL )).fire();
363
      }
364
    } );
365
  }
366
367
  /**
368
   * Creates a label for the given key after interpolating its value.
369
   *
370
   * @param key The key to find in the resource bundle.
371
   * @return The value of the key as a label.
372
   */
373
  private Node label( final Key key ) {
374
    return label( key, (String[]) null );
375
  }
376
377
  private Node label( final Key key, final String... values ) {
378
    return new Label( get( key.toString() + ".desc", (Object[]) values ) );
379
  }
380
381
  private String title( final Key key ) {
382
    return get( key.toString() + ".title" );
383
  }
384
385
  private ObjectProperty<File> fileProperty( final Key key ) {
386
    return mWorkspace.fileProperty( key );
387
  }
388
389
  private StringProperty stringProperty( final Key key ) {
390
    return mWorkspace.stringProperty( key );
391
  }
392
393
  private BooleanProperty booleanProperty( final Key key ) {
394
    return mWorkspace.booleanProperty( key );
395
  }
396
397
  @SuppressWarnings( "SameParameterValue" )
398
  private IntegerProperty integerProperty( final Key key ) {
399
    return mWorkspace.integerProperty( key );
380400
  }
381401
M src/main/java/com/keenwrite/preferences/Workspace.java
8080
    entry( KEY_DOC_DATE, asStringProperty( getDate() ) ),
8181
82
    entry( KEY_EDITOR_AUTOSAVE, asIntegerProperty( 30 ) ),
83
8284
    entry( KEY_R_SCRIPT, asStringProperty( "" ) ),
8385
    entry( KEY_R_DIR, asFileProperty( USER_DIRECTORY ) ),
...
115117
    entry( KEY_UI_SKIN_SELECTION, asSkinProperty( SKIN_DEFAULT ) ),
116118
    entry( KEY_UI_SKIN_CUSTOM, asFileProperty( SKIN_CUSTOM_DEFAULT ) ),
119
120
    entry( KEY_UI_PREVIEW_STYLESHEET, asFileProperty( PREVIEW_CUSTOM_DEFAULT ) ),
117121
118122
    entry( KEY_LANGUAGE_LOCALE, asLocaleProperty( LOCALE_DEFAULT ) ),
...
127131
  private StringProperty asStringProperty( final String defaultValue ) {
128132
    return new SimpleStringProperty( defaultValue );
133
  }
134
135
  @SuppressWarnings( "SameParameterValue" )
136
  private IntegerProperty asIntegerProperty( final int defaultValue ) {
137
    return new SimpleIntegerProperty( defaultValue );
129138
  }
130139
...
163172
      LocaleProperty.class, LocaleProperty::parseLocale,
164173
      SimpleBooleanProperty.class, Boolean::parseBoolean,
174
      SimpleIntegerProperty.class, Integer::parseInt,
165175
      SimpleDoubleProperty.class, Double::parseDouble,
166176
      SimpleFloatProperty.class, Float::parseFloat,
...
253263
    assert key != null;
254264
    return (Boolean) valuesProperty( key ).getValue();
265
  }
266
267
  /**
268
   * Returns the {@link Integer} preference value associated with the given
269
   * {@link Key}. The caller must be sure that the given {@link Key} is
270
   * associated with a value that matches the return type.
271
   *
272
   * @param key The {@link Key} associated with a preference value.
273
   * @return The value associated with the given {@link Key}.
274
   */
275
  public int toInteger( final Key key ) {
276
    assert key != null;
277
    return (Integer) valuesProperty( key ).getValue();
255278
  }
256279
...
282305
    assert ended != null;
283306
    return new Tokens( stringProperty( began ), stringProperty( ended ) );
307
  }
308
309
  @SuppressWarnings( "SameParameterValue" )
310
  public IntegerProperty integerProperty( final Key key ) {
311
    assert key != null;
312
    return valuesProperty( key );
284313
  }
285314
M src/main/java/com/keenwrite/preferences/WorkspaceKeys.java
2727
  public static final Key KEY_DOC_COPYRIGHT = key( KEY_DOC, "copyright" );
2828
29
  public static final Key KEY_EDITOR = key( KEY_ROOT, "editor" );
30
  public static final Key KEY_EDITOR_AUTOSAVE = key( KEY_EDITOR, "autosave" );
31
2932
  public static final Key KEY_R = key( KEY_ROOT, "r" );
3033
  public static final Key KEY_R_SCRIPT = key( KEY_R, "script" );
...
7881
  public static final Key KEY_UI_SKIN_SELECTION = key( KEY_UI_SKIN, "selection" );
7982
  public static final Key KEY_UI_SKIN_CUSTOM = key( KEY_UI_SKIN, "custom" );
83
84
  public static final Key KEY_UI_PREVIEW = key( KEY_UI, "preview" );
85
  public static final Key KEY_UI_PREVIEW_STYLESHEET = key( KEY_UI_PREVIEW, "stylesheet" );
8086
8187
  public static final Key KEY_LANGUAGE = key( KEY_ROOT, "language" );
A src/main/java/com/keenwrite/preview/DiagramUrlGenerator.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.preview;
3
4
import java.util.zip.Deflater;
5
6
import static java.lang.String.format;
7
import static java.util.Base64.getUrlEncoder;
8
9
/**
10
 * Responsible for transforming text-based diagram descriptions into URLs
11
 * that the HTML renderer can embed as SVG images.
12
 */
13
public class DiagramUrlGenerator {
14
  private DiagramUrlGenerator() {
15
  }
16
17
  /**
18
   * Returns a URL that can be embedded as the {@code src} attribute to an HTML
19
   * {@code img} tag.
20
   *
21
   * @param server  Name of server to use for diagram conversion.
22
   * @param diagram Diagram type (e.g., Graphviz, Block, PlantUML).
23
   * @param text    Diagram text that conforms to the diagram type.
24
   * @return A secure URL string to use as an image {@code src} attribute.
25
   */
26
  public static String toUrl(
27
    final String server, final String diagram, final String text ) {
28
    return format(
29
      "https://%s/%s/svg/%s", server, diagram, encode( text )
30
    );
31
  }
32
33
  /**
34
   * Convert the plain-text version of the diagram into a URL-encoded value
35
   * suitable for passing to a web server using an HTTP GET request.
36
   *
37
   * @param text The diagram text to encode.
38
   * @return The URL-encoded (and compressed) version of the text.
39
   */
40
  private static String encode( final String text ) {
41
    return getUrlEncoder().encodeToString( compress( text.getBytes() ) );
42
  }
43
44
  /**
45
   * Compresses a sequence of bytes using ZLIB format.
46
   *
47
   * @param source The data to compress.
48
   * @return A lossless, compressed sequence of bytes.
49
   */
50
  private static byte[] compress( byte[] source ) {
51
    final var deflater = new Deflater();
52
    deflater.setInput( source );
53
    deflater.finish();
54
55
    final var compressed = new byte[ Short.MAX_VALUE ];
56
    final var size = deflater.deflate( compressed );
57
    final var result = new byte[ size ];
58
59
    System.arraycopy( compressed, 0, result, 0, size );
60
61
    return result;
62
  }
63
}
164
M src/main/java/com/keenwrite/preview/HtmlPreview.java
6161
   * <li>%s --- default stylesheet</li>
6262
   * <li>%s --- language-specific stylesheet</li>
63
   * <li>%s --- font family</li>
64
   * <li>%d --- font size (must be pixels, not points due to bug)</li>
65
   * <li>%s --- base href</li>
66
   * </p>
67
   */
68
  private static final String HTML_HEAD =
69
    """
70
      <!doctype html>
71
      <html lang='%s'><head><title> </title><meta charset='utf-8'/>
72
      %s%s<style>body{font-family:'%s';font-size: %dpx;}</style>%s</head><body>
73
      """;
74
75
  private static final String HTML_TAIL = "</body></html>";
76
77
  private static final URL HTML_STYLE_PREVIEW = toUrl( STYLESHEET_PREVIEW );
78
79
  private final ChainedReplacedElementFactory mFactory;
80
81
  /**
82
   * Reusing this buffer prevents repetitious memory re-allocations.
83
   */
84
  private final StringBuilder mDocument = new StringBuilder( 65536 );
85
86
  private HtmlPanel mView;
87
  private JScrollPane mScrollPane;
88
  private String mBaseUriPath = "";
89
  private String mHead = "";
90
91
  private volatile boolean mLocked;
92
  private final JButton mScrollLockButton = new JButton();
93
  private final Workspace mWorkspace;
94
95
  /**
96
   * Creates a new preview pane that can scroll to the caret position within the
97
   * document.
98
   *
99
   * @param workspace Contains locale and font size information.
100
   */
101
  public HtmlPreview( final Workspace workspace ) {
102
    mWorkspace = workspace;
103
104
    // The order is important: SwingReplacedElementFactory replaces SVG images
105
    // with a blank image, which will cause the chained factory to cache the
106
    // image and exit. Instead, the SVG must execute first to rasterize the
107
    // content. Consequently, the chained factory must maintain insertion order.
108
    mFactory = new ChainedReplacedElementFactory(
109
      new SvgReplacedElementFactory(),
110
      new SwingReplacedElementFactory()
111
    );
112
113
    // Attempts to prevent a flash of black un-styled content upon load.
114
    setStyle( "-fx-background-color: white;" );
115
116
    invokeLater( () -> {
117
      mHead = generateHead();
118
      mView = new HtmlPanel();
119
      mScrollPane = new JScrollPane( mView );
120
      final var verticalBar = mScrollPane.getVerticalScrollBar();
121
      final var verticalPanel = new JPanel( new BorderLayout() );
122
123
      final var map = verticalBar.getInputMap( WHEN_IN_FOCUSED_WINDOW );
124
      addKeyboardEvents( map );
125
126
      mScrollLockButton.setFont( getIconFont( 14 ) );
127
      mScrollLockButton.setText( getLockText( mLocked ) );
128
      mScrollLockButton.setMargin( new Insets( 1, 0, 0, 0 ) );
129
      mScrollLockButton.addActionListener( e -> fireScrollLockEvent( !mLocked ) );
130
131
      verticalPanel.add( verticalBar, CENTER );
132
      verticalPanel.add( mScrollLockButton, PAGE_END );
133
134
      final var wrapper = new JPanel( new BorderLayout() );
135
      wrapper.add( mScrollPane, CENTER );
136
      wrapper.add( verticalPanel, LINE_END );
137
138
      // Enabling the cache attempts to prevent black flashes when resizing.
139
      setCache( true );
140
      setCacheHint( SPEED );
141
      setContent( wrapper );
142
      wrapper.addComponentListener( this );
143
144
      final var context = mView.getSharedContext();
145
      final var textRenderer = context.getTextRenderer();
146
      context.setReplacedElementFactory( mFactory );
147
      textRenderer.setSmoothingThreshold( 0 );
148
149
      localeProperty().addListener( ( c, o, n ) -> rerender() );
150
      fontFamilyProperty().addListener( ( c, o, n ) -> rerender() );
151
      fontSizeProperty().addListener( ( c, o, n ) -> rerender() );
152
    } );
153
154
    register( this );
155
  }
156
157
  @Subscribe
158
  public void handle( final ScrollLockEvent event ) {
159
    mLocked = event.isLocked();
160
    invokeLater( () -> mScrollLockButton.setText( getLockText( mLocked ) ) );
161
  }
162
163
  /**
164
   * Updates the internal HTML source shown in the preview pane.
165
   *
166
   * @param html The new HTML document to display.
167
   */
168
  public void render( final String html ) {
169
    mView.render( decorate( html ), getBaseUri() );
170
  }
171
172
  /**
173
   * Clears the caches then re-renders the content.
174
   */
175
  public void refresh() {
176
    mFactory.clearCache();
177
    rerender();
178
  }
179
180
  /**
181
   * Recomputes the HTML head then renders the document.
182
   */
183
  private void rerender() {
184
    mHead = generateHead();
185
    render( mDocument.toString() );
186
  }
187
188
  /**
189
   * Attaches the HTML head prefix and HTML tail suffix to the given HTML
190
   * string.
191
   *
192
   * @param html The HTML to adorn with opening and closing tags.
193
   * @return A complete HTML document, ready for rendering.
194
   */
195
  private String decorate( final String html ) {
196
    mDocument.setLength( 0 );
197
    mDocument.append( html );
198
199
    // Head and tail must be separate from document due to re-rendering.
200
    return mHead + mDocument + HTML_TAIL;
201
  }
202
203
  /**
204
   * Called when settings are changed that affect the HTML document preamble.
205
   * This is a minor performance optimization to avoid generating the head
206
   * each time that the document itself changes.
207
   *
208
   * @return A new doctype and HTML {@code head} element.
209
   */
210
  private String generateHead() {
211
    final var locale = getLocale();
212
    final var url = toUrl( locale );
213
    final var base = getBaseUri();
214
215
    // Point sizes are converted to pixels because of a rendering bug.
216
    return format(
217
      HTML_HEAD,
218
      locale.getLanguage(),
219
      format( HTML_STYLESHEET, HTML_STYLE_PREVIEW ),
220
      url == null ? "" : format( HTML_STYLESHEET, url ),
221
      getFontFamily(),
222
      toPixels( getFontSize() ),
223
      base.isBlank() ? "" : format( HTML_BASE, base )
224
    );
225
  }
226
227
  /**
228
   * Clears the preview pane by rendering an empty string.
229
   */
230
  public void clear() {
231
    render( "" );
232
  }
233
234
  /**
235
   * Sets the base URI to the containing directory the file being edited.
236
   *
237
   * @param path The path to the file being edited.
238
   */
239
  public void setBaseUri( final Path path ) {
240
    final var parent = path.getParent();
241
    mBaseUriPath = parent == null ? "" : parent.toUri().toString();
242
  }
243
244
  /**
245
   * Scrolls to the closest element matching the given identifier without
246
   * waiting for the document to be ready.
247
   *
248
   * @param id Scroll the preview pane to this unique paragraph identifier.
249
   */
250
  public void scrollTo( final String id ) {
251
    if( mLocked ) {
252
      return;
253
    }
254
255
    invokeLater( () -> {
256
      int iter = 0;
257
      Box box = null;
258
259
      while( iter++ < 3 && ((box = mView.getBoxById( id )) == null) ) {
260
        try {
261
          sleep( 10 );
262
        } catch( final Exception ex ) {
263
          clue( ex );
264
        }
265
      }
266
267
      scrollTo( box );
268
    } );
269
  }
270
271
  /**
272
   * Scrolls to the location specified by the {@link Box} that corresponds
273
   * to a point somewhere in the preview pane. If there is no caret, then
274
   * this will not change the scroll position. Changing the scroll position
275
   * to the top if the {@link Box} instance is {@code null} will result in
276
   * jumping around a lot and inconsistent synchronization issues.
277
   *
278
   * @param box The rectangular region containing the caret, or {@code null}
279
   *            if the HTML does not have a caret.
280
   */
281
  private void scrollTo( final Box box ) {
282
    if( box != null ) {
283
      invokeLater( () -> {
284
        mView.scrollTo( createPoint( box ) );
285
        getScrollPane().repaint();
286
      } );
287
    }
288
  }
289
290
  /**
291
   * Creates a {@link Point} to use as a reference for scrolling to the area
292
   * described by the given {@link Box}. The {@link Box} coordinates are used
293
   * to populate the {@link Point}'s location, with minor adjustments for
294
   * vertical centering.
295
   *
296
   * @param box The {@link Box} that represents a scrolling anchor reference.
297
   * @return A coordinate suitable for scrolling to.
298
   */
299
  private Point createPoint( final Box box ) {
300
    assert box != null;
301
302
    // Scroll back up by half the height of the scroll bar to keep the typing
303
    // area within the view port. Otherwise the view port will have jumped too
304
    // high up and the most recently typed letters won't be visible.
305
    int y = max( box.getAbsY() - getVerticalScrollBarHeight() / 2, 0 );
306
    int x = box.getAbsX();
307
308
    if( !box.getStyle().isInline() ) {
309
      final var margin = box.getMargin( mView.getLayoutContext() );
310
      y += margin.top();
311
      x += margin.left();
312
    }
313
314
    return new Point( x, y );
315
  }
316
317
  private String getBaseUri() {
318
    return mBaseUriPath;
319
  }
320
321
  private JScrollPane getScrollPane() {
322
    return mScrollPane;
323
  }
324
325
  public JScrollBar getVerticalScrollBar() {
326
    return getScrollPane().getVerticalScrollBar();
327
  }
328
329
  private int getVerticalScrollBarHeight() {
330
    return getVerticalScrollBar().getHeight();
331
  }
332
333
  /**
334
   * Returns the ISO 639 alpha-2 or alpha-3 language code followed by a hyphen
335
   * followed by the ISO 15924 alpha-4 script code, followed by an ISO 3166
336
   * alpha-2 country code or UN M.49 numeric-3 area code. For example, this
337
   * could return "en-Latn-CA" for Canadian English written in the Latin
338
   * character set.
339
   *
340
   * @return Unique identifier for language and country.
341
   */
342
  private static URL toUrl( final Locale locale ) {
343
    return toUrl(
344
      get(
345
        sSettings.getSetting( STYLESHEET_PREVIEW_LOCALE, "" ),
346
        locale.getLanguage(),
347
        locale.getScript(),
348
        locale.getCountry()
349
      )
350
    );
351
  }
352
353
  private static URL toUrl( final String path ) {
354
    return HtmlPreview.class.getResource( path );
355
  }
356
357
  private Locale getLocale() {
358
    return localeProperty().toLocale();
359
  }
360
361
  private LocaleProperty localeProperty() {
362
    return mWorkspace.localeProperty( KEY_LANGUAGE_LOCALE );
363
  }
364
365
  private String getFontFamily() {
366
    return fontFamilyProperty().get();
367
  }
368
369
  private StringProperty fontFamilyProperty() {
370
    return mWorkspace.stringProperty( KEY_UI_FONT_PREVIEW_NAME );
371
  }
372
373
  private double getFontSize() {
374
    return fontSizeProperty().get();
375
  }
376
377
  /**
378
   * Returns the font size in points.
379
   *
380
   * @return The user-defined font size (in pt).
381
   */
382
  private DoubleProperty fontSizeProperty() {
383
    return mWorkspace.doubleProperty( KEY_UI_FONT_PREVIEW_SIZE );
384
  }
385
386
  private String getLockText( final boolean locked ) {
387
    return Character.toString( (locked ? LOCK : UNLOCK_ALT).getChar() );
388
  }
389
390
  /**
391
   * Maps keyboard events to scrollbar commands so that users may control
392
   * the {@link HtmlPreview} panel using the keyboard.
393
   *
394
   * @param map The map to update with keyboard events.
395
   */
396
  private void addKeyboardEvents( final InputMap map ) {
397
    map.put( getKeyStroke( VK_DOWN, 0 ), "positiveUnitIncrement" );
398
    map.put( getKeyStroke( VK_UP, 0 ), "negativeUnitIncrement" );
399
    map.put( getKeyStroke( VK_PAGE_DOWN, 0 ), "positiveBlockIncrement" );
400
    map.put( getKeyStroke( VK_PAGE_UP, 0 ), "negativeBlockIncrement" );
401
    map.put( getKeyStroke( VK_HOME, 0 ), "minScroll" );
402
    map.put( getKeyStroke( VK_END, 0 ), "maxScroll" );
403
  }
404
405
  @Override
406
  public void componentResized( final ComponentEvent e ) {
407
    if( mWorkspace.toBoolean( KEY_IMAGES_RESIZE ) ) {
408
      mFactory.clearCache();
409
    }
410
411
    // Force update on the Swing EDT, otherwise the scrollbar and content
412
    // will not be updated correctly on some platforms.
413
    invokeLater( () -> getContent().repaint() );
414
  }
415
416
  @Override
417
  public void componentMoved( final ComponentEvent e ) { }
418
419
  @Override
420
  public void componentShown( final ComponentEvent e ) { }
421
422
  @Override
423
  public void componentHidden( final ComponentEvent e ) { }
63
   * <li>%s --- user-customized stylesheet</li>
64
   * <li>%s --- font family</li>
65
   * <li>%d --- font size (must be pixels, not points due to bug)</li>
66
   * <li>%s --- base href</li>
67
   * </p>
68
   */
69
  private static final String HTML_HEAD =
70
    """
71
      <!doctype html>
72
      <html lang='%s'><head><title> </title><meta charset='utf-8'/>
73
      %s%s%s<style>body{font-family:'%s';font-size: %dpx;}</style>%s</head><body>
74
      """;
75
76
  private static final String HTML_TAIL = "</body></html>";
77
78
  private static final URL HTML_STYLE_PREVIEW = toUrl( STYLESHEET_PREVIEW );
79
80
  private final ChainedReplacedElementFactory mFactory;
81
82
  /**
83
   * Reusing this buffer prevents repetitious memory re-allocations.
84
   */
85
  private final StringBuilder mDocument = new StringBuilder( 65536 );
86
87
  private HtmlPanel mView;
88
  private JScrollPane mScrollPane;
89
  private String mBaseUriPath = "";
90
  private String mHead = "";
91
92
  private volatile boolean mLocked;
93
  private final JButton mScrollLockButton = new JButton();
94
  private final Workspace mWorkspace;
95
96
  /**
97
   * Creates a new preview pane that can scroll to the caret position within the
98
   * document.
99
   *
100
   * @param workspace Contains locale and font size information.
101
   */
102
  public HtmlPreview( final Workspace workspace ) {
103
    mWorkspace = workspace;
104
105
    // The order is important: SwingReplacedElementFactory replaces SVG images
106
    // with a blank image, which will cause the chained factory to cache the
107
    // image and exit. Instead, the SVG must execute first to rasterize the
108
    // content. Consequently, the chained factory must maintain insertion order.
109
    mFactory = new ChainedReplacedElementFactory(
110
      new SvgReplacedElementFactory(),
111
      new SwingReplacedElementFactory()
112
    );
113
114
    // Attempts to prevent a flash of black un-styled content upon load.
115
    setStyle( "-fx-background-color: white;" );
116
117
    invokeLater( () -> {
118
      mHead = generateHead();
119
      mView = new HtmlPanel();
120
      mScrollPane = new JScrollPane( mView );
121
      final var verticalBar = mScrollPane.getVerticalScrollBar();
122
      final var verticalPanel = new JPanel( new BorderLayout() );
123
124
      final var map = verticalBar.getInputMap( WHEN_IN_FOCUSED_WINDOW );
125
      addKeyboardEvents( map );
126
127
      mScrollLockButton.setFont( getIconFont( 14 ) );
128
      mScrollLockButton.setText( getLockText( mLocked ) );
129
      mScrollLockButton.setMargin( new Insets( 1, 0, 0, 0 ) );
130
      mScrollLockButton.addActionListener( e -> fireScrollLockEvent( !mLocked ) );
131
132
      verticalPanel.add( verticalBar, CENTER );
133
      verticalPanel.add( mScrollLockButton, PAGE_END );
134
135
      final var wrapper = new JPanel( new BorderLayout() );
136
      wrapper.add( mScrollPane, CENTER );
137
      wrapper.add( verticalPanel, LINE_END );
138
139
      // Enabling the cache attempts to prevent black flashes when resizing.
140
      setCache( true );
141
      setCacheHint( SPEED );
142
      setContent( wrapper );
143
      wrapper.addComponentListener( this );
144
145
      final var context = mView.getSharedContext();
146
      final var textRenderer = context.getTextRenderer();
147
      context.setReplacedElementFactory( mFactory );
148
      textRenderer.setSmoothingThreshold( 0 );
149
150
      localeProperty().addListener( ( c, o, n ) -> rerender() );
151
      fontFamilyProperty().addListener( ( c, o, n ) -> rerender() );
152
      fontSizeProperty().addListener( ( c, o, n ) -> rerender() );
153
    } );
154
155
    register( this );
156
  }
157
158
  @Subscribe
159
  public void handle( final ScrollLockEvent event ) {
160
    mLocked = event.isLocked();
161
    invokeLater( () -> mScrollLockButton.setText( getLockText( mLocked ) ) );
162
  }
163
164
  /**
165
   * Updates the internal HTML source shown in the preview pane.
166
   *
167
   * @param html The new HTML document to display.
168
   */
169
  public void render( final String html ) {
170
    mView.render( decorate( html ), getBaseUri() );
171
  }
172
173
  /**
174
   * Clears the caches then re-renders the content.
175
   */
176
  public void refresh() {
177
    mFactory.clearCache();
178
    rerender();
179
  }
180
181
  /**
182
   * Recomputes the HTML head then renders the document.
183
   */
184
  private void rerender() {
185
    mHead = generateHead();
186
    render( mDocument.toString() );
187
  }
188
189
  /**
190
   * Attaches the HTML head prefix and HTML tail suffix to the given HTML
191
   * string.
192
   *
193
   * @param html The HTML to adorn with opening and closing tags.
194
   * @return A complete HTML document, ready for rendering.
195
   */
196
  private String decorate( final String html ) {
197
    mDocument.setLength( 0 );
198
    mDocument.append( html );
199
200
    // Head and tail must be separate from document due to re-rendering.
201
    return mHead + mDocument + HTML_TAIL;
202
  }
203
204
  /**
205
   * Called when settings are changed that affect the HTML document preamble.
206
   * This is a minor performance optimization to avoid generating the head
207
   * each time that the document itself changes.
208
   *
209
   * @return A new doctype and HTML {@code head} element.
210
   */
211
  private String generateHead() {
212
    final var locale = getLocale();
213
    final var base = getBaseUri();
214
    final var custom = getCustomStylesheetUrl();
215
216
    // Point sizes are converted to pixels because of a rendering bug.
217
    return format(
218
      HTML_HEAD,
219
      locale.getLanguage(),
220
      toStylesheetString( HTML_STYLE_PREVIEW ),
221
      toStylesheetString( toUrl( locale ) ),
222
      toStylesheetString( custom ),
223
      getFontFamily(),
224
      toPixels( getFontSize() ),
225
      base.isBlank() ? "" : format( HTML_BASE, base )
226
    );
227
  }
228
229
  /**
230
   * Clears the preview pane by rendering an empty string.
231
   */
232
  public void clear() {
233
    render( "" );
234
  }
235
236
  /**
237
   * Sets the base URI to the containing directory the file being edited.
238
   *
239
   * @param path The path to the file being edited.
240
   */
241
  public void setBaseUri( final Path path ) {
242
    final var parent = path.getParent();
243
    mBaseUriPath = parent == null ? "" : parent.toUri().toString();
244
  }
245
246
  /**
247
   * Scrolls to the closest element matching the given identifier without
248
   * waiting for the document to be ready.
249
   *
250
   * @param id Scroll the preview pane to this unique paragraph identifier.
251
   */
252
  public void scrollTo( final String id ) {
253
    if( mLocked ) {
254
      return;
255
    }
256
257
    invokeLater( () -> {
258
      int iter = 0;
259
      Box box = null;
260
261
      while( iter++ < 3 && ((box = mView.getBoxById( id )) == null) ) {
262
        try {
263
          sleep( 10 );
264
        } catch( final Exception ex ) {
265
          clue( ex );
266
        }
267
      }
268
269
      scrollTo( box );
270
    } );
271
  }
272
273
  /**
274
   * Scrolls to the location specified by the {@link Box} that corresponds
275
   * to a point somewhere in the preview pane. If there is no caret, then
276
   * this will not change the scroll position. Changing the scroll position
277
   * to the top if the {@link Box} instance is {@code null} will result in
278
   * jumping around a lot and inconsistent synchronization issues.
279
   *
280
   * @param box The rectangular region containing the caret, or {@code null}
281
   *            if the HTML does not have a caret.
282
   */
283
  private void scrollTo( final Box box ) {
284
    if( box != null ) {
285
      invokeLater( () -> {
286
        mView.scrollTo( createPoint( box ) );
287
        getScrollPane().repaint();
288
      } );
289
    }
290
  }
291
292
  /**
293
   * Creates a {@link Point} to use as a reference for scrolling to the area
294
   * described by the given {@link Box}. The {@link Box} coordinates are used
295
   * to populate the {@link Point}'s location, with minor adjustments for
296
   * vertical centering.
297
   *
298
   * @param box The {@link Box} that represents a scrolling anchor reference.
299
   * @return A coordinate suitable for scrolling to.
300
   */
301
  private Point createPoint( final Box box ) {
302
    assert box != null;
303
304
    // Scroll back up by half the height of the scroll bar to keep the typing
305
    // area within the view port. Otherwise the view port will have jumped too
306
    // high up and the most recently typed letters won't be visible.
307
    int y = max( box.getAbsY() - getVerticalScrollBarHeight() / 2, 0 );
308
    int x = box.getAbsX();
309
310
    if( !box.getStyle().isInline() ) {
311
      final var margin = box.getMargin( mView.getLayoutContext() );
312
      y += margin.top();
313
      x += margin.left();
314
    }
315
316
    return new Point( x, y );
317
  }
318
319
  private String getBaseUri() {
320
    return mBaseUriPath;
321
  }
322
323
  private JScrollPane getScrollPane() {
324
    return mScrollPane;
325
  }
326
327
  public JScrollBar getVerticalScrollBar() {
328
    return getScrollPane().getVerticalScrollBar();
329
  }
330
331
  private int getVerticalScrollBarHeight() {
332
    return getVerticalScrollBar().getHeight();
333
  }
334
335
  /**
336
   * Returns the ISO 639 alpha-2 or alpha-3 language code followed by a hyphen
337
   * followed by the ISO 15924 alpha-4 script code, followed by an ISO 3166
338
   * alpha-2 country code or UN M.49 numeric-3 area code. For example, this
339
   * could return "en-Latn-CA" for Canadian English written in the Latin
340
   * character set.
341
   *
342
   * @return Unique identifier for language and country.
343
   */
344
  private static URL toUrl( final Locale locale ) {
345
    return toUrl(
346
      get(
347
        sSettings.getSetting( STYLESHEET_PREVIEW_LOCALE, "" ),
348
        locale.getLanguage(),
349
        locale.getScript(),
350
        locale.getCountry()
351
      )
352
    );
353
  }
354
355
  private static URL toUrl( final String path ) {
356
    return HtmlPreview.class.getResource( path );
357
  }
358
359
  private Locale getLocale() {
360
    return localeProperty().toLocale();
361
  }
362
363
  private LocaleProperty localeProperty() {
364
    return mWorkspace.localeProperty( KEY_LANGUAGE_LOCALE );
365
  }
366
367
  private String getFontFamily() {
368
    return fontFamilyProperty().get();
369
  }
370
371
  private StringProperty fontFamilyProperty() {
372
    return mWorkspace.stringProperty( KEY_UI_FONT_PREVIEW_NAME );
373
  }
374
375
  private double getFontSize() {
376
    return fontSizeProperty().get();
377
  }
378
379
  /**
380
   * Returns the font size in points.
381
   *
382
   * @return The user-defined font size (in pt).
383
   */
384
  private DoubleProperty fontSizeProperty() {
385
    return mWorkspace.doubleProperty( KEY_UI_FONT_PREVIEW_SIZE );
386
  }
387
388
  private String getLockText( final boolean locked ) {
389
    return Character.toString( (locked ? LOCK : UNLOCK_ALT).getChar() );
390
  }
391
392
  private URL getCustomStylesheetUrl() {
393
    try {
394
      return mWorkspace.toFile( KEY_UI_PREVIEW_STYLESHEET ).toURI().toURL();
395
    } catch( final Exception ex ) {
396
      clue( ex );
397
      return null;
398
    }
399
  }
400
401
  /**
402
   * Maps keyboard events to scrollbar commands so that users may control
403
   * the {@link HtmlPreview} panel using the keyboard.
404
   *
405
   * @param map The map to update with keyboard events.
406
   */
407
  private void addKeyboardEvents( final InputMap map ) {
408
    map.put( getKeyStroke( VK_DOWN, 0 ), "positiveUnitIncrement" );
409
    map.put( getKeyStroke( VK_UP, 0 ), "negativeUnitIncrement" );
410
    map.put( getKeyStroke( VK_PAGE_DOWN, 0 ), "positiveBlockIncrement" );
411
    map.put( getKeyStroke( VK_PAGE_UP, 0 ), "negativeBlockIncrement" );
412
    map.put( getKeyStroke( VK_HOME, 0 ), "minScroll" );
413
    map.put( getKeyStroke( VK_END, 0 ), "maxScroll" );
414
  }
415
416
  @Override
417
  public void componentResized( final ComponentEvent e ) {
418
    if( mWorkspace.toBoolean( KEY_IMAGES_RESIZE ) ) {
419
      mFactory.clearCache();
420
    }
421
422
    // Force update on the Swing EDT, otherwise the scrollbar and content
423
    // will not be updated correctly on some platforms.
424
    invokeLater( () -> getContent().repaint() );
425
  }
426
427
  @Override
428
  public void componentMoved( final ComponentEvent e ) { }
429
430
  @Override
431
  public void componentShown( final ComponentEvent e ) { }
432
433
  @Override
434
  public void componentHidden( final ComponentEvent e ) { }
435
436
  private static String toStylesheetString( final URL url ) {
437
    return url == null ? "" : format( HTML_STYLESHEET, url );
438
  }
424439
}
425440
M src/main/java/com/keenwrite/processors/XhtmlProcessor.java
66
import com.keenwrite.preferences.Workspace;
77
import com.keenwrite.ui.heuristics.WordCounter;
8
import com.whitemagicsoftware.keenquotes.Contractions;
89
import com.whitemagicsoftware.keenquotes.Converter;
910
import javafx.beans.property.StringProperty;
1011
import org.w3c.dom.Document;
1112
1213
import java.io.FileNotFoundException;
1314
import java.nio.file.Path;
15
import java.util.List;
1416
import java.util.Locale;
1517
import java.util.Map;
...
4143
    compile( "\\p{Blank}", UNICODE_CHARACTER_CLASS );
4244
43
  private final static Converter sTypographer =
44
    new Converter( lex -> clue( lex.toString() ), CHARS, PARSER_XML );
45
  private final static Converter sTypographer = new Converter(
46
    lex -> clue( lex.toString() ), contractions(), CHARS, PARSER_XML );
4547
4648
  private final ProcessorContext mContext;
...
217219
  }
218220
219
  private Locale locale() { return getWorkspace().getLocale(); }
221
  private Locale locale() {return getWorkspace().getLocale();}
220222
221223
  private String title() {
...
286288
  private StringProperty stringProperty( final Key key ) {
287289
    return getWorkspace().stringProperty( key );
290
  }
291
292
  /**
293
   * Creates contracts with a custom set of unambiguous strings.
294
   *
295
   * @return List of contractions to use for curling straight quotes.
296
   */
297
  private static Contractions contractions() {
298
    final var builder = new Contractions.Builder();
299
    return builder.withBeganUnambiguous( List.of( "bout" ) ).build();
288300
  }
289301
}
M src/main/java/com/keenwrite/processors/markdown/extensions/fences/FencedBlockExtension.java
33
44
import com.keenwrite.preferences.Workspace;
5
import com.keenwrite.preview.DiagramUrlGenerator;
56
import com.keenwrite.processors.DefinitionProcessor;
67
import com.keenwrite.processors.Processor;
...
1617
import org.jetbrains.annotations.NotNull;
1718
18
import java.io.ByteArrayOutputStream;
1919
import java.util.HashSet;
2020
import java.util.Set;
21
import java.util.zip.Deflater;
2221
23
import static com.keenwrite.events.StatusEvent.clue;
2422
import static com.keenwrite.preferences.WorkspaceKeys.KEY_IMAGES_SERVER;
2523
import static com.vladsch.flexmark.html.HtmlRenderer.Builder;
2624
import static com.vladsch.flexmark.html.renderer.LinkType.LINK;
27
import static java.lang.String.format;
28
import static java.util.Base64.getUrlEncoder;
29
import static java.util.zip.Deflater.BEST_COMPRESSION;
30
import static java.util.zip.Deflater.FULL_FLUSH;
3125
3226
/**
...
3933
4034
  private final Processor<String> mProcessor;
41
  private final ProcessorContext mContext;
35
  private final Workspace mWorkspace;
4236
4337
  public FencedBlockExtension(
44
    final Processor<String> processor, final ProcessorContext context ) {
38
    final Processor<String> processor, final Workspace workspace ) {
4539
    assert processor != null;
46
    assert context != null;
40
    assert workspace != null;
4741
    mProcessor = processor;
48
    mContext = context;
42
    mWorkspace = workspace;
4943
  }
5044
...
6963
  public static FencedBlockExtension create(
7064
    final Processor<String> processor, final ProcessorContext context ) {
71
    return new FencedBlockExtension( processor, context );
65
    assert processor != null;
66
    assert context != null;
67
    return new FencedBlockExtension( processor, context.getWorkspace() );
7268
  }
7369
...
107103
          final var content = node.getContentChars().normalizeEOL();
108104
          final var text = mProcessor.apply( content );
109
          final var encoded = encode( text );
110
          final var source = getSourceUrl( type, encoded );
105
          final var server = mWorkspace.toString( KEY_IMAGES_SERVER );
106
          final var source = DiagramUrlGenerator.toUrl( server, type, text );
111107
          final var link = context.resolveLink( LINK, source, false );
112108
...
121117
122118
      return set;
123
    }
124
125
    private byte[] compress( byte[] source ) {
126
      final var inLen = source.length;
127
      final var result = new byte[ inLen ];
128
      final var compressor = new Deflater( BEST_COMPRESSION );
129
130
      compressor.setInput( source, 0, inLen );
131
      compressor.finish();
132
      final var outLen = compressor.deflate( result, 0, inLen, FULL_FLUSH );
133
      compressor.end();
134
135
      try( final var out = new ByteArrayOutputStream() ) {
136
        out.write( result, 0, outLen );
137
        return out.toByteArray();
138
      } catch( final Exception ex ) {
139
        clue( ex );
140
        throw new RuntimeException( ex );
141
      }
142
    }
143
144
    private String encode( final String decoded ) {
145
      return getUrlEncoder().encodeToString( compress( decoded.getBytes() ) );
146
    }
147
148
    private String getSourceUrl( final String type, final String encoded ) {
149
      return
150
        format( "https://%s/%s/svg/%s", getDiagramServerName(), type, encoded );
151
    }
152
153
    private Workspace getWorkspace() {
154
      return mContext.getWorkspace();
155
    }
156
157
    private String getDiagramServerName() {
158
      return getWorkspace().toString( KEY_IMAGES_SERVER );
159119
    }
160120
  }
M src/main/java/com/keenwrite/typesetting/Typesetter.java
173173
174174
    /**
175
     * Setting {@code TEXMFCACHE} when run on a fresh system fails on first
176
     * run. If the cache directory doesn't exist, attempt to create it, then
175
     * Setting {@code TEXMFCACHE} when run on a fresh system fails on the first
176
     * try. If the cache directory doesn't exist, attempt to create it, then
177177
     * call ConTeXt to generate the PDF. This is brittle because if the
178178
     * directory is empty, or not populated with cached data, a false positive
...
199199
200200
      final var process = builder.start();
201
      final var stream = process.getInputStream();
201202
202203
      // Reading from stdout allows slurping page numbers while generating.
203
      final var listener = new PaginationListener(
204
        process.getInputStream(), stdout );
204
      final var listener = new PaginationListener( stream, stdout );
205205
      listener.start();
206206
207
      // Even though the process has completed, there may be incomplete I/O.
207208
      process.waitFor();
209
210
      // Allow time for any incomplete I/O to take place.
211
      process.waitFor( 1, SECONDS );
212
208213
      final var exit = process.exitValue();
209214
      process.destroy();
...
336341
    @Override
337342
    public void run() {
338
      try( final var reader = createReader() ) {
343
      try( final var reader = createReader( mInputStream ) ) {
339344
        int pageCount = 1;
340345
        int passCount = 1;
...
361366
            pageCount = page;
362367
363
            // Let the user know that something is happening in the background.
368
            // Inform the user of pages being typeset.
364369
            clue( "Main.status.typeset.page",
365370
                  pageCount, pageTotal < 1 ? "?" : pageTotal, passCount
366371
            );
367372
          }
368373
        }
369374
      } catch( final IOException ex ) {
375
        clue( ex );
370376
        throw new RuntimeException( ex );
371377
      }
372378
    }
373379
374
    private BufferedReader createReader() {
375
      return new BufferedReader( new InputStreamReader( mInputStream ) );
380
    private BufferedReader createReader( final InputStream inputStream ) {
381
      return new BufferedReader( new InputStreamReader( inputStream ) );
376382
    }
377383
  }
M src/main/resources/com/keenwrite/messages.properties
3838
workspace.document.date.title=Timestamp
3939
40
workspace.editor=Editor
41
workspace.editor.autosave=Autosave
42
workspace.editor.autosave.desc=Amount of time to wait between saves, in seconds (0 means disabled).
43
workspace.editor.autosave.title=Timeout
44
4045
workspace.typeset=Typesetting
4146
workspace.typeset.context=ConTeXt
...
96101
workspace.ui.skin.custom.desc=User-defined JavaFX cascading stylesheet file.
97102
workspace.ui.skin.custom.title=Path
103
104
workspace.ui.preview=Preview
105
workspace.ui.preview.stylesheet=Stylesheet
106
workspace.ui.preview.stylesheet.desc=User-defined HTML cascading stylesheet file.
107
workspace.ui.preview.stylesheet.title=Path
98108
99109
workspace.ui.font=Fonts
D src/main/resources/com/keenwrite/quotes/en/README.md
1
Listing English contractions helps converting straight apostrophes into curly apostrophes. The files include:
2
3
* began.txt -- Contractions that begin with an apostrophe.
4
* ended.txt -- Contractions that end with an apostrophe.
5
* inner.txt -- Contractions that have internal apostrophes.
6
* outer.txt -- Contractions that start and end with an apostrophe.
7
* verbs.txt -- Contractions that form suffixes for a variety of words.
8
9
The contractions for verbs must be detected dynamically, all other contractions can be hard-coded into either regular expressions or EBNF grammars.
10
111
D src/main/resources/com/keenwrite/quotes/en/began.txt
1
'aporth
2
'bout
3
'boutcha
4
'boutchu
5
'choo
6
'dillo
7
'e'll
8
'ere
9
'e
10
'e's
11
'fraid
12
'fro
13
'ho
14
'kay
15
'lo
16
'n
17
'neath
18
'nother
19
'onna
20
'pon
21
'sblood
22
'scuse
23
'sfar
24
'sfoot
25
'sup
26
't
27
'taint
28
'tain't
29
'tis
30
'tisn't
31
'tshall
32
'twas
33
'twasn't
34
'tween
35
'twere
36
'tweren't
37
'twill
38
'twixt
39
'twon't
40
'twou'd
41
'twou'dn't
42
'twould
43
'twouldn't
44
'um
45
've
46
'zat
47
481
D src/main/resources/com/keenwrite/quotes/en/ended.txt
1
ain'
2
an'
3
burlin'
4
cas'
5
didn'
6
doan'
7
doin'
8
fo'
9
gerrin'
10
gon'
11
i'
12
Ima'
13
mo'
14
namsayin'
15
o'
16
ol'
17
o'th'
18
po'
19
t'
20
th'
21
221
D src/main/resources/com/keenwrite/quotes/en/inner.txt
1
aboves'd
2
after't
3
a'ight
4
ain't
5
ain'tcha
6
all's
7
and's
8
a'n't
9
an't
10
anybody'll
11
anybody's
12
aren'chu
13
aren't
14
a'right
15
as't
16
at's
17
bain't
18
bean't
19
before't
20
ben't
21
better'n
22
bettern't
23
bisn't
24
b'long
25
bo's'n
26
br'er
27
but's
28
by'r
29
by't
30
cain't
31
call't
32
cam'st
33
cann't
34
ca'n't
35
can't
36
can'tcha
37
can't've
38
can've
39
cap'n
40
casn't
41
ch'ill
42
c'mere
43
c'min
44
c'mon
45
col's
46
couldn't
47
couldn't've
48
couldn've
49
could've
50
cudn't
51
damfidon't
52
damnfidon't
53
daredn't
54
daren't
55
dasn't
56
dassn't
57
dat's
58
dere's
59
der's
60
didn't
61
didn'tcha
62
didn'tchya
63
di'n't
64
din't
65
doesn't
66
does't
67
don't
68
don'tcha
69
do't
70
dothn't
71
dudn't
72
dun't
73
dursen't
74
dursn't
75
durstn't
76
d'ya
77
d'y'all
78
d'ye
79
d'yer
80
d'you
81
e'en
82
e'er
83
everybody's
84
everyone's
85
ev'ry
86
far's
87
fo'c's'le
88
fo'c'sle
89
fo'c'stle
90
for't
91
f'rever
92
f'rexample
93
g'bye
94
g'day
95
g'head
96
gi's
97
giv'n
98
g'night
99
g'wan
100
hadn't
101
hadn't've
102
had've
103
hain't
104
ha'n't
105
han't
106
ha'pence
107
ha'pennies
108
ha'penny
109
ha'p'orth
110
ha'porth
111
ha'p'orths
112
hasn't
113
has't
114
haven't
115
have't
116
havn't
117
heav'n
118
he'd
119
he'd've
120
he'l
121
he'll
122
he'll've
123
here'll
124
here're
125
here's
126
her's
127
he's
128
he'sn't
129
he've
130
how'd
131
how'll
132
how'm
133
how're
134
how's
135
how't
136
how've
137
I'd
138
I'd-a
139
I'da
140
idn't
141
I'dn't've
142
I'd've
143
i'faith
144
if'n
145
if't
146
I'l
147
I'll
148
I'll've
149
I'm
150
I'm'a
151
I'm-a
152
I'ma
153
i'm'a
154
i'ma
155
I'mma
156
i'n
157
in's
158
i'n't
159
in't
160
into't
161
I's
162
i's
163
I'se
164
isn't
165
is't
166
it'd
167
it'd've
168
it'll
169
it's
170
it'sn't
171
I've
172
I'ven't
173
let's
174
li'l
175
littl'un
176
ma'am
177
mayn't
178
may't
179
may've
180
m'dear
181
mightn't
182
mightn't've
183
might've
184
m'lad
185
m'ladies
186
m'lady
187
m'lord
188
m'lords
189
mng't
190
more'n
191
mus'n't
192
musn't
193
mustn't
194
mustn't've
195
must've
196
needn't
197
nee'n't
198
ne'er
199
ne'er-do-well
200
never've
201
nobody'd
202
nobody's
203
nobody've
204
nor'easter
205
not've
206
n't
207
o'clock
208
o'er
209
o'erhead
210
o'erload
211
o'erloads
212
o'erlook
213
o'erlooks
214
Oi'll
215
Oi've
216
o'lantern
217
o'lanterns
218
one's
219
on't
220
other'n
221
oughtn't
222
oughtn't've
223
p'aps
224
penn'orth
225
pen'orth
226
people'd
227
po'boy
228
pow'r
229
p'r'aps
230
p'raps
231
pray'r
232
p'rhaps
233
pudd'n'head
234
r'coon
235
run-o'-the-mill
236
same's
237
see't
238
se'nnight
239
sev'n
240
shalln't
241
shall's
242
shall've
243
sha'n't
244
shan't
245
sh'd
246
she'd
247
she'd've
248
she'l
249
she'll
250
she'll've
251
she's
252
she've
253
shouldn't
254
shouldn't've
255
should've
256
s'long
257
s'matter
258
s'more
259
s'mores
260
somebody'd
261
somebody's
262
someone's
263
something's
264
sort've
265
so's
266
th'are
267
th'art
268
that'd
269
that'd've
270
that'll
271
that'll've
272
that're
273
that's
274
that've
275
them's
276
there'd
277
there'll
278
there're
279
there's
280
there've
281
these're
282
these've
283
they'd
284
they'da
285
they'd've
286
they'l
287
they'll
288
they'll've
289
they're
290
they's
291
they've
292
th'immortall
293
this'd
294
this'll
295
this's
296
this've
297
those're
298
those've
299
tho't
300
thou'dst
301
thou'lt
302
thou'rt
303
thou'st
304
tops'l
305
to't
306
to've
307
twasn't
308
twopenn'orths
309
t'ye
310
unto't
311
upon't
312
usedn't
313
usen't
314
us's
315
view't
316
wadn't
317
wait'll
318
wa'n't
319
wan't
320
warn't
321
wasn't
322
was't
323
wazn't
324
we'd
325
we'd've
326
we'l
327
we'll
328
we'll've
329
we're
330
weren't
331
we's
332
we've
333
we'ven't
334
what'd
335
whate'er
336
whatever's
337
what'll
338
what'm
339
what're
340
what's
341
what've
342
when'd
343
whene'er
344
when'll
345
when's
346
where'd
347
where'er
348
where'm
349
where're
350
where's
351
where've
352
which'd
353
which'll
354
which're
355
which's
356
which've
357
who'd
358
who'da
359
who'd've
360
whoe'er
361
who'll
362
who'm
363
whom're
364
who're
365
who's
366
who've
367
why'd
368
why'm
369
whyn't
370
why're
371
why's
372
willn't
373
will've
374
with't
375
wolln't
376
wo'n't
377
won't
378
won't've
379
woo't
380
worn't
381
wou'd
382
wouldn't
383
wouldn'ta
384
wouldn't've
385
would've
386
wudn't
387
y'ad
388
y'ain't
389
y'all
390
ya'll
391
y'all'd
392
y'all'd've
393
y'all'll
394
y'all're
395
y'allself
396
y'allselves
397
y'all've
398
y'are
399
y'ave
400
ye'd
401
ye'll
402
y'ere
403
ye're
404
yestere'en
405
yet's
406
ye've
407
y'ever
408
y'knew
409
y'know
410
you'd
411
you'dn't've
412
you'd've
413
you'l
414
you'll
415
you'll've
416
you're
417
you'ren't
418
yours'd
419
yours'll
420
yours've
421
you's
422
you'se
423
you've
424
you'ven't
425
yo've
426
y'see
427
4281
D src/main/resources/com/keenwrite/quotes/en/outer.txt
1
'n'
2
31
D src/main/resources/com/keenwrite/quotes/en/verbs.txt
1
'd
2
'll
3
'm
4
're
5
's
6
've
7
81
M src/main/resources/lexicons/en.txt
Binary file
M src/test/java/com/keenwrite/definition/TreeViewTest.java
2323
import static com.keenwrite.util.FontLoader.initFonts;
2424
25
//@ExtendWith(ApplicationExtension.class)
2625
public class TreeViewTest extends Application {
2726
  private final SimpleObjectProperty<Node> mTextEditor =
A src/test/java/com/keenwrite/preview/DiagramUrlGeneratorTest.java
1
package com.keenwrite.preview;
2
3
import org.junit.jupiter.api.Test;
4
5
import static com.keenwrite.preview.DiagramUrlGenerator.toUrl;
6
import static org.junit.jupiter.api.Assertions.assertEquals;
7
8
/**
9
 * Responsible for testing that images sent to the diagram server will render.
10
 */
11
class DiagramUrlGeneratorTest {
12
  private final static String SERVER_NAME = "kroki.io";
13
14
  // @formatter:off
15
  private final static String[] DIAGRAMS = new String[]{
16
    "graphviz",
17
    "digraph G {Hello->World; World->Hello;}",
18
    "https://kroki.io/graphviz/svg/eJxLyUwvSizIUHBXqPZIzcnJ17ULzy_KSbFWAFO6dmBB61oAE9kNww==",
19
20
    "blockdiag",
21
    """
22
      blockdiag {
23
        Kroki -> generates -> "Block diagrams";
24
        Kroki -> is -> "very easy!";
25
26
        Kroki [color = "greenyellow"];
27
        "Block diagrams" [color = "pink"];
28
        "very easy!" [color = "orange"];
29
      }
30
      """,
31
    "https://kroki.io/blockdiag/svg/eJxdzDEKQjEQhOHeU4zpPYFoYesRxGJ9bwghMSsbUYJ4d10UCZbDfPynolOek0Q8FsDeNCestoisNLmy-Qg7R3Blcm5hPcr0ITdaB6X15fv-_YdJixo2CNHI2lmK3sPRA__RwV5SzV80ZAegJjXSyfMFptc71w=="
32
  };
33
  // @formatter:on
34
35
  /**
36
   * Test that URL encoding works with Kroki's server.
37
   */
38
  @Test
39
  public void test_Generation_TextDiagram_UrlEncoded() {
40
    // Use a map of pairs if this test needs more complexity.
41
    for( int i = 0; i < DIAGRAMS.length / 3; i += 3 ) {
42
      final var name = DIAGRAMS[ i ];
43
      final var text = DIAGRAMS[ i + 1 ];
44
      final var expected = DIAGRAMS[ i + 2 ];
45
      final var actual = toUrl( SERVER_NAME, name, text );
46
47
      assertEquals( expected, actual );
48
    }
49
  }
50
}
151