Dave Jarvis' Repositories

git clone https://repo.autonoma.ca/repo/keenwrite.git
M LICENSE.md
11
# License
22
3
Copyright 2020 White Magic Software, Ltd.
4
5
Copyright 2015 Karl Tauber
3
Copyright 2023 White Magic Software, Ltd.
64
75
All rights reserved.
M build.gradle
100100
  implementation 'org.fxmisc.flowless:flowless:0.7.2'
101101
  implementation 'org.fxmisc.wellbehaved:wellbehavedfx:0.3.3'
102
  implementation 'com.miglayout:miglayout-javafx:11.3'
103102
  implementation 'com.dlsc.preferencesfx:preferencesfx-core:11.16.0'
104103
  implementation 'com.panemu:tiwulfx-dock:0.2'
...
145144
  // Misc.
146145
  implementation 'org.ahocorasick:ahocorasick:0.6.3'
147
  implementation 'org.apache.commons:commons-lang3:3.14.0'
148146
  implementation 'com.github.albfernandez:juniversalchardet:2.4.0'
149147
  implementation 'jakarta.validation:jakarta.validation-api:3.0.2'
150148
  implementation 'org.greenrobot:eventbus-java:3.3.1'
151149
152150
  // Command-line parsing
153151
  implementation "info.picocli:picocli:${v_picocli}"
154152
  annotationProcessor "info.picocli:picocli-codegen:${v_picocli}"
155153
156
  // KeenQuotes, KeenType, KeenSpell, word split.
154
  // KeenQuotes, KeenType, KeenSpell, KeenCount.
157155
  implementation fileTree( include: ['**/*.jar'], dir: 'libs' )
158156
M container/Containerfile
6363
6464
RUN \
65
  apk add -t py3-cssselect && \
66
  apk add -t py3-lxml && \
67
  apk add -t py3-numpy && \
6568
  apk --update --no-cache \
6669
    add ca-certificates curl fontconfig inkscape rsync && \
M container/manage.sh
112112
    local -r remote_path="${repository}/${remote_file}"
113113
114
    $log "Publishing to ${remote_path}"
114
    $log "Publishing ${CONTAINER_IMAGE_FILE} to ${remote_path}"
115115
116116
    # Path to the repository.
M docs/credits.md
11
# Credits
22
3
* Karl Tauber: [Markdown Writer FX](https://github.com/JFormDesigner/markdown-writer-fx)
4
* Tomas Mikula: [RichTextFX](https://github.com/TomasMikula/RichTextFX), [ReactFX](https://github.com/TomasMikula/ReactFX), [WellBehavedFX](https://github.com/TomasMikula/WellBehavedFX), [Flowless](https://github.com/TomasMikula/Flowless), and [UndoFX](https://github.com/TomasMikula/UndoFX)
5
* Mikael Grev: [MigLayout](http://www.miglayout.com/)
6
* Tom Eugelink: [MigPane](https://github.com/mikaelgrev/miglayout/blob/master/javafx/src/main/java/org/tbee/javafx/scene/layout/fxml/MigPane.java)
3
Using libraries from:
4
5
* Tomas Mikula: [RichTextFX](https://github.com/TomasMikula/RichTextFX), [WellBehavedFX](https://github.com/TomasMikula/WellBehavedFX), [Flowless](https://github.com/TomasMikula/Flowless), and [UndoFX](https://github.com/TomasMikula/UndoFX)
76
* Jens Deters: [FontAwesomeFX](https://bitbucket.org/Jerady/fontawesomefx)
8
* Dieter Holz, [PreferencesFX](https://github.com/dlsc-software-consulting-gmbh/PreferencesFX)
9
* David Croft, [File Preferences](http://www.davidc.net/programming/java/java-preferences-using-file-backing-store)
10
* Alex Bertram, [Renjin](https://www.renjin.org/)
7
* Dieter Holz: [PreferencesFX](https://github.com/dlsc-software-consulting-gmbh/PreferencesFX)
8
* David Croft: [File Preferences](http://www.davidc.net/programming/java/java-preferences-using-file-backing-store)
9
* Alex Bertram: [Renjin](https://www.renjin.org/)
1110
* Vladimir Schneider: [flexmark](https://github.com/vsch/flexmark-java)
12
* Shy Shalom, Kohei Taketa: [juniversalchardet](https://github.com/takscape/juniversalchardet)
11
* Alberto Fernández, Shy Shalom, Kohei Taketa: [juniversalchardet](https://github.com/takscape/juniversalchardet)
12
* Morten Nobel-Jørgensen: [Java Image Scaling](https://github.com/mortennobel/java-image-scaling)
13
14
Inspired by:
1315
16
* Karl Tauber: [Markdown Writer FX](https://github.com/JFormDesigner/markdown-writer-fx)
1417
M docs/licenses/JAVA-IMAGE-SCALING.md
44
All rights reserved.
55
6
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
7
8
Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.  Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
6
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the
7
following conditions are met:
98
10
Neither the name of the <ORGANIZATION> nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
9
Redistributions of source code must retain the above copyright notice, this list of conditions and the following
10
disclaimer.
11
Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following
12
disclaimer in the documentation and/or other materials provided with the distribution.
13
Neither the name of the <ORGANIZATION> nor the names of its contributors may be used to endorse or promote products
14
derived from this software without specific prior written permission.
15
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
16
INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
17
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
18
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
19
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
20
WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
1121
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
12
1322
M docs/licenses/JUNIVERSAL-CHARDET.md
1919
2020
Contributor(s):
21
        Alberto Fernández <infjaf@gmail.com>
2122
        Shy Shalom <shooshX@gmail.com>
2223
        Kohei TAKETA <k-tak@void.in> (Java port)
...
3334
the provisions above, a recipient may use your version of this file under
3435
the terms of any one of the MPL, the GPL or the LGPL.
35
3636
D docs/licenses/JWHEATSHEAF.md
1
Copyright © 2020 Mark Raynsford <code@io7m.com> http://io7m.com
2
3
Permission to use, copy, modify, and/or distribute this software for any
4
purpose with or without fee is hereby granted, provided that the above
5
copyright notice and this permission notice appear in all copies.
6
7
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
8
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
9
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
10
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
11
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
12
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
13
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
141
D docs/licenses/MARKDOWN-WRITER-FX.md
1
Copyright (c) 2015 Karl Tauber <karl@jformdesigner.com>
2
All rights reserved.
3
4
Redistribution and use in source and binary forms, with or without
5
modification, are permitted provided that the following conditions are met:
6
7
* Redistributions of source code must retain the above copyright
8
  notice, this list of conditions and the following disclaimer.
9
10
* Redistributions in binary form must reproduce the above copyright
11
  notice, this list of conditions and the following disclaimer in the
12
  documentation and/or other materials provided with the distribution.
13
14
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
15
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
16
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
17
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
18
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
19
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
20
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
21
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
22
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
23
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
24
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
251
D docs/licenses/MIG-LAYOUT.md
1
Copyright (c) 2000 Mikael Grev
2
All rights reserved.
3
4
Redistribution and use in source and binary forms, with or without
5
modification, are permitted provided that the following conditions
6
are met:
7
1. Redistributions of source code must retain the above copyright
8
   notice, this list of conditions and the following disclaimer.
9
2. Redistributions in binary form must reproduce the above copyright
10
   notice, this list of conditions and the following disclaimer in the
11
   documentation and/or other materials provided with the distribution.
12
3. The name of the author may not be used to endorse or promote products
13
   derived from this software without specific prior written permission.
14
15
THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR
16
IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
17
OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
18
IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,
19
INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
20
NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
21
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
22
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
23
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
24
THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
25
261
D docs/licenses/REACT-FX.md
1
Copyright (c) 2013-2014, Tomas Mikula
2
All rights reserved.
3
4
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
5
6
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
7
8
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
9
10
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
111
M src/main/java/com/keenwrite/Launcher.java
173173
    out( "%n%s version %s", APP_TITLE, APP_VERSION );
174174
    out( "Copyright 2016-%s White Magic Software, Ltd.", APP_YEAR );
175
    out( "Portions copyright 2015-2020 Karl Tauber.%n" );
176175
  }
177176
M src/main/java/com/keenwrite/MainPane.java
201201
    mStatistics = new DocumentStatistics( workspace );
202202
203
    mTextEditor.addListener( ( c, o, n ) -> {
204
      if( o != null ) {
205
        removeProcessor( o );
206
      }
207
208
      if( n != null ) {
209
        mPreview.setBaseUri( n.getPath() );
210
        updateProcessors( n );
211
        process( n );
212
      }
213
    } );
214
215
    mTextEditor.set( createMarkdownEditor( DOCUMENT_DEFAULT ) );
216
    mDefinitionEditor.set( createDefinitionEditor( workspace ) );
217
    mVariableNameInjector = new VariableNameInjector( workspace );
218
    mRBootstrapController = new RBootstrapController(
219
      workspace, mDefinitionEditor.get()::getDefinitions
220
    );
221
222
    // If the user modifies the definitions, re-process the variables.
223
    mDefinitionEditor.addListener( ( c, o, n ) -> {
224
      final var textEditor = getTextEditor();
225
226
      if( textEditor.isMediaType( TEXT_R_MARKDOWN ) ) {
227
        mRBootstrapController.update();
228
      }
229
230
      process( textEditor );
231
    } );
232
233
    open( collect( getRecentFiles() ) );
234
    viewPreview();
235
    setDividerPositions( calculateDividerPositions() );
236
237
    // Once the main scene's window regains focus, update the active definition
238
    // editor to the currently selected tab.
239
    runLater( () -> getWindow().setOnCloseRequest( event -> {
240
      // Order matters: Open file names must be persisted before closing all.
241
      mWorkspace.save();
242
243
      if( closeAll() ) {
244
        exit();
245
        terminate( 0 );
246
      }
247
248
      event.consume();
249
    } ) );
250
251
    register( this );
252
    initAutosave( workspace );
253
254
    restoreSession();
255
    runLater( this::restoreFocus );
256
257
    mInstallWizard = new TypesetterInstaller( workspace );
258
  }
259
260
  /**
261
   * Called when spellchecking can be run. This will reload the dictionary
262
   * into memory once, and then re-use it for all the existing text editors.
263
   *
264
   * @param event The event to process, having a populated word-frequency map.
265
   */
266
  @Subscribe
267
  public void handle( final LexiconLoadedEvent event ) {
268
    final var lexicon = event.getLexicon();
269
270
    try {
271
      final var checker = SymSpellSpeller.forLexicon( lexicon );
272
      mSpellChecker.set( checker );
273
    } catch( final Exception ex ) {
274
      clue( ex );
275
    }
276
  }
277
278
  @Subscribe
279
  public void handle( final TextEditorFocusEvent event ) {
280
    mTextEditor.set( event.get() );
281
  }
282
283
  @Subscribe
284
  public void handle( final TextDefinitionFocusEvent event ) {
285
    mDefinitionEditor.set( event.get() );
286
  }
287
288
  /**
289
   * Typically called when a file name is clicked in the preview panel.
290
   *
291
   * @param event The event to process, must contain a valid file reference.
292
   */
293
  @Subscribe
294
  public void handle( final FileOpenEvent event ) {
295
    final File eventFile;
296
    final var eventUri = event.getUri();
297
298
    if( eventUri.isAbsolute() ) {
299
      eventFile = new File( eventUri.getPath() );
300
    }
301
    else {
302
      final var activeFile = getTextEditor().getFile();
303
      final var parent = activeFile.getParentFile();
304
305
      if( parent == null ) {
306
        clue( new FileNotFoundException( eventUri.getPath() ) );
307
        return;
308
      }
309
      else {
310
        final var parentPath = parent.getAbsolutePath();
311
        eventFile = toFile( Path.of( parentPath, eventUri.getPath() ) );
312
      }
313
    }
314
315
    final var mediaType = MediaTypeExtension.fromFile( eventFile );
316
317
    runLater( () -> {
318
      // Open text files locally.
319
      if( mediaType.isType( TEXT ) ) {
320
        open( eventFile );
321
      }
322
      else {
323
        try {
324
          // Delegate opening all other file types to the operating system.
325
          getDesktop().open( eventFile );
326
        } catch( final Exception ex ) {
327
          clue( ex );
328
        }
329
      }
330
    } );
331
  }
332
333
  @Subscribe
334
  public void handle( final CaretNavigationEvent event ) {
335
    runLater( () -> {
336
      final var textArea = getTextEditor();
337
      textArea.moveTo( event.getOffset() );
338
      textArea.requestFocus();
339
    } );
340
  }
341
342
  @Subscribe
343
  public void handle( final InsertDefinitionEvent<String> event ) {
344
    final var leaf = event.getLeaf();
345
    final var editor = mTextEditor.get();
346
347
    mVariableNameInjector.insert( editor, leaf );
348
  }
349
350
  private void initAutosave( final Workspace workspace ) {
351
    final var rate = workspace.integerProperty( KEY_EDITOR_AUTOSAVE );
352
353
    rate.addListener(
354
      ( c, o, n ) -> {
355
        final var taskRef = mSaveTask.get();
356
357
        // Prevent multiple auto-saves from running.
358
        if( taskRef != null ) {
359
          taskRef.cancel( false );
360
        }
361
362
        initAutosave( rate );
363
      }
364
    );
365
366
    // Start the save listener (avoids duplicating some code).
367
    initAutosave( rate );
368
  }
369
370
  private void initAutosave( final IntegerProperty rate ) {
371
    mSaveTask.set(
372
      mSaver.scheduleAtFixedRate(
373
        () -> {
374
          if( getTextEditor().isModified() ) {
375
            // Ensure the modified indicator is cleared by running on EDT.
376
            runLater( this::save );
377
          }
378
        }, 0, rate.intValue(), SECONDS
379
      )
380
    );
381
  }
382
383
  /**
384
   * TODO: Load divider positions from exported settings, see
385
   *   {@link #collect(SetProperty)} comment.
386
   */
387
  private double[] calculateDividerPositions() {
388
    final var ratio = 100f / getItems().size() / 100;
389
    final var positions = getDividerPositions();
390
391
    for( int i = 0; i < positions.length; i++ ) {
392
      positions[ i ] = ratio * i;
393
    }
394
395
    return positions;
396
  }
397
398
  /**
399
   * Opens all the files into the application, provided the paths are unique.
400
   * This may only be called for any type of files that a user can edit
401
   * (i.e., update and persist), such as definitions and text files.
402
   *
403
   * @param files The list of files to open.
404
   */
405
  public void open( final List<File> files ) {
406
    files.forEach( this::open );
407
  }
408
409
  /**
410
   * This opens the given file. Since the preview pane is not a file that
411
   * can be opened, it is safe to add a listener to the detachable pane.
412
   * This will exit early if the given file is not a regular file (i.e., a
413
   * directory).
414
   *
415
   * @param inputFile The file to open.
416
   */
417
  private void open( final File inputFile ) {
418
    // Prevent opening directories (a non-existent "untitled.md" is fine).
419
    if( !inputFile.isFile() && inputFile.exists() ) {
420
      return;
421
    }
422
423
    final var mediaType = fromFilename( inputFile );
424
425
    // Only allow opening text files.
426
    if( !mediaType.isType( TEXT ) ) {
427
      return;
428
    }
429
430
    final var tab = createTab( inputFile );
431
    final var node = tab.getContent();
432
    final var tabPane = obtainTabPane( mediaType );
433
434
    tab.setTooltip( createTooltip( inputFile ) );
435
    tabPane.setFocusTraversable( false );
436
    tabPane.setTabClosingPolicy( ALL_TABS );
437
    tabPane.getTabs().add( tab );
438
439
    // Attach the tab scene factory for new tab panes.
440
    if( !getItems().contains( tabPane ) ) {
441
      addTabPane(
442
        node instanceof TextDefinition ? 0 : getItems().size(), tabPane
443
      );
444
    }
445
446
    if( inputFile.isFile() ) {
447
      getRecentFiles().add( inputFile.getAbsolutePath() );
448
    }
449
  }
450
451
  /**
452
   * Gives focus to the most recently edited document and attempts to move
453
   * the caret to the most recently known offset into said document.
454
   */
455
  private void restoreSession() {
456
    final var workspace = getWorkspace();
457
    final var file = workspace.fileProperty( KEY_UI_RECENT_DOCUMENT );
458
    final var offset = workspace.integerProperty( KEY_UI_RECENT_OFFSET );
459
460
    for( final var pane : mTabPanes ) {
461
      for( final var tab : pane.getTabs() ) {
462
        final var tooltip = tab.getTooltip();
463
464
        if( tooltip != null ) {
465
          final var tabName = tooltip.getText();
466
          final var fileName = file.get().toString();
467
468
          if( tabName.equalsIgnoreCase( fileName ) ) {
469
            final var node = tab.getContent();
470
471
            pane.getSelectionModel().select( tab );
472
            node.requestFocus();
473
474
            if( node instanceof TextEditor editor ) {
475
              runLater( () -> editor.moveTo( offset.getValue() ) );
476
            }
477
478
            break;
479
          }
480
        }
481
      }
482
    }
483
  }
484
485
  /**
486
   * Sets the focus to the middle pane, which contains the text editor tabs.
487
   */
488
  private void restoreFocus() {
489
    // Work around a bug where focusing directly on the middle pane results
490
    // in the R engine not loading variables properly.
491
    mTabPanes.get( 0 ).requestFocus();
492
493
    // This is the only line that should be required.
494
    mTabPanes.get( 1 ).requestFocus();
495
  }
496
497
  /**
498
   * Opens a new text editor document using a document file name that doesn't
499
   * clash with an existing document.
500
   */
501
  public void newTextEditor() {
502
    final String key = "file.default.document.";
503
    final String prefix = Constants.get( STR."\{key}prefix" );
504
    final String suffix = Constants.get( STR."\{key}suffix" );
505
506
    File file = new File( STR."\{prefix}.\{suffix}" );
507
    int i = 0;
508
509
    while( file.exists() && i++ < 100 ) {
510
      file = new File( STR."\{prefix}-\{i}.\{suffix}" );
511
    }
512
513
    open( file );
514
  }
515
516
  /**
517
   * Opens a new definition editor document using the default definition
518
   * file name.
519
   */
520
  @SuppressWarnings( "unused" )
521
  public void newDefinitionEditor() {
522
    open( DEFINITION_DEFAULT );
523
  }
524
525
  /**
526
   * Iterates over all tab panes to find all {@link TextEditor}s and request
527
   * that they save themselves.
528
   */
529
  public void saveAll() {
530
    iterateEditors( this::save );
531
  }
532
533
  /**
534
   * Requests that the active {@link TextEditor} saves itself. Don't bother
535
   * checking if modified first because if the user swaps external media from
536
   * an external source (e.g., USB thumb drive), save should not second-guess
537
   * the user: save always re-saves. Also, it's less code.
538
   */
539
  public void save() {
540
    save( getTextEditor() );
541
  }
542
543
  /**
544
   * Saves the active {@link TextEditor} under a new name.
545
   *
546
   * @param files The new active editor {@link File} reference, must contain
547
   *              at least one element.
548
   */
549
  public void saveAs( final List<File> files ) {
550
    assert files != null;
551
    assert !files.isEmpty();
552
    final var editor = getTextEditor();
553
    final var tab = getTab( editor );
554
    final var file = files.get( 0 );
555
556
    // If the file type has changed, refresh the processors.
557
    final var mediaType = fromFilename( file );
558
    final var typeChanged = !editor.isMediaType( mediaType );
559
560
    if( typeChanged ) {
561
      removeProcessor( editor );
562
    }
563
564
    editor.rename( file );
565
    tab.ifPresent( t -> {
566
      t.setText( editor.getFilename() );
567
      t.setTooltip( createTooltip( file ) );
568
    } );
569
570
    if( typeChanged ) {
571
      updateProcessors( editor );
572
      process( editor );
573
    }
574
575
    save();
576
  }
577
578
  /**
579
   * Saves the given {@link TextResource} to a file. This is typically used
580
   * to save either an instance of {@link TextEditor} or {@link TextDefinition}.
581
   *
582
   * @param resource The resource to export.
583
   */
584
  private void save( final TextResource resource ) {
585
    try {
586
      resource.save();
587
    } catch( final Exception ex ) {
588
      clue( ex );
589
      sNotifier.alert(
590
        getWindow(), resource.getPath(), "TextResource.saveFailed", ex
591
      );
592
    }
593
  }
594
595
  /**
596
   * Closes all open {@link TextEditor}s; all {@link TextDefinition}s stay open.
597
   *
598
   * @return {@code true} when all editors, modified or otherwise, were
599
   * permitted to close; {@code false} when one or more editors were modified
600
   * and the user requested no closing.
601
   */
602
  public boolean closeAll() {
603
    var closable = true;
604
605
    for( final var tabPane : mTabPanes ) {
606
      final var tabIterator = tabPane.getTabs().iterator();
607
608
      while( tabIterator.hasNext() ) {
609
        final var tab = tabIterator.next();
610
        final var resource = tab.getContent();
611
612
        // The definition panes auto-save, so being specific here prevents
613
        // closing the definitions in the situation where the user wants to
614
        // continue editing (i.e., possibly save unsaved work).
615
        if( !(resource instanceof TextEditor) ) {
616
          continue;
617
        }
618
619
        if( canClose( (TextEditor) resource ) ) {
620
          tabIterator.remove();
621
          close( tab );
622
        }
623
        else {
624
          closable = false;
625
        }
626
      }
627
    }
628
629
    return closable;
630
  }
631
632
  /**
633
   * Calls the tab's {@link Tab#getOnClosed()} handler to carry out a close
634
   * event.
635
   *
636
   * @param tab The {@link Tab} that was closed.
637
   */
638
  private void close( final Tab tab ) {
639
    assert tab != null;
640
641
    final var handler = tab.getOnClosed();
642
643
    if( handler != null ) {
644
      handler.handle( new ActionEvent() );
645
    }
646
  }
647
648
  /**
649
   * Closes the active tab; delegates to {@link #canClose(TextResource)}.
650
   */
651
  public void close() {
652
    final var editor = getTextEditor();
653
654
    if( canClose( editor ) ) {
655
      close( editor );
656
      removeProcessor( editor );
657
    }
658
  }
659
660
  /**
661
   * Closes the given {@link TextResource}. This must not be called from within
662
   * a loop that iterates over the tab panes using {@code forEach}, lest a
663
   * concurrent modification exception be thrown.
664
   *
665
   * @param resource The {@link TextResource} to close, without confirming with
666
   *                 the user.
667
   */
668
  private void close( final TextResource resource ) {
669
    getTab( resource ).ifPresent(
670
      tab -> {
671
        close( tab );
672
        tab.getTabPane().getTabs().remove( tab );
673
      }
674
    );
675
  }
676
677
  /**
678
   * Answers whether the given {@link TextResource} may be closed.
679
   *
680
   * @param editor The {@link TextResource} to try closing.
681
   * @return {@code true} when the editor may be closed; {@code false} when
682
   * the user has requested to keep the editor open.
683
   */
684
  private boolean canClose( final TextResource editor ) {
685
    final var editorTab = getTab( editor );
686
    final var canClose = new AtomicBoolean( true );
687
688
    if( editor.isModified() ) {
689
      final var filename = new StringBuilder();
690
      editorTab.ifPresent( tab -> filename.append( tab.getText() ) );
691
692
      final var message = sNotifier.createNotification(
693
        Messages.get( "Alert.file.close.title" ),
694
        Messages.get( "Alert.file.close.text" ),
695
        filename.toString()
696
      );
697
698
      final var dialog = sNotifier.createConfirmation( getWindow(), message );
699
700
      dialog.showAndWait().ifPresent(
701
        save -> canClose.set( save == YES ? editor.save() : save == NO )
702
      );
703
    }
704
705
    return canClose.get();
706
  }
707
708
  private void iterateEditors( final Consumer<TextEditor> consumer ) {
709
    mTabPanes.forEach(
710
      tp -> tp.getTabs().forEach( tab -> {
711
        final var node = tab.getContent();
712
713
        if( node instanceof final TextEditor editor ) {
714
          consumer.accept( editor );
715
        }
716
      } )
717
    );
718
  }
719
720
  /**
721
   * Adds the HTML preview tab to its own, singular tab pane.
722
   */
723
  public void viewPreview() {
724
    addTab( mPreview, TEXT_HTML, "Pane.preview.title" );
725
  }
726
727
  /**
728
   * Adds the document outline tab to its own, singular tab pane.
729
   */
730
  public void viewOutline() {
731
    addTab( mOutline, APP_DOCUMENT_OUTLINE, "Pane.outline.title" );
732
  }
733
734
  public void viewStatistics() {
735
    addTab( mStatistics, APP_DOCUMENT_STATISTICS, "Pane.statistics.title" );
736
  }
737
738
  public void viewFiles() {
739
    try {
740
      final var factory = new FilePickerFactory( getWorkspace() );
741
      final var fileManager = factory.createModeless();
742
      addTab( fileManager, APP_FILE_MANAGER, "Pane.files.title" );
743
    } catch( final Exception ex ) {
744
      clue( ex );
745
    }
746
  }
747
748
  public void viewRefresh() {
749
    mPreview.refresh();
750
    Engine.clear();
751
    mRBootstrapController.update();
752
  }
753
754
  private void addTab(
755
    final Node node, final MediaType mediaType, final String key ) {
756
    final var tabPane = obtainTabPane( mediaType );
757
758
    for( final var tab : tabPane.getTabs() ) {
759
      if( tab.getContent() == node ) {
760
        return;
761
      }
762
    }
763
764
    tabPane.getTabs().add( createTab( get( key ), node ) );
765
    addTabPane( tabPane );
766
  }
767
768
  /**
769
   * Returns the tab that contains the given {@link TextEditor}.
770
   *
771
   * @param editor The {@link TextEditor} instance to find amongst the tabs.
772
   * @return The first tab having content that matches the given tab.
773
   */
774
  private Optional<Tab> getTab( final TextResource editor ) {
775
    return mTabPanes.stream()
776
                    .flatMap( pane -> pane.getTabs().stream() )
777
                    .filter( tab -> editor.equals( tab.getContent() ) )
778
                    .findFirst();
779
  }
780
781
  private TextDefinition createDefinitionEditor( final File file ) {
782
    final var editor = new DefinitionEditor( file, createTreeTransformer() );
783
784
    editor.addTreeChangeHandler( mTreeHandler );
785
786
    return editor;
787
  }
788
789
  /**
790
   * Creates a new {@link DefinitionEditor} wrapped in a listener that
791
   * is used to detect when the active {@link DefinitionEditor} has changed.
792
   * Upon changing, the variables are interpolated and the active text editor
793
   * is refreshed.
794
   *
795
   * @param workspace Has the most recently edited definitions file name.
796
   * @return A newly configured property that represents the active
797
   * {@link DefinitionEditor}, never {@code null}.
798
   */
799
  private TextDefinition createDefinitionEditor(
800
    final Workspace workspace ) {
801
    final var fileProperty = workspace.fileProperty( KEY_UI_RECENT_DEFINITION );
802
    final var filename = fileProperty.get();
803
    final SetProperty<String> recent = workspace.setsProperty(
804
      KEY_UI_RECENT_OPEN_PATH
805
    );
806
807
    // Open the most recently used YAML definition file.
808
    for( final var recentFile : recent.get() ) {
809
      if( recentFile.endsWith( filename.toString() ) ) {
810
        return createDefinitionEditor( new File( recentFile ) );
811
      }
812
    }
813
814
    return createDefaultDefinitionEditor();
815
  }
816
817
  private TextDefinition createDefaultDefinitionEditor() {
818
    final var transformer = createTreeTransformer();
819
    return new DefinitionEditor( transformer );
820
  }
821
822
  private TreeTransformer createTreeTransformer() {
823
    return new YamlTreeTransformer();
824
  }
825
826
  private Tab createTab( final String filename, final Node node ) {
827
    return new DetachableTab( filename, node );
828
  }
829
830
  private Tab createTab( final File file ) {
831
    final var r = createTextResource( file );
832
    final var filename = r.getFilename();
833
    final var tab = createTab( filename, r.getNode() );
834
835
    r.modifiedProperty().addListener(
836
      ( c, o, n ) -> tab.setText( filename + (n ? "*" : "") )
837
    );
838
839
    // This is called when either the tab is closed by the user clicking on
840
    // the tab's close icon or when closing (all) from the file menu.
841
    tab.setOnClosed(
842
      __ -> getRecentFiles().remove( file.getAbsolutePath() )
843
    );
844
845
    // When closing a tab, give focus to the newly revealed tab.
846
    tab.selectedProperty().addListener( ( c, o, n ) -> {
847
      if( n != null && n ) {
848
        final var pane = tab.getTabPane();
849
850
        if( pane != null ) {
851
          pane.requestFocus();
852
        }
853
      }
854
    } );
855
856
    tab.tabPaneProperty().addListener( ( cPane, oPane, nPane ) -> {
857
      if( nPane != null ) {
858
        nPane.focusedProperty().addListener( ( c, o, n ) -> {
859
          if( n != null && n ) {
860
            final var selected = nPane.getSelectionModel().getSelectedItem();
861
            final var node = selected.getContent();
862
            node.requestFocus();
863
          }
864
        } );
865
      }
866
    } );
867
868
    return tab;
869
  }
870
871
  /**
872
   * Creates bins for the different {@link MediaType}s, which eventually are
873
   * added to the UI as separate tab panes. If ever a general-purpose scene
874
   * exporter is developed to serialize a scene to an FXML file, this could
875
   * be replaced by such a class.
876
   * <p>
877
   * When binning the files, this makes sure that at least one file exists
878
   * for every type. If the user has opted to close a particular type (such
879
   * as the definition pane), the view will suppressed elsewhere.
880
   * </p>
881
   * <p>
882
   * The order that the binned files are returned will be reflected in the
883
   * order that the corresponding panes are rendered in the UI.
884
   * </p>
885
   *
886
   * @param paths The file paths to bin according to their type.
887
   * @return An in-order list of files, first by structured definition files,
888
   * then by plain text documents.
889
   */
890
  private List<File> collect( final SetProperty<String> paths ) {
891
    // Treat all files destined for the text editor as plain text documents
892
    // so that they are added to the same pane. Grouping by TEXT_PLAIN is a
893
    // bit arbitrary, but means explicitly capturing TEXT_PLAIN isn't needed.
894
    final Function<MediaType, MediaType> bin =
895
      m -> PLAIN_TEXT_FORMAT.contains( m ) ? TEXT_PLAIN : m;
896
897
    // Create two groups: YAML files and plain text files. The order that
898
    // the elements are listed in the enumeration for media types determines
899
    // what files are loaded first. Variable definitions come before all other
900
    // plain text documents.
901
    final var bins = paths
902
      .stream()
903
      .collect(
904
        groupingBy(
905
          path -> bin.apply( fromFilename( path ) ),
906
          () -> new TreeMap<>( Enum::compareTo ),
907
          Collectors.toList()
908
        )
909
      );
910
911
    bins.putIfAbsent( TEXT_YAML, List.of( DEFINITION_DEFAULT.toString() ) );
912
    bins.putIfAbsent( TEXT_PLAIN, List.of( DOCUMENT_DEFAULT.toString() ) );
913
914
    final var result = new LinkedList<File>();
915
916
    // Ensure that the same types are listed together (keep insertion order).
917
    bins.forEach( ( mediaType, files ) -> result.addAll(
918
      files.stream().map( File::new ).toList() )
919
    );
920
921
    return result;
922
  }
923
924
  /**
925
   * Force the active editor to update, which will cause the processor
926
   * to re-evaluate the interpolated definition map thereby updating the
927
   * preview pane.
928
   *
929
   * @param editor Contains the source document to update in the preview pane.
930
   */
931
  private void process( final TextEditor editor ) {
932
    // Ensure processing does not run on the JavaFX thread, which frees the
933
    // text editor immediately for caret movement. The preview will have a
934
    // slight delay when catching up to the caret position.
935
    final var task = new Task<Void>() {
936
      @Override
937
      public Void call() {
938
        try {
939
          final var p = mProcessors.getOrDefault( editor, IDENTITY );
940
          p.apply( editor == null ? "" : editor.getText() );
941
        } catch( final Exception ex ) {
942
          clue( ex );
943
        }
944
945
        return null;
946
      }
947
    };
948
949
    // TODO: Each time the editor successfully runs the processor, the task is
950
    //   considered successful. Due to the rapid-fire nature of processing
951
    //   (e.g., keyboard navigation, fast typing), it isn't necessary to
952
    //   scroll each time.
953
    //   The algorithm:
954
    //   1. Peek at the oldest time.
955
    //   2. If the difference between the oldest time and current time exceeds
956
    //      250 milliseconds, then invoke the scrolling.
957
    //   3. Insert the current time into the circular queue.
958
    task.setOnSucceeded(
959
      e -> invokeLater( () -> mPreview.scrollTo( CARET_ID ) )
960
    );
961
962
    // Prevents multiple process requests from executing simultaneously (due
963
    // to having a restricted queue size).
964
    sExecutor.execute( task );
965
  }
966
967
  /**
968
   * Lazily creates a {@link TabPane} configured to listen for tab select
969
   * events. The tab pane is associated with a given media type so that
970
   * similar files can be grouped together.
971
   *
972
   * @param mediaType The media type to associate with the tab pane.
973
   * @return An instance of {@link TabPane} that will handle tab docking.
974
   */
975
  private TabPane obtainTabPane( final MediaType mediaType ) {
976
    for( final var pane : mTabPanes ) {
977
      for( final var tab : pane.getTabs() ) {
978
        final var node = tab.getContent();
979
980
        if( node instanceof TextResource r && r.supports( mediaType ) ) {
981
          return pane;
982
        }
983
      }
984
    }
985
986
    final var pane = createTabPane();
987
    mTabPanes.add( pane );
988
    return pane;
989
  }
990
991
  /**
992
   * Creates an initialized {@link TabPane} instance.
993
   *
994
   * @return A new {@link TabPane} with all listeners configured.
995
   */
996
  private TabPane createTabPane() {
997
    final var tabPane = new DetachableTabPane();
998
999
    initStageOwnerFactory( tabPane );
1000
    initTabListener( tabPane );
1001
1002
    return tabPane;
1003
  }
1004
1005
  /**
1006
   * When any {@link DetachableTabPane} is detached from the main window,
1007
   * the stage owner factory must be given its parent window, which will
1008
   * own the child window. The parent window is the {@link MainPane}'s
1009
   * {@link Scene}'s {@link Window} instance.
1010
   *
1011
   * <p>
1012
   * This will derives the new title from the main window title, incrementing
1013
   * the window count to help uniquely identify the child windows.
1014
   * </p>
1015
   *
1016
   * @param tabPane A new {@link DetachableTabPane} to configure.
1017
   */
1018
  private void initStageOwnerFactory( final DetachableTabPane tabPane ) {
1019
    tabPane.setStageOwnerFactory( stage -> {
1020
      final var title = get(
1021
        "Detach.tab.title",
1022
        ((Stage) getWindow()).getTitle(), ++mWindowCount
1023
      );
1024
      stage.setTitle( title );
1025
1026
      return getScene().getWindow();
1027
    } );
1028
  }
1029
1030
  /**
1031
   * Responsible for configuring the content of each {@link DetachableTab} when
1032
   * it is added to the given {@link DetachableTabPane} instance.
1033
   * <p>
1034
   * For {@link TextEditor} contents, an instance of {@link ScrollEventHandler}
1035
   * is initialized to perform synchronized scrolling between the editor and
1036
   * its preview window. Additionally, the last tab in the tab pane's list of
1037
   * tabs is given focus.
1038
   * </p>
1039
   * <p>
1040
   * Note that multiple tabs can be added simultaneously.
1041
   * </p>
1042
   *
1043
   * @param tabPane A new {@link TabPane} to configure.
1044
   */
1045
  private void initTabListener( final TabPane tabPane ) {
1046
    tabPane.getTabs().addListener(
1047
      ( final ListChangeListener.Change<? extends Tab> listener ) -> {
1048
        while( listener.next() ) {
1049
          if( listener.wasAdded() ) {
1050
            final var tabs = listener.getAddedSubList();
1051
1052
            tabs.forEach( tab -> {
1053
              final var node = tab.getContent();
1054
1055
              if( node instanceof TextEditor ) {
1056
                initScrollEventListener( tab );
1057
              }
1058
            } );
1059
1060
            // Select and give focus to the last tab opened.
1061
            final var index = tabs.size() - 1;
1062
            if( index >= 0 ) {
1063
              final var tab = tabs.get( index );
1064
              tabPane.getSelectionModel().select( tab );
1065
              tab.getContent().requestFocus();
1066
            }
1067
          }
1068
        }
1069
      }
1070
    );
1071
  }
1072
1073
  /**
1074
   * Synchronizes scrollbar positions between the given {@link Tab} that
1075
   * contains an instance of {@link TextEditor} and {@link HtmlPreview} pane.
1076
   *
1077
   * @param tab The container for an instance of {@link TextEditor}.
1078
   */
1079
  private void initScrollEventListener( final Tab tab ) {
1080
    final var editor = (TextEditor) tab.getContent();
1081
    final var scrollPane = editor.getScrollPane();
1082
    final var scrollBar = mPreview.getVerticalScrollBar();
1083
    final var handler = new ScrollEventHandler( scrollPane, scrollBar );
1084
1085
    handler.enabledProperty().bind( tab.selectedProperty() );
1086
  }
1087
1088
  private void addTabPane( final int index, final TabPane tabPane ) {
1089
    final var items = getItems();
1090
1091
    if( !items.contains( tabPane ) ) {
1092
      items.add( index, tabPane );
1093
    }
1094
  }
1095
1096
  private void addTabPane( final TabPane tabPane ) {
1097
    addTabPane( getItems().size(), tabPane );
1098
  }
1099
1100
  private GenericBuilder<Mutator, ProcessorContext> processorContextBuilder() {
1101
    final var w = getWorkspace();
1102
1103
    return builder()
1104
      .with( Mutator::setDefinitions, this::getDefinitions )
1105
      .with( Mutator::setLocale, w::getLocale )
1106
      .with( Mutator::setMetadata, w::getMetadata )
1107
      .with( Mutator::setThemeDir, w::getThemesPath )
1108
      .with( Mutator::setCacheDir,
1109
             () -> w.getFile( KEY_CACHE_DIR ) )
1110
      .with( Mutator::setImageDir,
1111
             () -> w.getFile( KEY_IMAGE_DIR ) )
1112
      .with( Mutator::setImageOrder,
1113
             () -> w.getString( KEY_IMAGE_ORDER ) )
1114
      .with( Mutator::setImageServer,
1115
             () -> w.getString( KEY_IMAGE_SERVER ) )
1116
      .with( Mutator::setFontDir,
1117
             () -> w.getFile( KEY_TYPESET_CONTEXT_FONTS_DIR ) )
1118
      .with( Mutator::setCaret,
1119
             () -> getTextEditor().getCaret() )
1120
      .with( Mutator::setSigilBegan,
1121
             () -> w.getString( KEY_DEF_DELIM_BEGAN ) )
1122
      .with( Mutator::setSigilEnded,
1123
             () -> w.getString( KEY_DEF_DELIM_ENDED ) )
1124
      .with( Mutator::setRScript,
1125
             () -> w.getString( KEY_R_SCRIPT ) )
1126
      .with( Mutator::setRWorkingDir,
1127
             () -> w.getFile( KEY_R_DIR ).toPath() )
1128
      .with( Mutator::setCurlQuotes,
1129
             () -> w.getBoolean( KEY_TYPESET_TYPOGRAPHY_QUOTES ) )
1130
      .with( Mutator::setAutoRemove,
1131
             () -> w.getBoolean( KEY_TYPESET_CONTEXT_CLEAN ) );
1132
  }
1133
1134
  public ProcessorContext createProcessorContext() {
1135
    return createProcessorContextBuilder( NONE ).build();
1136
  }
1137
1138
  private GenericBuilder<Mutator, ProcessorContext> createProcessorContextBuilder(
1139
    final ExportFormat format ) {
1140
    final var textEditor = getTextEditor();
1141
    final var sourcePath = textEditor.getPath();
1142
1143
    return processorContextBuilder()
1144
      .with( Mutator::setSourcePath, sourcePath )
1145
      .with( Mutator::setExportFormat, format );
1146
  }
1147
1148
  /**
1149
   * @param targetPath Used when exporting to a PDF file (binary).
1150
   * @param format     Used when processors export to a new text format.
1151
   * @return A new {@link ProcessorContext} to use when creating an instance of
1152
   * {@link Processor}.
1153
   */
1154
  public ProcessorContext createProcessorContext(
1155
    final Path targetPath, final ExportFormat format ) {
1156
    assert targetPath != null;
1157
    assert format != null;
1158
1159
    return createProcessorContextBuilder( format )
1160
      .with( Mutator::setTargetPath, targetPath )
1161
      .build();
1162
  }
1163
1164
  /**
1165
   * @param sourcePath Used by {@link ProcessorFactory} to determine
1166
   *                   {@link Processor} type to create based on file type.
1167
   * @return A new {@link ProcessorContext} to use when creating an instance of
1168
   * {@link Processor}.
1169
   */
1170
  private ProcessorContext createProcessorContext( final Path sourcePath ) {
1171
    return processorContextBuilder()
1172
      .with( Mutator::setSourcePath, sourcePath )
1173
      .with( Mutator::setExportFormat, NONE )
1174
      .build();
1175
  }
1176
1177
  private TextResource createTextResource( final File file ) {
1178
    if( fromFilename( file ) == TEXT_YAML ) {
1179
      final var editor = createDefinitionEditor( file );
1180
      mDefinitionEditor.set( editor );
1181
      return editor;
1182
    }
1183
    else {
1184
      final var editor = createMarkdownEditor( file );
1185
      mTextEditor.set( editor );
1186
      return editor;
1187
    }
1188
  }
1189
1190
  /**
1191
   * Creates an instance of {@link MarkdownEditor} that listens for both
1192
   * caret change events and text change events. Text change events must
1193
   * take priority over caret change events because it's possible to change
1194
   * the text without moving the caret (e.g., delete selected text).
1195
   *
1196
   * @param inputFile The file containing contents for the text editor.
1197
   * @return A non-null text editor.
1198
   */
1199
  private MarkdownEditor createMarkdownEditor( final File inputFile ) {
1200
    final var editor = new MarkdownEditor( inputFile, getWorkspace() );
1201
1202
    // Listener for editor modifications or caret position changes.
1203
    editor.addDirtyListener( ( c, o, n ) -> {
1204
      if( n ) {
1205
        // Reset the status bar after changing the text.
1206
        clue();
1207
1208
        // Processing the text may update the status bar.
1209
        process( editor );
1210
1211
        // Update the caret position in the status bar.
1212
        CaretMovedEvent.fire( editor.getCaret() );
1213
      }
1214
    } );
1215
1216
    editor.addEventListener(
1217
      keyPressed( SPACE, CONTROL_DOWN ), this::autoinsert
1218
    );
1219
1220
    editor.addEventListener(
1221
      keyPressed( ENTER, ALT_DOWN ), event -> mEditorSpeller.autofix( editor )
1222
    );
1223
1224
    final var textArea = editor.getTextArea();
1225
1226
    // Spell check when the paragraph changes.
1227
    textArea
1228
      .plainTextChanges()
1229
      .filter( p -> !p.isIdentity() )
1230
      .subscribe( change -> mEditorSpeller.checkParagraph( textArea, change ) );
1231
1232
    // Store the caret position to restore it after restarting the application.
1233
    textArea.caretPositionProperty().addListener(
1234
      ( c, o, n ) ->
1235
        getWorkspace().integerProperty( KEY_UI_RECENT_OFFSET ).setValue( n )
1236
    );
1237
1238
    // Check the entire document after the spellchecker is initialized (with
1239
    // a valid lexicon) so that only the current paragraph need be scanned
1240
    // while editing. (Technically, only the most recently modified word must
1241
    // be scanned.)
1242
    mSpellChecker.addListener(
1243
      ( c, o, n ) -> runLater(
1244
        () -> iterateEditors( mEditorSpeller::checkDocument )
1245
      )
1246
    );
1247
1248
    // Check the entire document after it has been loaded.
1249
    mEditorSpeller.checkDocument( editor );
1250
1251
    return editor;
1252
  }
1253
1254
  /**
1255
   * Creates a processor for an editor, provided one doesn't already exist.
1256
   *
1257
   * @param editor The editor that potentially requires an associated processor.
1258
   */
1259
  private void updateProcessors( final TextEditor editor ) {
1260
    final var path = editor.getFile().toPath();
1261
1262
    mProcessors.computeIfAbsent(
1263
      editor, p -> createProcessors(
1264
        createProcessorContext( path ),
1265
        createHtmlPreviewProcessor()
1266
      )
1267
    );
1268
  }
1269
1270
  /**
1271
   * Removes a processor for an editor. This is required because a file may
1272
   * change type while editing (e.g., from plain Markdown to R Markdown).
1273
   * In the case that an editor's type changes, its associated processor must
1274
   * be changed accordingly.
1275
   *
1276
   * @param editor The editor that potentially requires an associated processor.
1277
   */
1278
  private void removeProcessor( final TextEditor editor ) {
1279
    mProcessors.remove( editor );
1280
  }
1281
1282
  /**
1283
   * Creates a {@link Processor} capable of rendering an HTML document onto
1284
   * a GUI widget.
1285
   *
1286
   * @return The {@link Processor} for rendering an HTML document.
1287
   */
1288
  private Processor<String> createHtmlPreviewProcessor() {
1289
    return new HtmlPreviewProcessor( getPreview() );
1290
  }
1291
1292
  /**
1293
   * Creates a spellchecker that accepts all words as correct. This allows
1294
   * the spellchecker property to be initialized to a known valid value.
1295
   *
1296
   * @return A wrapped {@link PermissiveSpeller}.
1297
   */
1298
  private ObjectProperty<SpellChecker> createSpellChecker() {
1299
    return new SimpleObjectProperty<>( new PermissiveSpeller() );
1300
  }
1301
1302
  private TextEditorSpellChecker createTextEditorSpellChecker(
1303
    final ObjectProperty<SpellChecker> spellChecker ) {
1304
    return new TextEditorSpellChecker( spellChecker );
1305
  }
1306
1307
  /**
1308
   * Delegates to {@link #autoinsert()}.
1309
   *
1310
   * @param keyEvent Ignored.
1311
   */
1312
  private void autoinsert( final KeyEvent keyEvent ) {
203
    mTextEditor.addListener( ( _, o, n ) -> {
204
      if( o != null ) {
205
        removeProcessor( o );
206
      }
207
208
      if( n != null ) {
209
        mPreview.setBaseUri( n.getPath() );
210
        updateProcessors( n );
211
        process( n );
212
      }
213
    } );
214
215
    mTextEditor.set( createMarkdownEditor( DOCUMENT_DEFAULT ) );
216
    mDefinitionEditor.set( createDefinitionEditor( workspace ) );
217
    mVariableNameInjector = new VariableNameInjector( workspace );
218
    mRBootstrapController = new RBootstrapController(
219
      workspace, mDefinitionEditor.get()::getDefinitions
220
    );
221
222
    // If the user modifies the definitions, re-process the variables.
223
    mDefinitionEditor.addListener( ( _, _, _ ) -> {
224
      final var textEditor = getTextEditor();
225
226
      if( textEditor.isMediaType( TEXT_R_MARKDOWN ) ) {
227
        mRBootstrapController.update();
228
      }
229
230
      process( textEditor );
231
    } );
232
233
    open( collect( getRecentFiles() ) );
234
    viewPreview();
235
    setDividerPositions( calculateDividerPositions() );
236
237
    // Once the main scene's window regains focus, update the active definition
238
    // editor to the currently selected tab.
239
    runLater( () -> getWindow().setOnCloseRequest( event -> {
240
      // Order matters: Open file names must be persisted before closing all.
241
      mWorkspace.save();
242
243
      if( closeAll() ) {
244
        exit();
245
        terminate( 0 );
246
      }
247
248
      event.consume();
249
    } ) );
250
251
    register( this );
252
    initAutosave( workspace );
253
254
    restoreSession();
255
    runLater( this::restoreFocus );
256
257
    mInstallWizard = new TypesetterInstaller( workspace );
258
  }
259
260
  /**
261
   * Called when spellchecking can be run. This will reload the dictionary
262
   * into memory once, and then re-use it for all the existing text editors.
263
   *
264
   * @param event The event to process, having a populated word-frequency map.
265
   */
266
  @Subscribe
267
  public void handle( final LexiconLoadedEvent event ) {
268
    final var lexicon = event.getLexicon();
269
270
    try {
271
      final var checker = SymSpellSpeller.forLexicon( lexicon );
272
      mSpellChecker.set( checker );
273
    } catch( final Exception ex ) {
274
      clue( ex );
275
    }
276
  }
277
278
  @Subscribe
279
  public void handle( final TextEditorFocusEvent event ) {
280
    mTextEditor.set( event.get() );
281
  }
282
283
  @Subscribe
284
  public void handle( final TextDefinitionFocusEvent event ) {
285
    mDefinitionEditor.set( event.get() );
286
  }
287
288
  /**
289
   * Typically called when a file name is clicked in the preview panel.
290
   *
291
   * @param event The event to process, must contain a valid file reference.
292
   */
293
  @Subscribe
294
  public void handle( final FileOpenEvent event ) {
295
    final File eventFile;
296
    final var eventUri = event.getUri();
297
298
    if( eventUri.isAbsolute() ) {
299
      eventFile = new File( eventUri.getPath() );
300
    }
301
    else {
302
      final var activeFile = getTextEditor().getFile();
303
      final var parent = activeFile.getParentFile();
304
305
      if( parent == null ) {
306
        clue( new FileNotFoundException( eventUri.getPath() ) );
307
        return;
308
      }
309
      else {
310
        final var parentPath = parent.getAbsolutePath();
311
        eventFile = toFile( Path.of( parentPath, eventUri.getPath() ) );
312
      }
313
    }
314
315
    final var mediaType = MediaTypeExtension.fromFile( eventFile );
316
317
    runLater( () -> {
318
      // Open text files locally.
319
      if( mediaType.isType( TEXT ) ) {
320
        open( eventFile );
321
      }
322
      else {
323
        try {
324
          // Delegate opening all other file types to the operating system.
325
          getDesktop().open( eventFile );
326
        } catch( final Exception ex ) {
327
          clue( ex );
328
        }
329
      }
330
    } );
331
  }
332
333
  @Subscribe
334
  public void handle( final CaretNavigationEvent event ) {
335
    runLater( () -> {
336
      final var textArea = getTextEditor();
337
      textArea.moveTo( event.getOffset() );
338
      textArea.requestFocus();
339
    } );
340
  }
341
342
  @Subscribe
343
  public void handle( final InsertDefinitionEvent<String> event ) {
344
    final var leaf = event.getLeaf();
345
    final var editor = mTextEditor.get();
346
347
    mVariableNameInjector.insert( editor, leaf );
348
  }
349
350
  private void initAutosave( final Workspace workspace ) {
351
    final var rate = workspace.integerProperty( KEY_EDITOR_AUTOSAVE );
352
353
    rate.addListener(
354
      ( _, _, _ ) -> {
355
        final var taskRef = mSaveTask.get();
356
357
        // Prevent multiple auto-saves from running.
358
        if( taskRef != null ) {
359
          taskRef.cancel( false );
360
        }
361
362
        initAutosave( rate );
363
      }
364
    );
365
366
    // Start the save listener (avoids duplicating some code).
367
    initAutosave( rate );
368
  }
369
370
  private void initAutosave( final IntegerProperty rate ) {
371
    mSaveTask.set(
372
      mSaver.scheduleAtFixedRate(
373
        () -> {
374
          if( getTextEditor().isModified() ) {
375
            // Ensure the modified indicator is cleared by running on EDT.
376
            runLater( this::save );
377
          }
378
        }, 0, rate.intValue(), SECONDS
379
      )
380
    );
381
  }
382
383
  /**
384
   * TODO: Load divider positions from exported settings, see
385
   *   {@link #collect(SetProperty)} comment.
386
   */
387
  private double[] calculateDividerPositions() {
388
    final var ratio = 100f / getItems().size() / 100;
389
    final var positions = getDividerPositions();
390
391
    for( int i = 0; i < positions.length; i++ ) {
392
      positions[ i ] = ratio * i;
393
    }
394
395
    return positions;
396
  }
397
398
  /**
399
   * Opens all the files into the application, provided the paths are unique.
400
   * This may only be called for any type of files that a user can edit
401
   * (i.e., update and persist), such as definitions and text files.
402
   *
403
   * @param files The list of files to open.
404
   */
405
  public void open( final List<File> files ) {
406
    files.forEach( this::open );
407
  }
408
409
  /**
410
   * This opens the given file. Since the preview pane is not a file that
411
   * can be opened, it is safe to add a listener to the detachable pane.
412
   * This will exit early if the given file is not a regular file (i.e., a
413
   * directory).
414
   *
415
   * @param inputFile The file to open.
416
   */
417
  private void open( final File inputFile ) {
418
    // Prevent opening directories (a non-existent "untitled.md" is fine).
419
    if( !inputFile.isFile() && inputFile.exists() ) {
420
      return;
421
    }
422
423
    final var mediaType = fromFilename( inputFile );
424
425
    // Only allow opening text files.
426
    if( !mediaType.isType( TEXT ) ) {
427
      return;
428
    }
429
430
    final var tab = createTab( inputFile );
431
    final var node = tab.getContent();
432
    final var tabPane = obtainTabPane( mediaType );
433
434
    tab.setTooltip( createTooltip( inputFile ) );
435
    tabPane.setFocusTraversable( false );
436
    tabPane.setTabClosingPolicy( ALL_TABS );
437
    tabPane.getTabs().add( tab );
438
439
    // Attach the tab scene factory for new tab panes.
440
    if( !getItems().contains( tabPane ) ) {
441
      addTabPane(
442
        node instanceof TextDefinition ? 0 : getItems().size(), tabPane
443
      );
444
    }
445
446
    if( inputFile.isFile() ) {
447
      getRecentFiles().add( inputFile.getAbsolutePath() );
448
    }
449
  }
450
451
  /**
452
   * Gives focus to the most recently edited document and attempts to move
453
   * the caret to the most recently known offset into said document.
454
   */
455
  private void restoreSession() {
456
    final var workspace = getWorkspace();
457
    final var file = workspace.fileProperty( KEY_UI_RECENT_DOCUMENT );
458
    final var offset = workspace.integerProperty( KEY_UI_RECENT_OFFSET );
459
460
    for( final var pane : mTabPanes ) {
461
      for( final var tab : pane.getTabs() ) {
462
        final var tooltip = tab.getTooltip();
463
464
        if( tooltip != null ) {
465
          final var tabName = tooltip.getText();
466
          final var fileName = file.get().toString();
467
468
          if( tabName.equalsIgnoreCase( fileName ) ) {
469
            final var node = tab.getContent();
470
471
            pane.getSelectionModel().select( tab );
472
            node.requestFocus();
473
474
            if( node instanceof TextEditor editor ) {
475
              runLater( () -> editor.moveTo( offset.getValue() ) );
476
            }
477
478
            break;
479
          }
480
        }
481
      }
482
    }
483
  }
484
485
  /**
486
   * Sets the focus to the middle pane, which contains the text editor tabs.
487
   */
488
  private void restoreFocus() {
489
    // Work around a bug where focusing directly on the middle pane results
490
    // in the R engine not loading variables properly.
491
    mTabPanes.get( 0 ).requestFocus();
492
493
    // This is the only line that should be required.
494
    mTabPanes.get( 1 ).requestFocus();
495
  }
496
497
  /**
498
   * Opens a new text editor document using a document file name that doesn't
499
   * clash with an existing document.
500
   */
501
  public void newTextEditor() {
502
    final String key = "file.default.document.";
503
    final String prefix = Constants.get( STR."\{key}prefix" );
504
    final String suffix = Constants.get( STR."\{key}suffix" );
505
506
    File file = new File( STR."\{prefix}.\{suffix}" );
507
    int i = 0;
508
509
    while( file.exists() && i++ < 100 ) {
510
      file = new File( STR."\{prefix}-\{i}.\{suffix}" );
511
    }
512
513
    open( file );
514
  }
515
516
  /**
517
   * Opens a new definition editor document using the default definition
518
   * file name.
519
   */
520
  @SuppressWarnings( "unused" )
521
  public void newDefinitionEditor() {
522
    open( DEFINITION_DEFAULT );
523
  }
524
525
  /**
526
   * Iterates over all tab panes to find all {@link TextEditor}s and request
527
   * that they save themselves.
528
   */
529
  public void saveAll() {
530
    iterateEditors( this::save );
531
  }
532
533
  /**
534
   * Requests that the active {@link TextEditor} saves itself. Don't bother
535
   * checking if modified first because if the user swaps external media from
536
   * an external source (e.g., USB thumb drive), save should not second-guess
537
   * the user: save always re-saves. Also, it's less code.
538
   */
539
  public void save() {
540
    save( getTextEditor() );
541
  }
542
543
  /**
544
   * Saves the active {@link TextEditor} under a new name.
545
   *
546
   * @param files The new active editor {@link File} reference, must contain
547
   *              at least one element.
548
   */
549
  public void saveAs( final List<File> files ) {
550
    assert files != null;
551
    assert !files.isEmpty();
552
    final var editor = getTextEditor();
553
    final var tab = getTab( editor );
554
    final var file = files.getFirst();
555
556
    // If the file type has changed, refresh the processors.
557
    final var mediaType = fromFilename( file );
558
    final var typeChanged = !editor.isMediaType( mediaType );
559
560
    if( typeChanged ) {
561
      removeProcessor( editor );
562
    }
563
564
    editor.rename( file );
565
    tab.ifPresent( t -> {
566
      t.setText( editor.getFilename() );
567
      t.setTooltip( createTooltip( file ) );
568
    } );
569
570
    if( typeChanged ) {
571
      updateProcessors( editor );
572
      process( editor );
573
    }
574
575
    save();
576
  }
577
578
  /**
579
   * Saves the given {@link TextResource} to a file. This is typically used
580
   * to save either an instance of {@link TextEditor} or {@link TextDefinition}.
581
   *
582
   * @param resource The resource to export.
583
   */
584
  private void save( final TextResource resource ) {
585
    try {
586
      resource.save();
587
    } catch( final Exception ex ) {
588
      clue( ex );
589
      sNotifier.alert(
590
        getWindow(), resource.getPath(), "TextResource.saveFailed", ex
591
      );
592
    }
593
  }
594
595
  /**
596
   * Closes all open {@link TextEditor}s; all {@link TextDefinition}s stay open.
597
   *
598
   * @return {@code true} when all editors, modified or otherwise, were
599
   * permitted to close; {@code false} when one or more editors were modified
600
   * and the user requested no closing.
601
   */
602
  public boolean closeAll() {
603
    var closable = true;
604
605
    for( final var tabPane : mTabPanes ) {
606
      final var tabIterator = tabPane.getTabs().iterator();
607
608
      while( tabIterator.hasNext() ) {
609
        final var tab = tabIterator.next();
610
        final var resource = tab.getContent();
611
612
        // The definition panes auto-save, so being specific here prevents
613
        // closing the definitions in the situation where the user wants to
614
        // continue editing (i.e., possibly save unsaved work).
615
        if( !(resource instanceof TextEditor) ) {
616
          continue;
617
        }
618
619
        if( canClose( (TextEditor) resource ) ) {
620
          tabIterator.remove();
621
          close( tab );
622
        }
623
        else {
624
          closable = false;
625
        }
626
      }
627
    }
628
629
    return closable;
630
  }
631
632
  /**
633
   * Calls the tab's {@link Tab#getOnClosed()} handler to carry out a close
634
   * event.
635
   *
636
   * @param tab The {@link Tab} that was closed.
637
   */
638
  private void close( final Tab tab ) {
639
    assert tab != null;
640
641
    final var handler = tab.getOnClosed();
642
643
    if( handler != null ) {
644
      handler.handle( new ActionEvent() );
645
    }
646
  }
647
648
  /**
649
   * Closes the active tab; delegates to {@link #canClose(TextResource)}.
650
   */
651
  public void close() {
652
    final var editor = getTextEditor();
653
654
    if( canClose( editor ) ) {
655
      close( editor );
656
      removeProcessor( editor );
657
    }
658
  }
659
660
  /**
661
   * Closes the given {@link TextResource}. This must not be called from within
662
   * a loop that iterates over the tab panes using {@code forEach}, lest a
663
   * concurrent modification exception be thrown.
664
   *
665
   * @param resource The {@link TextResource} to close, without confirming with
666
   *                 the user.
667
   */
668
  private void close( final TextResource resource ) {
669
    getTab( resource ).ifPresent(
670
      tab -> {
671
        close( tab );
672
        tab.getTabPane().getTabs().remove( tab );
673
      }
674
    );
675
  }
676
677
  /**
678
   * Answers whether the given {@link TextResource} may be closed.
679
   *
680
   * @param editor The {@link TextResource} to try closing.
681
   * @return {@code true} when the editor may be closed; {@code false} when
682
   * the user has requested to keep the editor open.
683
   */
684
  private boolean canClose( final TextResource editor ) {
685
    final var editorTab = getTab( editor );
686
    final var canClose = new AtomicBoolean( true );
687
688
    if( editor.isModified() ) {
689
      final var filename = new StringBuilder();
690
      editorTab.ifPresent( tab -> filename.append( tab.getText() ) );
691
692
      final var message = sNotifier.createNotification(
693
        Messages.get( "Alert.file.close.title" ),
694
        Messages.get( "Alert.file.close.text" ),
695
        filename.toString()
696
      );
697
698
      final var dialog = sNotifier.createConfirmation( getWindow(), message );
699
700
      dialog.showAndWait().ifPresent(
701
        save -> canClose.set( save == YES ? editor.save() : save == NO )
702
      );
703
    }
704
705
    return canClose.get();
706
  }
707
708
  private void iterateEditors( final Consumer<TextEditor> consumer ) {
709
    mTabPanes.forEach(
710
      tp -> tp.getTabs().forEach( tab -> {
711
        final var node = tab.getContent();
712
713
        if( node instanceof final TextEditor editor ) {
714
          consumer.accept( editor );
715
        }
716
      } )
717
    );
718
  }
719
720
  /**
721
   * Adds the HTML preview tab to its own, singular tab pane.
722
   */
723
  public void viewPreview() {
724
    addTab( mPreview, TEXT_HTML, "Pane.preview.title" );
725
  }
726
727
  /**
728
   * Adds the document outline tab to its own, singular tab pane.
729
   */
730
  public void viewOutline() {
731
    addTab( mOutline, APP_DOCUMENT_OUTLINE, "Pane.outline.title" );
732
  }
733
734
  public void viewStatistics() {
735
    addTab( mStatistics, APP_DOCUMENT_STATISTICS, "Pane.statistics.title" );
736
  }
737
738
  public void viewFiles() {
739
    try {
740
      final var factory = new FilePickerFactory( getWorkspace() );
741
      final var fileManager = factory.createModeless();
742
      addTab( fileManager, APP_FILE_MANAGER, "Pane.files.title" );
743
    } catch( final Exception ex ) {
744
      clue( ex );
745
    }
746
  }
747
748
  public void viewRefresh() {
749
    mPreview.refresh();
750
    Engine.clear();
751
    mRBootstrapController.update();
752
  }
753
754
  private void addTab(
755
    final Node node, final MediaType mediaType, final String key ) {
756
    final var tabPane = obtainTabPane( mediaType );
757
758
    for( final var tab : tabPane.getTabs() ) {
759
      if( tab.getContent() == node ) {
760
        return;
761
      }
762
    }
763
764
    tabPane.getTabs().add( createTab( get( key ), node ) );
765
    addTabPane( tabPane );
766
  }
767
768
  /**
769
   * Returns the tab that contains the given {@link TextEditor}.
770
   *
771
   * @param editor The {@link TextEditor} instance to find amongst the tabs.
772
   * @return The first tab having content that matches the given tab.
773
   */
774
  private Optional<Tab> getTab( final TextResource editor ) {
775
    return mTabPanes.stream()
776
                    .flatMap( pane -> pane.getTabs().stream() )
777
                    .filter( tab -> editor.equals( tab.getContent() ) )
778
                    .findFirst();
779
  }
780
781
  private TextDefinition createDefinitionEditor( final File file ) {
782
    final var editor = new DefinitionEditor( file, createTreeTransformer() );
783
784
    editor.addTreeChangeHandler( mTreeHandler );
785
786
    return editor;
787
  }
788
789
  /**
790
   * Creates a new {@link DefinitionEditor} wrapped in a listener that
791
   * is used to detect when the active {@link DefinitionEditor} has changed.
792
   * Upon changing, the variables are interpolated and the active text editor
793
   * is refreshed.
794
   *
795
   * @param workspace Has the most recently edited definitions file name.
796
   * @return A newly configured property that represents the active
797
   * {@link DefinitionEditor}, never {@code null}.
798
   */
799
  private TextDefinition createDefinitionEditor(
800
    final Workspace workspace ) {
801
    final var fileProperty = workspace.fileProperty( KEY_UI_RECENT_DEFINITION );
802
    final var filename = fileProperty.get();
803
    final SetProperty<String> recent = workspace.setsProperty(
804
      KEY_UI_RECENT_OPEN_PATH
805
    );
806
807
    // Open the most recently used YAML definition file.
808
    for( final var recentFile : recent.get() ) {
809
      if( recentFile.endsWith( filename.toString() ) ) {
810
        return createDefinitionEditor( new File( recentFile ) );
811
      }
812
    }
813
814
    return createDefaultDefinitionEditor();
815
  }
816
817
  private TextDefinition createDefaultDefinitionEditor() {
818
    final var transformer = createTreeTransformer();
819
    return new DefinitionEditor( transformer );
820
  }
821
822
  private TreeTransformer createTreeTransformer() {
823
    return new YamlTreeTransformer();
824
  }
825
826
  private Tab createTab( final String filename, final Node node ) {
827
    return new DetachableTab( filename, node );
828
  }
829
830
  private Tab createTab( final File file ) {
831
    final var r = createTextResource( file );
832
    final var filename = r.getFilename();
833
    final var tab = createTab( filename, r.getNode() );
834
835
    r.modifiedProperty().addListener(
836
      ( _, _, n ) -> tab.setText( filename + (n ? "*" : "") )
837
    );
838
839
    // This is called when either the tab is closed by the user clicking on
840
    // the tab's close icon or when closing (all) from the file menu.
841
    tab.setOnClosed(
842
      _ -> getRecentFiles().remove( file.getAbsolutePath() )
843
    );
844
845
    // When closing a tab, give focus to the newly revealed tab.
846
    tab.selectedProperty().addListener( ( _, _, n ) -> {
847
      if( n != null && n ) {
848
        final var pane = tab.getTabPane();
849
850
        if( pane != null ) {
851
          pane.requestFocus();
852
        }
853
      }
854
    } );
855
856
    tab.tabPaneProperty().addListener( ( _, _, nPane ) -> {
857
      if( nPane != null ) {
858
        nPane.focusedProperty().addListener( ( _, _, n ) -> {
859
          if( n != null && n ) {
860
            final var selected = nPane.getSelectionModel().getSelectedItem();
861
            final var node = selected.getContent();
862
            node.requestFocus();
863
          }
864
        } );
865
      }
866
    } );
867
868
    return tab;
869
  }
870
871
  /**
872
   * Creates bins for the different {@link MediaType}s, which eventually are
873
   * added to the UI as separate tab panes. If ever a general-purpose scene
874
   * exporter is developed to serialize a scene to an FXML file, this could
875
   * be replaced by such a class.
876
   * <p>
877
   * When binning the files, this makes sure that at least one file exists
878
   * for every type. If the user has opted to close a particular type (such
879
   * as the definition pane), the view will suppressed elsewhere.
880
   * </p>
881
   * <p>
882
   * The order that the binned files are returned will be reflected in the
883
   * order that the corresponding panes are rendered in the UI.
884
   * </p>
885
   *
886
   * @param paths The file paths to bin according to their type.
887
   * @return An in-order list of files, first by structured definition files,
888
   * then by plain text documents.
889
   */
890
  private List<File> collect( final SetProperty<String> paths ) {
891
    // Treat all files destined for the text editor as plain text documents
892
    // so that they are added to the same pane. Grouping by TEXT_PLAIN is a
893
    // bit arbitrary, but means explicitly capturing TEXT_PLAIN isn't needed.
894
    final Function<MediaType, MediaType> bin =
895
      m -> PLAIN_TEXT_FORMAT.contains( m ) ? TEXT_PLAIN : m;
896
897
    // Create two groups: YAML files and plain text files. The order that
898
    // the elements are listed in the enumeration for media types determines
899
    // what files are loaded first. Variable definitions come before all other
900
    // plain text documents.
901
    final var bins = paths
902
      .stream()
903
      .collect(
904
        groupingBy(
905
          path -> bin.apply( fromFilename( path ) ),
906
          () -> new TreeMap<>( Enum::compareTo ),
907
          Collectors.toList()
908
        )
909
      );
910
911
    bins.putIfAbsent( TEXT_YAML, List.of( DEFINITION_DEFAULT.toString() ) );
912
    bins.putIfAbsent( TEXT_PLAIN, List.of( DOCUMENT_DEFAULT.toString() ) );
913
914
    final var result = new LinkedList<File>();
915
916
    // Ensure that the same types are listed together (keep insertion order).
917
    bins.forEach( ( _, files ) -> result.addAll(
918
      files.stream().map( File::new ).toList() )
919
    );
920
921
    return result;
922
  }
923
924
  /**
925
   * Force the active editor to update, which will cause the processor
926
   * to re-evaluate the interpolated definition map thereby updating the
927
   * preview pane.
928
   *
929
   * @param editor Contains the source document to update in the preview pane.
930
   */
931
  private void process( final TextEditor editor ) {
932
    // Ensure processing does not run on the JavaFX thread, which frees the
933
    // text editor immediately for caret movement. The preview will have a
934
    // slight delay when catching up to the caret position.
935
    final var task = new Task<Void>() {
936
      @Override
937
      public Void call() {
938
        try {
939
          final var p = mProcessors.getOrDefault( editor, IDENTITY );
940
          p.apply( editor == null ? "" : editor.getText() );
941
        } catch( final Exception ex ) {
942
          clue( ex );
943
        }
944
945
        return null;
946
      }
947
    };
948
949
    // TODO: Each time the editor successfully runs the processor, the task is
950
    //   considered successful. Due to the rapid-fire nature of processing
951
    //   (e.g., keyboard navigation, fast typing), it isn't necessary to
952
    //   scroll each time.
953
    //   The algorithm:
954
    //   1. Peek at the oldest time.
955
    //   2. If the difference between the oldest time and current time exceeds
956
    //      250 milliseconds, then invoke the scrolling.
957
    //   3. Insert the current time into the circular queue.
958
    task.setOnSucceeded(
959
      _ -> invokeLater( () -> mPreview.scrollTo( CARET_ID ) )
960
    );
961
962
    // Prevents multiple process requests from executing simultaneously (due
963
    // to having a restricted queue size).
964
    sExecutor.execute( task );
965
  }
966
967
  /**
968
   * Lazily creates a {@link TabPane} configured to listen for tab select
969
   * events. The tab pane is associated with a given media type so that
970
   * similar files can be grouped together.
971
   *
972
   * @param mediaType The media type to associate with the tab pane.
973
   * @return An instance of {@link TabPane} that will handle tab docking.
974
   */
975
  private TabPane obtainTabPane( final MediaType mediaType ) {
976
    for( final var pane : mTabPanes ) {
977
      for( final var tab : pane.getTabs() ) {
978
        final var node = tab.getContent();
979
980
        if( node instanceof TextResource r && r.supports( mediaType ) ) {
981
          return pane;
982
        }
983
      }
984
    }
985
986
    final var pane = createTabPane();
987
    mTabPanes.add( pane );
988
    return pane;
989
  }
990
991
  /**
992
   * Creates an initialized {@link TabPane} instance.
993
   *
994
   * @return A new {@link TabPane} with all listeners configured.
995
   */
996
  private TabPane createTabPane() {
997
    final var tabPane = new DetachableTabPane();
998
999
    initStageOwnerFactory( tabPane );
1000
    initTabListener( tabPane );
1001
1002
    return tabPane;
1003
  }
1004
1005
  /**
1006
   * When any {@link DetachableTabPane} is detached from the main window,
1007
   * the stage owner factory must be given its parent window, which will
1008
   * own the child window. The parent window is the {@link MainPane}'s
1009
   * {@link Scene}'s {@link Window} instance.
1010
   *
1011
   * <p>
1012
   * This will derives the new title from the main window title, incrementing
1013
   * the window count to help uniquely identify the child windows.
1014
   * </p>
1015
   *
1016
   * @param tabPane A new {@link DetachableTabPane} to configure.
1017
   */
1018
  private void initStageOwnerFactory( final DetachableTabPane tabPane ) {
1019
    tabPane.setStageOwnerFactory( stage -> {
1020
      final var title = get(
1021
        "Detach.tab.title",
1022
        ((Stage) getWindow()).getTitle(), ++mWindowCount
1023
      );
1024
      stage.setTitle( title );
1025
1026
      return getScene().getWindow();
1027
    } );
1028
  }
1029
1030
  /**
1031
   * Responsible for configuring the content of each {@link DetachableTab} when
1032
   * it is added to the given {@link DetachableTabPane} instance.
1033
   * <p>
1034
   * For {@link TextEditor} contents, an instance of {@link ScrollEventHandler}
1035
   * is initialized to perform synchronized scrolling between the editor and
1036
   * its preview window. Additionally, the last tab in the tab pane's list of
1037
   * tabs is given focus.
1038
   * </p>
1039
   * <p>
1040
   * Note that multiple tabs can be added simultaneously.
1041
   * </p>
1042
   *
1043
   * @param tabPane A new {@link TabPane} to configure.
1044
   */
1045
  private void initTabListener( final TabPane tabPane ) {
1046
    tabPane.getTabs().addListener(
1047
      ( final ListChangeListener.Change<? extends Tab> listener ) -> {
1048
        while( listener.next() ) {
1049
          if( listener.wasAdded() ) {
1050
            final var tabs = listener.getAddedSubList();
1051
1052
            tabs.forEach( tab -> {
1053
              final var node = tab.getContent();
1054
1055
              if( node instanceof TextEditor ) {
1056
                initScrollEventListener( tab );
1057
              }
1058
            } );
1059
1060
            // Select and give focus to the last tab opened.
1061
            final var index = tabs.size() - 1;
1062
            if( index >= 0 ) {
1063
              final var tab = tabs.get( index );
1064
              tabPane.getSelectionModel().select( tab );
1065
              tab.getContent().requestFocus();
1066
            }
1067
          }
1068
        }
1069
      }
1070
    );
1071
  }
1072
1073
  /**
1074
   * Synchronizes scrollbar positions between the given {@link Tab} that
1075
   * contains an instance of {@link TextEditor} and {@link HtmlPreview} pane.
1076
   *
1077
   * @param tab The container for an instance of {@link TextEditor}.
1078
   */
1079
  private void initScrollEventListener( final Tab tab ) {
1080
    final var editor = (TextEditor) tab.getContent();
1081
    final var scrollPane = editor.getScrollPane();
1082
    final var scrollBar = mPreview.getVerticalScrollBar();
1083
    final var handler = new ScrollEventHandler( scrollPane, scrollBar );
1084
1085
    handler.enabledProperty().bind( tab.selectedProperty() );
1086
  }
1087
1088
  private void addTabPane( final int index, final TabPane tabPane ) {
1089
    final var items = getItems();
1090
1091
    if( !items.contains( tabPane ) ) {
1092
      items.add( index, tabPane );
1093
    }
1094
  }
1095
1096
  private void addTabPane( final TabPane tabPane ) {
1097
    addTabPane( getItems().size(), tabPane );
1098
  }
1099
1100
  private GenericBuilder<Mutator, ProcessorContext> processorContextBuilder() {
1101
    final var w = getWorkspace();
1102
1103
    return builder()
1104
      .with( Mutator::setDefinitions, this::getDefinitions )
1105
      .with( Mutator::setLocale, w::getLocale )
1106
      .with( Mutator::setMetadata, w::getMetadata )
1107
      .with( Mutator::setThemeDir, w::getThemesPath )
1108
      .with( Mutator::setCacheDir,
1109
             () -> w.getFile( KEY_CACHE_DIR ) )
1110
      .with( Mutator::setImageDir,
1111
             () -> w.getFile( KEY_IMAGE_DIR ) )
1112
      .with( Mutator::setImageOrder,
1113
             () -> w.getString( KEY_IMAGE_ORDER ) )
1114
      .with( Mutator::setImageServer,
1115
             () -> w.getString( KEY_IMAGE_SERVER ) )
1116
      .with( Mutator::setCaret,
1117
             () -> getTextEditor().getCaret() )
1118
      .with( Mutator::setSigilBegan,
1119
             () -> w.getString( KEY_DEF_DELIM_BEGAN ) )
1120
      .with( Mutator::setSigilEnded,
1121
             () -> w.getString( KEY_DEF_DELIM_ENDED ) )
1122
      .with( Mutator::setRScript,
1123
             () -> w.getString( KEY_R_SCRIPT ) )
1124
      .with( Mutator::setRWorkingDir,
1125
             () -> w.getFile( KEY_R_DIR ).toPath() )
1126
      .with( Mutator::setFontDir,
1127
             () -> w.getFile( KEY_TYPESET_CONTEXT_FONTS_DIR ) )
1128
      .with( Mutator::setEnableMode,
1129
             () -> w.getString( KEY_TYPESET_MODES_ENABLED ) )
1130
      .with( Mutator::setCurlQuotes,
1131
             () -> w.getBoolean( KEY_TYPESET_TYPOGRAPHY_QUOTES ) )
1132
      .with( Mutator::setAutoRemove,
1133
             () -> w.getBoolean( KEY_TYPESET_CONTEXT_CLEAN ) );
1134
  }
1135
1136
  public ProcessorContext createProcessorContext() {
1137
    return createProcessorContextBuilder( NONE ).build();
1138
  }
1139
1140
  private GenericBuilder<Mutator, ProcessorContext> createProcessorContextBuilder(
1141
    final ExportFormat format ) {
1142
    final var textEditor = getTextEditor();
1143
    final var sourcePath = textEditor.getPath();
1144
1145
    return processorContextBuilder()
1146
      .with( Mutator::setSourcePath, sourcePath )
1147
      .with( Mutator::setExportFormat, format );
1148
  }
1149
1150
  /**
1151
   * @param targetPath Used when exporting to a PDF file (binary).
1152
   * @param format     Used when processors export to a new text format.
1153
   * @return A new {@link ProcessorContext} to use when creating an instance of
1154
   * {@link Processor}.
1155
   */
1156
  public ProcessorContext createProcessorContext(
1157
    final Path targetPath, final ExportFormat format ) {
1158
    assert targetPath != null;
1159
    assert format != null;
1160
1161
    return createProcessorContextBuilder( format )
1162
      .with( Mutator::setTargetPath, targetPath )
1163
      .build();
1164
  }
1165
1166
  /**
1167
   * @param sourcePath Used by {@link ProcessorFactory} to determine
1168
   *                   {@link Processor} type to create based on file type.
1169
   * @return A new {@link ProcessorContext} to use when creating an instance of
1170
   * {@link Processor}.
1171
   */
1172
  private ProcessorContext createProcessorContext( final Path sourcePath ) {
1173
    return processorContextBuilder()
1174
      .with( Mutator::setSourcePath, sourcePath )
1175
      .with( Mutator::setExportFormat, NONE )
1176
      .build();
1177
  }
1178
1179
  private TextResource createTextResource( final File file ) {
1180
    if( fromFilename( file ) == TEXT_YAML ) {
1181
      final var editor = createDefinitionEditor( file );
1182
      mDefinitionEditor.set( editor );
1183
      return editor;
1184
    }
1185
    else {
1186
      final var editor = createMarkdownEditor( file );
1187
      mTextEditor.set( editor );
1188
      return editor;
1189
    }
1190
  }
1191
1192
  /**
1193
   * Creates an instance of {@link MarkdownEditor} that listens for both
1194
   * caret change events and text change events. Text change events must
1195
   * take priority over caret change events because it's possible to change
1196
   * the text without moving the caret (e.g., delete selected text).
1197
   *
1198
   * @param inputFile The file containing contents for the text editor.
1199
   * @return A non-null text editor.
1200
   */
1201
  private MarkdownEditor createMarkdownEditor( final File inputFile ) {
1202
    final var editor = new MarkdownEditor( inputFile, getWorkspace() );
1203
1204
    // Listener for editor modifications or caret position changes.
1205
    editor.addDirtyListener( ( _, _, n ) -> {
1206
      if( n ) {
1207
        // Reset the status bar after changing the text.
1208
        clue();
1209
1210
        // Processing the text may update the status bar.
1211
        process( editor );
1212
1213
        // Update the caret position in the status bar.
1214
        CaretMovedEvent.fire( editor.getCaret() );
1215
      }
1216
    } );
1217
1218
    editor.addEventListener(
1219
      keyPressed( SPACE, CONTROL_DOWN ), this::autoinsert
1220
    );
1221
1222
    editor.addEventListener(
1223
      keyPressed( ENTER, ALT_DOWN ), _ -> mEditorSpeller.autofix( editor )
1224
    );
1225
1226
    final var textArea = editor.getTextArea();
1227
1228
    // Spell check when the paragraph changes.
1229
    textArea
1230
      .plainTextChanges()
1231
      .filter( p -> !p.isIdentity() )
1232
      .subscribe( change -> mEditorSpeller.checkParagraph( textArea, change ) );
1233
1234
    // Store the caret position to restore it after restarting the application.
1235
    textArea.caretPositionProperty().addListener(
1236
      ( _, _, n ) ->
1237
        getWorkspace().integerProperty( KEY_UI_RECENT_OFFSET ).setValue( n )
1238
    );
1239
1240
    // Check the entire document after the spellchecker is initialized (with
1241
    // a valid lexicon) so that only the current paragraph need be scanned
1242
    // while editing. (Technically, only the most recently modified word must
1243
    // be scanned.)
1244
    mSpellChecker.addListener(
1245
      ( _, _, _ ) -> runLater(
1246
        () -> iterateEditors( mEditorSpeller::checkDocument )
1247
      )
1248
    );
1249
1250
    // Check the entire document after it has been loaded.
1251
    mEditorSpeller.checkDocument( editor );
1252
1253
    return editor;
1254
  }
1255
1256
  /**
1257
   * Creates a processor for an editor, provided one doesn't already exist.
1258
   *
1259
   * @param editor The editor that potentially requires an associated processor.
1260
   */
1261
  private void updateProcessors( final TextEditor editor ) {
1262
    final var path = editor.getFile().toPath();
1263
1264
    mProcessors.computeIfAbsent(
1265
      editor, _ -> createProcessors(
1266
        createProcessorContext( path ),
1267
        createHtmlPreviewProcessor()
1268
      )
1269
    );
1270
  }
1271
1272
  /**
1273
   * Removes a processor for an editor. This is required because a file may
1274
   * change type while editing (e.g., from plain Markdown to R Markdown).
1275
   * In the case that an editor's type changes, its associated processor must
1276
   * be changed accordingly.
1277
   *
1278
   * @param editor The editor that potentially requires an associated processor.
1279
   */
1280
  private void removeProcessor( final TextEditor editor ) {
1281
    mProcessors.remove( editor );
1282
  }
1283
1284
  /**
1285
   * Creates a {@link Processor} capable of rendering an HTML document onto
1286
   * a GUI widget.
1287
   *
1288
   * @return The {@link Processor} for rendering an HTML document.
1289
   */
1290
  private Processor<String> createHtmlPreviewProcessor() {
1291
    return new HtmlPreviewProcessor( getPreview() );
1292
  }
1293
1294
  /**
1295
   * Creates a spellchecker that accepts all words as correct. This allows
1296
   * the spellchecker property to be initialized to a known valid value.
1297
   *
1298
   * @return A wrapped {@link PermissiveSpeller}.
1299
   */
1300
  private ObjectProperty<SpellChecker> createSpellChecker() {
1301
    return new SimpleObjectProperty<>( new PermissiveSpeller() );
1302
  }
1303
1304
  private TextEditorSpellChecker createTextEditorSpellChecker(
1305
    final ObjectProperty<SpellChecker> spellChecker ) {
1306
    return new TextEditorSpellChecker( spellChecker );
1307
  }
1308
1309
  /**
1310
   * Delegates to {@link #autoinsert()}.
1311
   *
1312
   * @param ignored Ignored.
1313
   */
1314
  private void autoinsert( final KeyEvent ignored ) {
13131315
    autoinsert();
13141316
  }
M src/main/java/com/keenwrite/cmdline/Arguments.java
3939
public final class Arguments implements Callable<Integer> {
4040
  @CommandLine.Option(
41
    names = {"--all"},
41
    names = { "--all" },
4242
    description =
4343
      "Concatenate files before processing (${DEFAULT-VALUE})",
4444
    defaultValue = "false"
4545
  )
4646
  private boolean mConcatenate;
4747
4848
  @CommandLine.Option(
49
    names = {"--keep-files"},
49
    names = { "--keep-files" },
5050
    description =
5151
      "Retain temporary build files (${DEFAULT-VALUE})",
5252
    defaultValue = "false"
5353
  )
5454
  private boolean mKeepFiles;
5555
5656
  @CommandLine.Option(
57
    names = {"-c", "--chapters"},
57
    names = { "-c", "--chapters" },
5858
    description =
5959
      "Export chapter ranges, no spaces (e.g., -3,5-9,15-)",
6060
    paramLabel = "String"
6161
  )
6262
  private String mChapters;
6363
6464
  @CommandLine.Option(
65
    names = {"--curl-quotes"},
65
    names = { "--curl-quotes" },
6666
    description =
6767
      "Replace straight quotes with curly quotes (${DEFAULT-VALUE})",
6868
    defaultValue = "true"
6969
  )
7070
  private boolean mCurlQuotes;
7171
7272
  @CommandLine.Option(
73
    names = {"-d", "--debug"},
73
    names = { "-d", "--debug" },
7474
    description =
7575
      "Enable logging to the console (${DEFAULT-VALUE})",
7676
    paramLabel = "Boolean",
7777
    defaultValue = "false"
7878
  )
7979
  private boolean mDebug;
8080
8181
  @CommandLine.Option(
82
    names = {"-i", "--input"},
82
    names = { "-i", "--input" },
8383
    description =
8484
      "Source document file path",
8585
    paramLabel = "PATH",
8686
    defaultValue = "stdin",
8787
    required = true
8888
  )
8989
  private Path mSourcePath;
9090
9191
  @CommandLine.Option(
92
    names = {"--font-dir"},
92
    names = { "--font-dir" },
9393
    description =
9494
      "Directory to specify additional fonts",
9595
    paramLabel = "String"
9696
  )
9797
  private File mFontDir;
9898
9999
  @CommandLine.Option(
100
    names = {"--format-subtype"},
100
    names = { "--mode" },
101
    description =
102
      "Enable one or more modes when typesetting",
103
    paramLabel = "String"
104
  )
105
  private String mEnableMode;
106
107
  @CommandLine.Option(
108
    names = { "--format-subtype" },
101109
    description =
102110
      "Export TeX subtype for HTML formats: svg, delimited",
103111
    paramLabel = "String",
104112
    defaultValue = "svg"
105113
  )
106114
  private String mFormatSubtype;
107115
108116
  @CommandLine.Option(
109
    names = {"--cache-dir"},
117
    names = { "--cache-dir" },
110118
    description =
111119
      "Directory to store remote resources",
112120
    paramLabel = "DIR"
113121
  )
114122
  private File mCachesDir;
115123
116124
  @CommandLine.Option(
117
    names = {"--image-dir"},
125
    names = { "--image-dir" },
118126
    description =
119127
      "Directory containing images",
120128
    paramLabel = "DIR"
121129
  )
122130
  private File mImagesDir;
123131
124132
  @CommandLine.Option(
125
    names = {"--image-order"},
133
    names = { "--image-order" },
126134
    description =
127135
      "Comma-separated image order (${DEFAULT-VALUE})",
128136
    paramLabel = "String",
129137
    defaultValue = "svg,pdf,png,jpg,tiff"
130138
  )
131139
  private String mImageOrder;
132140
133141
  @CommandLine.Option(
134
    names = {"--image-server"},
142
    names = { "--image-server" },
135143
    description =
136144
      "SVG diagram rendering service (${DEFAULT-VALUE})",
137145
    paramLabel = "String",
138146
    defaultValue = DIAGRAM_SERVER_NAME
139147
  )
140148
  private String mImageServer;
141149
142150
  @CommandLine.Option(
143
    names = {"--locale"},
151
    names = { "--locale" },
144152
    description =
145153
      "Set localization (${DEFAULT-VALUE})",
146154
    paramLabel = "String",
147155
    defaultValue = "en"
148156
  )
149157
  private String mLocale;
150158
151159
  @CommandLine.Option(
152
    names = {"-m", "--metadata"},
160
    names = { "-m", "--metadata" },
153161
    description =
154162
      "Map metadata keys to values, variable names allowed",
155163
    paramLabel = "key=value"
156164
  )
157165
  private Map<String, String> mMetadata;
158166
159167
  @CommandLine.Option(
160
    names = {"-o", "--output"},
168
    names = { "-o", "--output" },
161169
    description =
162170
      "Destination document file path",
163171
    paramLabel = "PATH",
164172
    defaultValue = "stdout",
165173
    required = true
166174
  )
167175
  private Path mTargetPath;
168176
169177
  @CommandLine.Option(
170
    names = {"-q", "--quiet"},
178
    names = { "-q", "--quiet" },
171179
    description =
172180
      "Suppress all status messages (${DEFAULT-VALUE})",
173181
    defaultValue = "false"
174182
  )
175183
  private boolean mQuiet;
176184
177185
  @CommandLine.Option(
178
    names = {"--r-dir"},
186
    names = { "--r-dir" },
179187
    description =
180188
      "R working directory",
181189
    paramLabel = "DIR"
182190
  )
183191
  private Path mRWorkingDir;
184192
185193
  @CommandLine.Option(
186
    names = {"--r-script"},
194
    names = { "--r-script" },
187195
    description =
188196
      "R bootstrap script file path",
189197
    paramLabel = "PATH"
190198
  )
191199
  private Path mRScriptPath;
192200
193201
  @CommandLine.Option(
194
    names = {"-s", "--set"},
202
    names = { "-s", "--set" },
195203
    description =
196204
      "Set (or override) a document variable value",
197205
    paramLabel = "key=value"
198206
  )
199207
  private Map<String, String> mOverrides;
200208
201209
  @CommandLine.Option(
202
    names = {"--sigil-opening"},
210
    names = { "--sigil-opening" },
203211
    description =
204212
      "Starting sigil for variable names (${DEFAULT-VALUE})",
205213
    paramLabel = "String",
206214
    defaultValue = "{{"
207215
  )
208216
  private String mSigilBegan;
209217
210218
  @CommandLine.Option(
211
    names = {"--sigil-closing"},
219
    names = { "--sigil-closing" },
212220
    description =
213221
      "Ending sigil for variable names (${DEFAULT-VALUE})",
214222
    paramLabel = "String",
215223
    defaultValue = "}}"
216224
  )
217225
  private String mSigilEnded;
218226
219227
  @CommandLine.Option(
220
    names = {"--theme-dir"},
228
    names = { "--theme-dir" },
221229
    description =
222230
      "Theme directory",
223231
    paramLabel = "DIR"
224232
  )
225233
  private Path mThemesDir;
226234
227235
  @CommandLine.Option(
228
    names = {"-v", "--variables"},
236
    names = { "-v", "--variables" },
229237
    description =
230238
      "Variables file path",
...
256264
      .with( Mutator::setImageOrder, () -> mImageOrder )
257265
      .with( Mutator::setFontDir, () -> mFontDir )
266
      .with( Mutator::setEnableMode, () -> mEnableMode )
258267
      .with( Mutator::setExportFormat, format )
259268
      .with( Mutator::setDefinitions, () -> definitions )
...
338347
339348
    final var jsonNode = node.getValue();
340
    final var keyName = parent + "." + node.getKey();
349
    final var keyName = STR."\{parent}.\{node.getKey()}";
341350
342351
    if( jsonNode.isValueNode() ) {
M src/main/java/com/keenwrite/constants/Constants.java
1818
import static com.keenwrite.io.SysFile.toFile;
1919
import static com.keenwrite.preferences.LocaleScripts.withScript;
20
import static com.keenwrite.util.SystemUtils.*;
2021
import static java.io.File.separator;
2122
import static java.lang.String.format;
2223
import static java.lang.System.getProperty;
23
import static org.apache.commons.lang3.SystemUtils.*;
2424
2525
/**
M src/main/java/com/keenwrite/editors/definition/DefinitionEditor.java
122122
    final var buttonBar = new HBox();
123123
    buttonBar.getChildren().addAll(
124
      createButton( "create", e -> createDefinition() ),
125
      createButton( "rename", e -> renameDefinition() ),
126
      createButton( "delete", e -> deleteDefinitions() )
127
    );
128
    buttonBar.setAlignment( CENTER );
129
    buttonBar.setSpacing( UI_CONTROL_SPACING );
130
    setTop( buttonBar );
131
    setCenter( mTreeView );
132
    setAlignment( buttonBar, TOP_CENTER );
133
134
    mEncoding = open( mFile );
135
    updateDefinitions( getDefinitions(), getTreeView().getRoot() );
136
137
    // After the file is opened, watch for changes, not before. Otherwise,
138
    // upon saving, users will be prompted to save a file that hasn't had
139
    // any modifications (from their perspective).
140
    addTreeChangeHandler( event -> {
141
      mModified.set( true );
142
      updateDefinitions( getDefinitions(), getTreeView().getRoot() );
143
    } );
144
  }
145
146
  /**
147
   * Replaces the given list of variable definitions with a flat hierarchy
148
   * of the converted {@link TreeView} root.
149
   *
150
   * @param definitions The definition map to update.
151
   * @param root        The values to flatten then insert into the map.
152
   */
153
  private void updateDefinitions(
154
    final Map<String, String> definitions,
155
    final TreeItem<String> root ) {
156
    definitions.clear();
157
    definitions.putAll( TreeItemMapper.convert( root ) );
158
    Engine.clear();
159
  }
160
161
  /**
162
   * Returns the variable definitions.
163
   *
164
   * @return The definition map.
165
   */
166
  @Override
167
  public Map<String, String> getDefinitions() {
168
    return mDefinitions;
169
  }
170
171
  @Override
172
  public void setText( final String document ) {
173
    final var foster = mTreeTransformer.transform( document );
174
    final var biological = getTreeRoot();
175
176
    for( final var child : foster.getChildren() ) {
177
      biological.getChildren().add( child );
178
    }
179
180
    getTreeView().refresh();
181
  }
182
183
  @Override
184
  public String getText() {
185
    final var result = new StringBuilder( 32768 );
186
187
    try {
188
      result.append( mTreeTransformer.transform( getTreeView().getRoot() ) );
189
190
      final var problem = isTreeWellFormed();
191
      problem.ifPresent( node -> clue( "yaml.error.tree.form", node ) );
192
    } catch( final Exception ex ) {
193
      // Catch errors while checking for a well-formed tree (e.g., stack smash).
194
      // Also catch any transformation exceptions (e.g., Json processing).
195
      clue( ex );
196
    }
197
198
    return result.toString();
199
  }
200
201
  @Override
202
  public File getFile() {
203
    return mFile;
204
  }
205
206
  @Override
207
  public void rename( final File file ) {
208
    mFile = file;
209
  }
210
211
  @Override
212
  public Charset getEncoding() {
213
    return mEncoding;
214
  }
215
216
  @Override
217
  public Node getNode() {
218
    return this;
219
  }
220
221
  @Override
222
  public ReadOnlyBooleanProperty modifiedProperty() {
223
    return mModified;
224
  }
225
226
  @Override
227
  public void clearModifiedProperty() {
228
    mModified.setValue( false );
229
  }
230
231
  private Button createButton(
232
    final String msgKey, final EventHandler<ActionEvent> eventHandler ) {
233
    final var keyPrefix = Constants.ACTION_PREFIX + "definition." + msgKey;
234
    final var button = new Button( get( keyPrefix + ".text" ) );
235
    final var graphic = createGraphic( get( keyPrefix + ".icon" ) );
236
237
    button.setOnAction( eventHandler );
238
    button.setGraphic( graphic );
239
    button.setTooltip( new Tooltip( get( keyPrefix + ".tooltip" ) ) );
240
241
    return button;
242
  }
243
244
  /**
245
   * Informs the caller of whenever any {@link TreeItem} in the {@link TreeView}
246
   * is modified. The modifications include: item value changes, item additions,
247
   * and item removals.
248
   * <p>
249
   * Safe to call multiple times; if a handler is already registered, the
250
   * old handler is used.
251
   * </p>
252
   *
253
   * @param handler The handler to call whenever any {@link TreeItem} changes.
254
   */
255
  public void addTreeChangeHandler(
256
    final EventHandler<TreeItem.TreeModificationEvent<Event>> handler ) {
257
    final var root = getTreeView().getRoot();
258
    root.addEventHandler( valueChangedEvent(), handler );
259
    root.addEventHandler( childrenModificationEvent(), handler );
260
  }
261
262
  /**
263
   * Answers whether the {@link TreeItem}s in the {@link TreeView} are suitably
264
   * well-formed for export. A tree is considered well-formed if the following
265
   * conditions are met:
266
   *
267
   * <ul>
268
   *   <li>The root node contains at least one child node having a leaf.</li>
269
   *   <li>There are no leaf nodes with sibling leaf nodes.</li>
270
   * </ul>
271
   *
272
   * @return {@code null} if the document is well-formed, otherwise the
273
   * problematic child {@link TreeItem}.
274
   */
275
  public Optional<TreeItem<String>> isTreeWellFormed() {
276
    final var root = getTreeView().getRoot();
277
278
    for( final var child : root.getChildren() ) {
279
      final var problemChild = isWellFormed( child );
280
281
      if( child.isLeaf() || problemChild != null ) {
282
        return Optional.ofNullable( problemChild );
283
      }
284
    }
285
286
    return Optional.empty();
287
  }
288
289
  /**
290
   * Determines whether the document is well-formed by ensuring that
291
   * child branches do not contain multiple leaves.
292
   *
293
   * @param item The subtree to check for well-formedness.
294
   * @return {@code null} when the tree is well-formed, otherwise the
295
   * problematic {@link TreeItem}.
296
   */
297
  private TreeItem<String> isWellFormed( final TreeItem<String> item ) {
298
    int childLeafs = 0;
299
    int childBranches = 0;
300
301
    for( final var child : item.getChildren() ) {
302
      if( child.isLeaf() ) {
303
        childLeafs++;
304
      }
305
      else {
306
        childBranches++;
307
      }
308
309
      final var problemChild = isWellFormed( child );
310
311
      if( problemChild != null ) {
312
        return problemChild;
313
      }
314
    }
315
316
    return ((childBranches > 0 && childLeafs == 0) ||
317
      (childBranches == 0 && childLeafs <= 1)) ? null : item;
318
  }
319
320
  @Override
321
  public DefinitionTreeItem<String> findLeafExact( final String text ) {
322
    return getTreeRoot().findLeafExact( text );
323
  }
324
325
  @Override
326
  public DefinitionTreeItem<String> findLeafContains( final String text ) {
327
    return getTreeRoot().findLeafContains( text );
328
  }
329
330
  @Override
331
  public DefinitionTreeItem<String> findLeafContainsNoCase(
332
    final String text ) {
333
    return getTreeRoot().findLeafContainsNoCase( text );
334
  }
335
336
  @Override
337
  public DefinitionTreeItem<String> findLeafStartsWith( final String text ) {
338
    return getTreeRoot().findLeafStartsWith( text );
339
  }
340
341
  public void select( final TreeItem<String> item ) {
342
    getSelectionModel().clearSelection();
343
    getSelectionModel().select( getTreeView().getRow( item ) );
344
  }
345
346
  /**
347
   * Collapses the tree, recursively.
348
   */
349
  public void collapse() {
350
    collapse( getTreeRoot().getChildren() );
351
  }
352
353
  /**
354
   * Collapses the tree, recursively.
355
   *
356
   * @param <T>   The type of tree item to expand (usually String).
357
   * @param nodes The nodes to collapse.
358
   */
359
  private <T> void collapse( final ObservableList<TreeItem<T>> nodes ) {
360
    for( final var node : nodes ) {
361
      node.setExpanded( false );
362
      collapse( node.getChildren() );
363
    }
364
  }
365
366
  /**
367
   * @return {@code true} when the user is editing a {@link TreeItem}.
368
   */
369
  private boolean isEditingTreeItem() {
370
    return getTreeView().editingItemProperty().getValue() != null;
371
  }
372
373
  /**
374
   * Changes to edit mode for the selected item.
375
   */
376
  @Override
377
  public void renameDefinition() {
378
    getTreeView().edit( getSelectedItem() );
379
  }
380
381
  /**
382
   * Removes all selected items from the {@link TreeView}.
383
   */
384
  @Override
385
  public void deleteDefinitions() {
386
    for( final var item : getSelectedItems() ) {
387
      final var parent = item.getParent();
388
389
      if( parent != null ) {
390
        parent.getChildren().remove( item );
391
      }
392
    }
393
  }
394
395
  /**
396
   * Deletes the selected item.
397
   */
398
  private void deleteSelectedItem() {
399
    final var c = getSelectedItem();
400
    getSiblings( c ).remove( c );
401
  }
402
403
  private void insertSelectedItem() {
404
    if( getSelectedItem() instanceof DefinitionTreeItem<String> node ) {
405
      if( node.isLeaf() ) {
406
        InsertDefinitionEvent.fire( node );
407
      }
408
    }
409
  }
410
411
  /**
412
   * Adds a new item under the selected item (or root if nothing is selected).
413
   * There are a few conditions to consider: when adding to the root,
414
   * when adding to a leaf, and when adding to a non-leaf. Items added to the
415
   * root must contain two items: a key and a value.
416
   */
417
  @Override
418
  public void createDefinition() {
419
    final var value = createDefinitionTreeItem();
420
    getSelectedItem().getChildren().add( value );
421
    expand( value );
422
    select( value );
423
  }
424
425
  private ContextMenu createContextMenu() {
426
    final var menu = new ContextMenu();
427
    final var items = menu.getItems();
428
429
    addMenuItem( items, ACTION_PREFIX + "definition.create.text" )
430
      .setOnAction( e -> createDefinition() );
431
    addMenuItem( items, ACTION_PREFIX + "definition.rename.text" )
432
      .setOnAction( e -> renameDefinition() );
433
    addMenuItem( items, ACTION_PREFIX + "definition.delete.text" )
434
      .setOnAction( e -> deleteSelectedItem() );
435
    addMenuItem( items, ACTION_PREFIX + "definition.insert.text" )
436
      .setOnAction( e -> insertSelectedItem() );
437
438
    return menu;
439
  }
440
441
  /**
442
   * Executes hot-keys for edits to the definition tree.
443
   *
444
   * @param event Contains the key code of the key that was pressed.
445
   */
446
  private void keyEventFilter( final KeyEvent event ) {
447
    if( !isEditingTreeItem() ) {
448
      switch( event.getCode() ) {
449
        case ENTER -> {
450
          expand( getSelectedItem() );
451
          event.consume();
452
        }
453
454
        case DELETE -> deleteDefinitions();
455
        case INSERT -> createDefinition();
456
457
        case R -> {
458
          if( event.isControlDown() ) {
459
            renameDefinition();
460
          }
461
        }
462
463
        default -> { }
464
      }
465
466
      for( final var handler : getKeyEventHandlers() ) {
467
        handler.handle( event );
468
      }
469
    }
470
  }
471
472
  /**
473
   * Called when the editor's input focus changes. This will fire an event
474
   * for subscribers.
475
   *
476
   * @param ignored Not used.
477
   * @param o       The old input focus property value.
478
   * @param n       The new input focus property value.
479
   */
480
  private void focused(
481
    final ObservableValue<? extends Boolean> ignored,
482
    final Boolean o,
483
    final Boolean n ) {
484
    if( n != null && n ) {
485
      TextDefinitionFocusEvent.fire( this );
486
    }
487
  }
488
489
  /**
490
   * Adds a menu item to a list of menu items.
491
   *
492
   * @param items    The list of menu items to append to.
493
   * @param labelKey The resource bundle key name for the menu item's label.
494
   * @return The menu item added to the list of menu items.
495
   */
496
  private MenuItem addMenuItem(
497
    final List<MenuItem> items, final String labelKey ) {
498
    final MenuItem menuItem = createMenuItem( labelKey );
499
    items.add( menuItem );
500
    return menuItem;
501
  }
502
503
  private MenuItem createMenuItem( final String labelKey ) {
504
    return new MenuItem( get( labelKey ) );
505
  }
506
507
  /**
508
   * Creates a new {@link TreeItem} that is intended to be the root-level item
509
   * added to the {@link TreeView}. This allows the root item to be
510
   * distinguished from the other items so that reference keys do not include
511
   * "Definition" as part of their name.
512
   *
513
   * @return A new {@link TreeItem}, never {@code null}.
514
   */
515
  private RootTreeItem<String> createRootTreeItem() {
516
    return new RootTreeItem<>( get( "Pane.definition.node.root.title" ) );
517
  }
518
519
  private DefinitionTreeItem<String> createDefinitionTreeItem() {
520
    return new DefinitionTreeItem<>( get( "Definition.menu.add.default" ) );
521
  }
522
523
  @Override
524
  public void requestFocus() {
525
    getTreeView().requestFocus();
526
  }
527
528
  /**
529
   * Expands the node to the root, recursively.
530
   *
531
   * @param <T>  The type of tree item to expand (usually String).
532
   * @param node The node to expand.
533
   */
534
  @Override
535
  public <T> void expand( final TreeItem<T> node ) {
536
    if( node != null ) {
537
      expand( node.getParent() );
538
      node.setExpanded( !node.isLeaf() );
539
    }
540
  }
541
542
  /**
543
   * Answers whether there are any definitions in the tree.
544
   *
545
   * @return {@code true} when there are no definitions; {@code false} when
546
   * there's at least one definition.
547
   */
548
  @Override
549
  public boolean isEmpty() {
550
    return getTreeRoot().isEmpty();
551
  }
552
553
  /**
554
   * Returns the actively selected item in the tree.
555
   *
556
   * @return The selected item, or the tree root item if no item is selected.
557
   */
558
  public TreeItem<String> getSelectedItem() {
559
    final var item = getSelectionModel().getSelectedItem();
560
    return item == null ? getTreeRoot() : item;
561
  }
562
563
  /**
564
   * Returns the {@link TreeView} that contains the definition hierarchy.
565
   *
566
   * @return A non-null instance.
567
   */
568
  private TreeView<String> getTreeView() {
569
    return mTreeView;
570
  }
571
572
  /**
573
   * Returns the root of the tree.
574
   *
575
   * @return The first node added to the definition tree.
576
   */
577
  private DefinitionTreeItem<String> getTreeRoot() {
578
    return mTreeRoot;
579
  }
580
581
  private ObservableList<TreeItem<String>> getSiblings(
582
    final TreeItem<String> item ) {
583
    final var root = getTreeView().getRoot();
584
    final var parent = (item == null || item == root) ? root : item.getParent();
124
      createButton( "create", _ -> createDefinition() ),
125
      createButton( "rename", _ -> renameDefinition() ),
126
      createButton( "delete", _ -> deleteDefinitions() )
127
    );
128
    buttonBar.setAlignment( CENTER );
129
    buttonBar.setSpacing( UI_CONTROL_SPACING );
130
    setTop( buttonBar );
131
    setCenter( mTreeView );
132
    setAlignment( buttonBar, TOP_CENTER );
133
134
    mEncoding = open( mFile );
135
    updateDefinitions( getDefinitions(), getTreeView().getRoot() );
136
137
    // After the file is opened, watch for changes, not before. Otherwise,
138
    // upon saving, users will be prompted to save a file that hasn't had
139
    // any modifications (from their perspective).
140
    addTreeChangeHandler( _ -> {
141
      mModified.set( true );
142
      updateDefinitions( getDefinitions(), getTreeView().getRoot() );
143
    } );
144
  }
145
146
  /**
147
   * Replaces the given list of variable definitions with a flat hierarchy
148
   * of the converted {@link TreeView} root.
149
   *
150
   * @param definitions The definition map to update.
151
   * @param root        The values to flatten then insert into the map.
152
   */
153
  private void updateDefinitions(
154
    final Map<String, String> definitions,
155
    final TreeItem<String> root ) {
156
    definitions.clear();
157
    definitions.putAll( TreeItemMapper.convert( root ) );
158
    Engine.clear();
159
  }
160
161
  /**
162
   * Returns the variable definitions.
163
   *
164
   * @return The definition map.
165
   */
166
  @Override
167
  public Map<String, String> getDefinitions() {
168
    return mDefinitions;
169
  }
170
171
  @Override
172
  public void setText( final String document ) {
173
    final var foster = mTreeTransformer.transform( document );
174
    final var biological = getTreeRoot();
175
176
    for( final var child : foster.getChildren() ) {
177
      biological.getChildren().add( child );
178
    }
179
180
    getTreeView().refresh();
181
  }
182
183
  @Override
184
  public String getText() {
185
    final var result = new StringBuilder( 32768 );
186
187
    try {
188
      result.append( mTreeTransformer.transform( getTreeView().getRoot() ) );
189
190
      final var problem = isTreeWellFormed();
191
      problem.ifPresent( node -> clue( "yaml.error.tree.form", node ) );
192
    } catch( final Exception ex ) {
193
      // Catch errors while checking for a well-formed tree (e.g., stack smash).
194
      // Also catch any transformation exceptions (e.g., Json processing).
195
      clue( ex );
196
    }
197
198
    return result.toString();
199
  }
200
201
  @Override
202
  public File getFile() {
203
    return mFile;
204
  }
205
206
  @Override
207
  public void rename( final File file ) {
208
    mFile = file;
209
  }
210
211
  @Override
212
  public Charset getEncoding() {
213
    return mEncoding;
214
  }
215
216
  @Override
217
  public Node getNode() {
218
    return this;
219
  }
220
221
  @Override
222
  public ReadOnlyBooleanProperty modifiedProperty() {
223
    return mModified;
224
  }
225
226
  @Override
227
  public void clearModifiedProperty() {
228
    mModified.setValue( false );
229
  }
230
231
  private Button createButton(
232
    final String msgKey, final EventHandler<ActionEvent> eventHandler ) {
233
    final var keyPrefix = STR."\{Constants.ACTION_PREFIX}definition.\{msgKey}";
234
    final var button = new Button( get( STR."\{keyPrefix}.text" ) );
235
    final var graphic = createGraphic( get( STR."\{keyPrefix}.icon" ) );
236
237
    button.setOnAction( eventHandler );
238
    button.setGraphic( graphic );
239
    button.setTooltip( new Tooltip( get( STR."\{keyPrefix}.tooltip" ) ) );
240
241
    return button;
242
  }
243
244
  /**
245
   * Informs the caller of whenever any {@link TreeItem} in the {@link TreeView}
246
   * is modified. The modifications include: item value changes, item additions,
247
   * and item removals.
248
   * <p>
249
   * Safe to call multiple times; if a handler is already registered, the
250
   * old handler is used.
251
   * </p>
252
   *
253
   * @param handler The handler to call whenever any {@link TreeItem} changes.
254
   */
255
  public void addTreeChangeHandler(
256
    final EventHandler<TreeItem.TreeModificationEvent<Event>> handler ) {
257
    final var root = getTreeView().getRoot();
258
    root.addEventHandler( valueChangedEvent(), handler );
259
    root.addEventHandler( childrenModificationEvent(), handler );
260
  }
261
262
  /**
263
   * Answers whether the {@link TreeItem}s in the {@link TreeView} are suitably
264
   * well-formed for export. A tree is considered well-formed if the following
265
   * conditions are met:
266
   *
267
   * <ul>
268
   *   <li>The root node contains at least one child node having a leaf.</li>
269
   *   <li>There are no leaf nodes with sibling leaf nodes.</li>
270
   * </ul>
271
   *
272
   * @return {@code null} if the document is well-formed, otherwise the
273
   * problematic child {@link TreeItem}.
274
   */
275
  public Optional<TreeItem<String>> isTreeWellFormed() {
276
    final var root = getTreeView().getRoot();
277
278
    for( final var child : root.getChildren() ) {
279
      final var problemChild = isWellFormed( child );
280
281
      if( child.isLeaf() || problemChild != null ) {
282
        return Optional.ofNullable( problemChild );
283
      }
284
    }
285
286
    return Optional.empty();
287
  }
288
289
  /**
290
   * Determines whether the document is well-formed by ensuring that
291
   * child branches do not contain multiple leaves.
292
   *
293
   * @param item The subtree to check for well-formedness.
294
   * @return {@code null} when the tree is well-formed, otherwise the
295
   * problematic {@link TreeItem}.
296
   */
297
  private TreeItem<String> isWellFormed( final TreeItem<String> item ) {
298
    int childLeafs = 0;
299
    int childBranches = 0;
300
301
    for( final var child : item.getChildren() ) {
302
      if( child.isLeaf() ) {
303
        childLeafs++;
304
      }
305
      else {
306
        childBranches++;
307
      }
308
309
      final var problemChild = isWellFormed( child );
310
311
      if( problemChild != null ) {
312
        return problemChild;
313
      }
314
    }
315
316
    return ((childBranches > 0 && childLeafs == 0) ||
317
            (childBranches == 0 && childLeafs <= 1))
318
      ? null
319
      : item;
320
  }
321
322
  @Override
323
  public DefinitionTreeItem<String> findLeafExact( final String text ) {
324
    return getTreeRoot().findLeafExact( text );
325
  }
326
327
  @Override
328
  public DefinitionTreeItem<String> findLeafContains( final String text ) {
329
    return getTreeRoot().findLeafContains( text );
330
  }
331
332
  @Override
333
  public DefinitionTreeItem<String> findLeafContainsNoCase(
334
    final String text ) {
335
    return getTreeRoot().findLeafContainsNoCase( text );
336
  }
337
338
  @Override
339
  public DefinitionTreeItem<String> findLeafStartsWith( final String text ) {
340
    return getTreeRoot().findLeafStartsWith( text );
341
  }
342
343
  public void select( final TreeItem<String> item ) {
344
    getSelectionModel().clearSelection();
345
    getSelectionModel().select( getTreeView().getRow( item ) );
346
  }
347
348
  /**
349
   * Collapses the tree, recursively.
350
   */
351
  public void collapse() {
352
    collapse( getTreeRoot().getChildren() );
353
  }
354
355
  /**
356
   * Collapses the tree, recursively.
357
   *
358
   * @param <T>   The type of tree item to expand (usually String).
359
   * @param nodes The nodes to collapse.
360
   */
361
  private <T> void collapse( final ObservableList<TreeItem<T>> nodes ) {
362
    for( final var node : nodes ) {
363
      node.setExpanded( false );
364
      collapse( node.getChildren() );
365
    }
366
  }
367
368
  /**
369
   * @return {@code true} when the user is editing a {@link TreeItem}.
370
   */
371
  private boolean isEditingTreeItem() {
372
    return getTreeView().editingItemProperty().getValue() != null;
373
  }
374
375
  /**
376
   * Changes to edit mode for the selected item.
377
   */
378
  @Override
379
  public void renameDefinition() {
380
    getTreeView().edit( getSelectedItem() );
381
  }
382
383
  /**
384
   * Removes all selected items from the {@link TreeView}.
385
   */
386
  @Override
387
  public void deleteDefinitions() {
388
    for( final var item : getSelectedItems() ) {
389
      final var parent = item.getParent();
390
391
      if( parent != null ) {
392
        parent.getChildren().remove( item );
393
      }
394
    }
395
  }
396
397
  /**
398
   * Deletes the selected item.
399
   */
400
  private void deleteSelectedItem() {
401
    final var c = getSelectedItem();
402
    getSiblings( c ).remove( c );
403
  }
404
405
  private void insertSelectedItem() {
406
    if( getSelectedItem() instanceof DefinitionTreeItem<String> node ) {
407
      if( node.isLeaf() ) {
408
        InsertDefinitionEvent.fire( node );
409
      }
410
    }
411
  }
412
413
  /**
414
   * Adds a new item under the selected item (or root if nothing is selected).
415
   * There are a few conditions to consider: when adding to the root,
416
   * when adding to a leaf, and when adding to a non-leaf. Items added to the
417
   * root must contain two items: a key and a value.
418
   */
419
  @Override
420
  public void createDefinition() {
421
    final var value = createDefinitionTreeItem();
422
    getSelectedItem().getChildren().add( value );
423
    expand( value );
424
    select( value );
425
  }
426
427
  private ContextMenu createContextMenu() {
428
    final var menu = new ContextMenu();
429
    final var items = menu.getItems();
430
431
    addMenuItem( items, STR."\{ACTION_PREFIX}definition.create.text" )
432
      .setOnAction( _ -> createDefinition() );
433
    addMenuItem( items, STR."\{ACTION_PREFIX}definition.rename.text" )
434
      .setOnAction( _ -> renameDefinition() );
435
    addMenuItem( items, STR."\{ACTION_PREFIX}definition.delete.text" )
436
      .setOnAction( _ -> deleteSelectedItem() );
437
    addMenuItem( items, STR."\{ACTION_PREFIX}definition.insert.text" )
438
      .setOnAction( _ -> insertSelectedItem() );
439
440
    return menu;
441
  }
442
443
  /**
444
   * Executes hot-keys for edits to the definition tree.
445
   *
446
   * @param event Contains the key code of the key that was pressed.
447
   */
448
  private void keyEventFilter( final KeyEvent event ) {
449
    if( !isEditingTreeItem() ) {
450
      switch( event.getCode() ) {
451
        case ENTER -> {
452
          expand( getSelectedItem() );
453
          event.consume();
454
        }
455
456
        case DELETE -> deleteDefinitions();
457
        case INSERT -> createDefinition();
458
459
        case R -> {
460
          if( event.isControlDown() ) {
461
            renameDefinition();
462
          }
463
        }
464
465
        default -> {}
466
      }
467
468
      for( final var handler : getKeyEventHandlers() ) {
469
        handler.handle( event );
470
      }
471
    }
472
  }
473
474
  /**
475
   * Called when the editor's input focus changes. This will fire an event
476
   * for subscribers.
477
   *
478
   * @param ignored Not used.
479
   * @param o       The old input focus property value.
480
   * @param n       The new input focus property value.
481
   */
482
  private void focused(
483
    final ObservableValue<? extends Boolean> ignored,
484
    final Boolean o,
485
    final Boolean n ) {
486
    if( n != null && n ) {
487
      TextDefinitionFocusEvent.fire( this );
488
    }
489
  }
490
491
  /**
492
   * Adds a menu item to a list of menu items.
493
   *
494
   * @param items    The list of menu items to append to.
495
   * @param labelKey The resource bundle key name for the menu item's label.
496
   * @return The menu item added to the list of menu items.
497
   */
498
  private MenuItem addMenuItem(
499
    final List<MenuItem> items, final String labelKey ) {
500
    final MenuItem menuItem = createMenuItem( labelKey );
501
    items.add( menuItem );
502
    return menuItem;
503
  }
504
505
  private MenuItem createMenuItem( final String labelKey ) {
506
    return new MenuItem( get( labelKey ) );
507
  }
508
509
  /**
510
   * Creates a new {@link TreeItem} that is intended to be the root-level item
511
   * added to the {@link TreeView}. This allows the root item to be
512
   * distinguished from the other items so that reference keys do not include
513
   * "Definition" as part of their name.
514
   *
515
   * @return A new {@link TreeItem}, never {@code null}.
516
   */
517
  private RootTreeItem<String> createRootTreeItem() {
518
    return new RootTreeItem<>( get( "Pane.definition.node.root.title" ) );
519
  }
520
521
  private DefinitionTreeItem<String> createDefinitionTreeItem() {
522
    return new DefinitionTreeItem<>( get( "Definition.menu.add.default" ) );
523
  }
524
525
  @Override
526
  public void requestFocus() {
527
    getTreeView().requestFocus();
528
  }
529
530
  /**
531
   * Expands the node to the root, recursively.
532
   *
533
   * @param <T>  The type of tree item to expand (usually String).
534
   * @param node The node to expand.
535
   */
536
  @Override
537
  public <T> void expand( final TreeItem<T> node ) {
538
    if( node != null ) {
539
      expand( node.getParent() );
540
      node.setExpanded( !node.isLeaf() );
541
    }
542
  }
543
544
  /**
545
   * Answers whether there are any definitions in the tree.
546
   *
547
   * @return {@code true} when there are no definitions; {@code false} when
548
   * there's at least one definition.
549
   */
550
  @Override
551
  public boolean isEmpty() {
552
    return getTreeRoot().isEmpty();
553
  }
554
555
  /**
556
   * Returns the actively selected item in the tree.
557
   *
558
   * @return The selected item, or the tree root item if no item is selected.
559
   */
560
  public TreeItem<String> getSelectedItem() {
561
    final var item = getSelectionModel().getSelectedItem();
562
    return item == null ? getTreeRoot() : item;
563
  }
564
565
  /**
566
   * Returns the {@link TreeView} that contains the definition hierarchy.
567
   *
568
   * @return A non-null instance.
569
   */
570
  private TreeView<String> getTreeView() {
571
    return mTreeView;
572
  }
573
574
  /**
575
   * Returns the root of the tree.
576
   *
577
   * @return The first node added to the definition tree.
578
   */
579
  private DefinitionTreeItem<String> getTreeRoot() {
580
    return mTreeRoot;
581
  }
582
583
  private ObservableList<TreeItem<String>> getSiblings(
584
    final TreeItem<String> item ) {
585
    final var root = getTreeView().getRoot();
586
    final var parent = (item == null || item == root)
587
      ? root
588
      : item.getParent();
585589
586590
    return parent.getChildren();
D src/main/java/com/keenwrite/editors/markdown/HyperlinkModel.java
1
/* Copyright 2023 White Magic Software, Ltd. -- All rights reserved.
2
 *
3
 * SPDX-License-Identifier: MIT
4
 */
5
package com.keenwrite.editors.markdown;
6
7
import com.vladsch.flexmark.ast.Link;
8
9
/**
10
 * Represents the model for a hyperlink: text, url, and title.
11
 */
12
public final class HyperlinkModel {
13
14
  private String text;
15
  private String url;
16
  private String title;
17
18
  /**
19
   * Constructs a new hyperlink model in Markdown format by default with no
20
   * title (i.e., tooltip).
21
   *
22
   * @param text The hyperlink text displayed (e.g., displayed to the user).
23
   * @param url  The destination URL (e.g., when clicked).
24
   */
25
  public HyperlinkModel( final String text, final String url ) {
26
    this( text, url, null );
27
  }
28
29
  /**
30
   * Constructs a new hyperlink model for the given AST link.
31
   *
32
   * @param link A Markdown link.
33
   */
34
  public HyperlinkModel( final Link link ) {
35
    this(
36
      link.getText().toString(),
37
      link.getUrl().toString(),
38
      link.getTitle().toString()
39
    );
40
  }
41
42
  /**
43
   * Constructs a new hyperlink model in Markdown format by default.
44
   *
45
   * @param text  The hyperlink text displayed (e.g., displayed to the user).
46
   * @param url   The destination URL (e.g., when clicked).
47
   * @param title The hyperlink title (e.g., shown as a tooltip).
48
   */
49
  public HyperlinkModel(
50
    final String text, final String url, final String title ) {
51
    setText( text );
52
    setUrl( url );
53
    setTitle( title );
54
  }
55
56
  /**
57
   * Returns the string in Markdown format by default.
58
   *
59
   * @return A Markdown version of the hyperlink.
60
   */
61
  @Override
62
  public String toString() {
63
    String format = "%s%s%s";
64
65
    if( hasText() ) {
66
      format = "[%s]" + (hasTitle() ? "(%s \"%s\")" : "(%s%s)");
67
    }
68
69
    // Becomes ""+URL+"" if no text is set.
70
    // Becomes [TITLE]+(URL)+"" if no title is set.
71
    // Becomes [TITLE]+(URL+ \"TITLE\") if title is set.
72
    return String.format( format, getText(), getUrl(), getTitle() );
73
  }
74
75
  public void setText( final String text ) {
76
    this.text = sanitize( text );
77
  }
78
79
  public void setUrl( final String url ) {
80
    this.url = sanitize( url );
81
  }
82
83
  public void setTitle( final String title ) {
84
    this.title = sanitize( title );
85
  }
86
87
  /**
88
   * Answers whether text has been set for the hyperlink.
89
   *
90
   * @return true This is a text link.
91
   */
92
  public boolean hasText() {
93
    return !getText().isEmpty();
94
  }
95
96
  /**
97
   * Answers whether a title (tooltip) has been set for the hyperlink.
98
   *
99
   * @return true There is a title.
100
   */
101
  public boolean hasTitle() {
102
    return !getTitle().isEmpty();
103
  }
104
105
  public String getText() {
106
    return this.text;
107
  }
108
109
  public String getUrl() {
110
    return this.url;
111
  }
112
113
  public String getTitle() {
114
    return this.title;
115
  }
116
117
  private String sanitize( final String s ) {
118
    return s == null ? "" : s;
119
  }
120
}
1211
M src/main/java/com/keenwrite/editors/markdown/MarkdownEditor.java
4343
import static com.keenwrite.io.MediaType.TEXT_R_MARKDOWN;
4444
import static com.keenwrite.preferences.AppKeys.*;
45
import static java.lang.Character.isWhitespace;
46
import static java.lang.String.format;
47
import static java.util.Collections.singletonList;
48
import static javafx.application.Platform.runLater;
49
import static javafx.scene.control.ScrollPane.ScrollBarPolicy.ALWAYS;
50
import static javafx.scene.input.KeyCode.*;
51
import static javafx.scene.input.KeyCombination.*;
52
import static org.apache.commons.lang3.StringUtils.stripEnd;
53
import static org.apache.commons.lang3.StringUtils.stripStart;
54
import static org.fxmisc.richtext.Caret.CaretVisibility.ON;
55
import static org.fxmisc.richtext.model.StyleSpans.singleton;
56
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
57
import static org.fxmisc.wellbehaved.event.InputMap.consume;
58
59
/**
60
 * Responsible for editing Markdown documents.
61
 */
62
public final class MarkdownEditor extends BorderPane implements TextEditor {
63
  /**
64
   * Regular expression that matches the type of markup block. This is used
65
   * when Enter is pressed to continue the block environment.
66
   */
67
  private static final Pattern PATTERN_AUTO_INDENT = Pattern.compile(
68
    "(\\s*[*+-]\\s+|\\s*[0-9]+\\.\\s+|\\s+)(.*)" );
69
70
  private final Workspace mWorkspace;
71
72
  /**
73
   * The text editor.
74
   */
75
  private final StyleClassedTextArea mTextArea =
76
    new StyleClassedTextArea( false );
77
78
  /**
79
   * Wraps the text editor in scrollbars.
80
   */
81
  private final VirtualizedScrollPane<StyleClassedTextArea> mScrollPane =
82
    new VirtualizedScrollPane<>( mTextArea );
83
84
  /**
85
   * Tracks where the caret is located in this document. This offers observable
86
   * properties for caret position changes.
87
   */
88
  private final Caret mCaret = createCaret( mTextArea );
89
90
  /**
91
   * File being edited by this editor instance.
92
   */
93
  private File mFile;
94
95
  /**
96
   * Set to {@code true} upon text or caret position changes. Value is {@code
97
   * false} by default.
98
   */
99
  private final BooleanProperty mDirty = new SimpleBooleanProperty();
100
101
  /**
102
   * Opened file's character encoding, or {@link Constants#DEFAULT_CHARSET} if
103
   * either no encoding could be determined or this is a new (empty) file.
104
   */
105
  private final Charset mEncoding;
106
107
  /**
108
   * Tracks whether the in-memory definitions have changed with respect to the
109
   * persisted definitions.
110
   */
111
  private final BooleanProperty mModified = new SimpleBooleanProperty();
112
113
  public MarkdownEditor( final File file, final Workspace workspace ) {
114
    mEncoding = open( mFile = file );
115
    mWorkspace = workspace;
116
117
    initTextArea( mTextArea );
118
    initStyle( mTextArea );
119
    initScrollPane( mScrollPane );
120
    initHotKeys();
121
    initUndoManager();
122
  }
123
124
  @SuppressWarnings( "unused" )
125
  private void initTextArea( final StyleClassedTextArea textArea ) {
126
    textArea.setShowCaret( ON );
127
    textArea.setWrapText( true );
128
    textArea.requestFollowCaret();
129
    textArea.moveTo( 0 );
130
131
    textArea.textProperty().addListener( ( c, o, n ) -> {
132
      // Fire, regardless of whether the caret position has changed.
133
      mDirty.set( false );
134
135
      // Prevent the subsequent caret position change from raising dirty bits.
136
      mDirty.set( true );
137
    } );
138
139
    textArea.caretPositionProperty().addListener( ( c, o, n ) -> {
140
      // Fire when the caret position has changed and the text has not.
141
      mDirty.set( true );
142
      mDirty.set( false );
143
    } );
144
145
    textArea.focusedProperty().addListener( ( c, o, n ) -> {
146
      if( n != null && n ) {
147
        TextEditorFocusEvent.fire( this );
148
      }
149
    } );
150
  }
151
152
  @SuppressWarnings( "unused" )
153
  private void initStyle( final StyleClassedTextArea textArea ) {
154
    textArea.getStyleClass().add( "markdown" );
155
156
    final var stylesheets = textArea.getStylesheets();
157
    stylesheets.add( getStylesheetPath( getLocale() ) );
158
159
    localeProperty().addListener( ( c, o, n ) -> {
160
      if( n != null ) {
161
        stylesheets.clear();
162
        stylesheets.add( getStylesheetPath( getLocale() ) );
163
      }
164
    } );
165
166
    fontNameProperty().addListener(
167
      ( c, o, n ) ->
168
        setFont( mTextArea, getFontName(), getFontSize() )
169
    );
170
171
    fontSizeProperty().addListener(
172
      ( c, o, n ) ->
173
        setFont( mTextArea, getFontName(), getFontSize() )
174
    );
175
176
    setFont( mTextArea, getFontName(), getFontSize() );
177
  }
178
179
  private void initScrollPane(
180
    final VirtualizedScrollPane<StyleClassedTextArea> scrollpane ) {
181
    scrollpane.setVbarPolicy( ALWAYS );
182
    setCenter( scrollpane );
183
  }
184
185
  private void initHotKeys() {
186
    addEventListener( keyPressed( ENTER ), this::onEnterPressed );
187
    addEventListener( keyPressed( X, CONTROL_DOWN ), this::cut );
188
    addEventListener( keyPressed( TAB ), this::tab );
189
    addEventListener( keyPressed( TAB, SHIFT_DOWN ), this::untab );
190
  }
191
192
  private void initUndoManager() {
193
    final var undoManager = getUndoManager();
194
    final var markedPosition = undoManager.atMarkedPositionProperty();
195
196
    undoManager.forgetHistory();
197
    undoManager.mark();
198
    mModified.bind( Bindings.not( markedPosition ) );
199
  }
200
201
  @Override
202
  public void moveTo( final int offset ) {
203
    assert 0 <= offset && offset <= mTextArea.getLength();
204
205
    if( offset <= mTextArea.getLength() ) {
206
      mTextArea.moveTo( offset );
207
      mTextArea.requestFollowCaret();
208
    }
209
  }
210
211
  /**
212
   * Delegate the focus request to the text area itself.
213
   */
214
  @Override
215
  public void requestFocus() {
216
    mTextArea.requestFocus();
217
  }
218
219
  @Override
220
  public void setText( final String text ) {
221
    mTextArea.clear();
222
    mTextArea.appendText( text );
223
    mTextArea.getUndoManager().mark();
224
  }
225
226
  @Override
227
  public String getText() {
228
    return mTextArea.getText();
229
  }
230
231
  @Override
232
  public Charset getEncoding() {
233
    return mEncoding;
234
  }
235
236
  @Override
237
  public File getFile() {
238
    return mFile;
239
  }
240
241
  @Override
242
  public void rename( final File file ) {
243
    mFile = file;
244
  }
245
246
  @Override
247
  public void undo() {
248
    final var manager = getUndoManager();
249
    xxdo( manager::isUndoAvailable, manager::undo, "Main.status.error.undo" );
250
  }
251
252
  @Override
253
  public void redo() {
254
    final var manager = getUndoManager();
255
    xxdo( manager::isRedoAvailable, manager::redo, "Main.status.error.redo" );
256
  }
257
258
  /**
259
   * Performs an undo or redo action, if possible, otherwise displays an error
260
   * message to the user.
261
   *
262
   * @param ready  Answers whether the action can be executed.
263
   * @param action The action to execute.
264
   * @param key    The informational message key having a value to display if
265
   *               the {@link Supplier} is not ready.
266
   */
267
  private void xxdo(
268
    final Supplier<Boolean> ready, final Runnable action, final String key ) {
269
    if( ready.get() ) {
270
      action.run();
271
    }
272
    else {
273
      clue( key );
274
    }
275
  }
276
277
  @Override
278
  public void cut() {
279
    final var selected = mTextArea.getSelectedText();
280
281
    // Emulate selecting the current line by firing Home then Shift+Down Arrow.
282
    if( selected == null || selected.isEmpty() ) {
283
      // Note: mTextArea.selectLine() does not select empty lines.
284
      mTextArea.fireEvent( keyDown( HOME, false ) );
285
      mTextArea.fireEvent( keyDown( DOWN, true ) );
286
    }
287
288
    mTextArea.cut();
289
  }
290
291
  @Override
292
  public void copy() {
293
    mTextArea.copy();
294
  }
295
296
  @Override
297
  public void paste() {
298
    mTextArea.paste();
299
  }
300
301
  @Override
302
  public void selectAll() {
303
    mTextArea.selectAll();
304
  }
305
306
  @Override
307
  public void bold() {
308
    enwrap( "**" );
309
  }
310
311
  @Override
312
  public void italic() {
313
    enwrap( "*" );
314
  }
315
316
  @Override
317
  public void monospace() {
318
    enwrap( "`" );
319
  }
320
321
  @Override
322
  public void superscript() {
323
    enwrap( "^" );
324
  }
325
326
  @Override
327
  public void subscript() {
328
    enwrap( "~" );
329
  }
330
331
  @Override
332
  public void strikethrough() {
333
    enwrap( "~~" );
334
  }
335
336
  @Override
337
  public void blockquote() {
338
    block( "> " );
339
  }
340
341
  @Override
342
  public void code() {
343
    enwrap( "`" );
344
  }
345
346
  @Override
347
  public void fencedCodeBlock() {
348
    enwrap( "\n\n```\n", "\n```\n\n" );
349
  }
350
351
  @Override
352
  public void heading( final int level ) {
353
    final var hashes = new String( new char[ level ] ).replace( "\0", "#" );
354
    block( format( "%s ", hashes ) );
355
  }
356
357
  @Override
358
  public void unorderedList() {
359
    block( "* " );
360
  }
361
362
  @Override
363
  public void orderedList() {
364
    block( "1. " );
365
  }
366
367
  @Override
368
  public void horizontalRule() {
369
    block( format( "---%n%n" ) );
370
  }
371
372
  @Override
373
  public Node getNode() {
374
    return this;
375
  }
376
377
  @Override
378
  public ReadOnlyBooleanProperty modifiedProperty() {
379
    return mModified;
380
  }
381
382
  @Override
383
  public void clearModifiedProperty() {
384
    getUndoManager().mark();
385
  }
386
387
  @Override
388
  public VirtualizedScrollPane<StyleClassedTextArea> getScrollPane() {
389
    return mScrollPane;
390
  }
391
392
  @Override
393
  public StyleClassedTextArea getTextArea() {
394
    return mTextArea;
395
  }
396
397
  private final Map<String, IndexRange> mStyles = new HashMap<>();
398
399
  @Override
400
  public void stylize( final IndexRange range, final String style ) {
401
    final var began = range.getStart();
402
    final var ended = range.getEnd() + 1;
403
404
    assert 0 <= began && began <= ended;
405
    assert style != null;
406
407
    // TODO: Ensure spell check and find highlights can coexist.
408
//    final var spans = mTextArea.getStyleSpans( range );
409
//    System.out.println( "SPANS: " + spans );
410
411
//    final var spans = mTextArea.getStyleSpans( range );
412
//    mTextArea.setStyleSpans( began, merge( spans, range.getLength(), style
413
//    ) );
414
415
//    final var builder = new StyleSpansBuilder<Collection<String>>();
416
//    builder.add( singleton( style ), range.getLength() + 1 );
417
//    mTextArea.setStyleSpans( began, builder.create() );
418
419
//    final var s = mTextArea.getStyleSpans( began, ended );
420
//    System.out.println( "STYLES: " +s );
421
422
    mStyles.put( style, range );
423
    mTextArea.setStyleClass( began, ended, style );
424
425
    // Ensure that whenever the user interacts with the text that the found
426
    // word will have its highlighting removed. The handler removes itself.
427
    // This won't remove the highlighting if the caret position moves by mouse.
428
    final var handler = mTextArea.getOnKeyPressed();
429
    mTextArea.setOnKeyPressed( event -> {
430
      mTextArea.setOnKeyPressed( handler );
431
      unstylize( style );
432
    } );
433
434
    //mTextArea.setStyleSpans(began, ended, s);
435
  }
436
437
  private static StyleSpans<Collection<String>> merge(
438
    StyleSpans<Collection<String>> spans, int len, String style ) {
439
    spans = spans.overlay(
440
      singleton( singletonList( style ), len ),
441
      ( bottomSpan, list ) -> {
442
        final List<String> l =
443
          new ArrayList<>( bottomSpan.size() + list.size() );
444
        l.addAll( bottomSpan );
445
        l.addAll( list );
446
        return l;
447
      } );
448
449
    return spans;
450
  }
451
452
  @Override
453
  public void unstylize( final String style ) {
454
    final var indexes = mStyles.remove( style );
455
    if( indexes != null ) {
456
      mTextArea.clearStyle( indexes.getStart(), indexes.getEnd() + 1 );
457
    }
458
  }
459
460
  @Override
461
  public Caret getCaret() {
462
    return mCaret;
463
  }
464
465
  /**
466
   * A {@link Caret} instance is not directly coupled ot the GUI because
467
   * document processing does not always require interactive status bar
468
   * updates. This can happen when processing from the command-line. However,
469
   * the processors need the {@link Caret} instance to inject the caret
470
   * position into the document. Making the {@link CaretExtension} optional
471
   * would require more effort than using a {@link Caret} model that is
472
   * decoupled from GUI widgets.
473
   *
474
   * @param editor The text editor containing caret position information.
475
   * @return An instance of {@link Caret} that tracks the GUI caret position.
476
   */
477
  private Caret createCaret( final StyleClassedTextArea editor ) {
478
    return Caret
479
      .builder()
480
      .with( Caret.Mutator::setParagraph,
481
             () -> editor.currentParagraphProperty().getValue() )
482
      .with( Caret.Mutator::setParagraphs,
483
             () -> editor.getParagraphs().size() )
484
      .with( Caret.Mutator::setParaOffset,
485
             () -> editor.caretColumnProperty().getValue() )
486
      .with( Caret.Mutator::setTextOffset,
487
             () -> editor.caretPositionProperty().getValue() )
488
      .with( Caret.Mutator::setTextLength,
489
             () -> editor.lengthProperty().getValue() )
490
      .build();
491
  }
492
493
  /**
494
   * This method adds listeners to editor events.
495
   *
496
   * @param <T>      The event type.
497
   * @param <U>      The consumer type for the given event type.
498
   * @param event    The event of interest.
499
   * @param consumer The method to call when the event happens.
500
   */
501
  public <T extends Event, U extends T> void addEventListener(
502
    final EventPattern<? super T, ? extends U> event,
503
    final Consumer<? super U> consumer ) {
504
    Nodes.addInputMap( mTextArea, consume( event, consumer ) );
505
  }
506
507
  private void onEnterPressed( final KeyEvent ignored ) {
508
    final var currentLine = getCaretParagraph();
509
    final var matcher = PATTERN_AUTO_INDENT.matcher( currentLine );
510
511
    // By default, insert a new line by itself.
512
    String newText = NEWLINE;
513
514
    // If the pattern was matched then determine what block type to continue.
515
    if( matcher.matches() ) {
516
      if( matcher.group( 2 ).isEmpty() ) {
517
        final var pos = mTextArea.getCaretPosition();
518
        mTextArea.selectRange( pos - currentLine.length(), pos );
519
      }
520
      else {
521
        // Indent the new line with the same whitespace characters and
522
        // list markers as current line. This ensures that the indentation
523
        // is propagated.
524
        newText = newText.concat( matcher.group( 1 ) );
525
      }
526
    }
527
528
    mTextArea.replaceSelection( newText );
529
    mTextArea.requestFollowCaret();
530
  }
531
532
  private void cut( final KeyEvent event ) {
533
    cut();
534
  }
535
536
  private void tab( final KeyEvent event ) {
537
    final var range = mTextArea.selectionProperty().getValue();
538
    final var sb = new StringBuilder( 1024 );
539
540
    if( range.getLength() > 0 ) {
541
      final var selection = mTextArea.getSelectedText();
542
543
      selection.lines().forEach(
544
        l -> sb.append( "\t" ).append( l ).append( NEWLINE )
545
      );
546
    }
547
    else {
548
      sb.append( "\t" );
549
    }
550
551
    mTextArea.replaceSelection( sb.toString() );
552
  }
553
554
  private void untab( final KeyEvent event ) {
555
    final var range = mTextArea.selectionProperty().getValue();
556
557
    if( range.getLength() > 0 ) {
558
      final var selection = mTextArea.getSelectedText();
559
      final var sb = new StringBuilder( selection.length() );
560
561
      selection.lines().forEach(
562
        l -> sb.append( l.startsWith( "\t" ) ? l.substring( 1 ) : l )
563
               .append( NEWLINE )
564
      );
565
566
      mTextArea.replaceSelection( sb.toString() );
567
    }
568
    else {
569
      final var p = getCaretParagraph();
570
571
      if( p.startsWith( "\t" ) ) {
572
        mTextArea.selectParagraph();
573
        mTextArea.replaceSelection( p.substring( 1 ) );
574
      }
575
    }
576
  }
577
578
  /**
579
   * Observers may listen for changes to the property returned from this method
580
   * to receive notifications when either the text or caret have changed. This
581
   * should not be used to track whether the text has been modified.
582
   */
583
  public void addDirtyListener( ChangeListener<Boolean> listener ) {
584
    mDirty.addListener( listener );
585
  }
586
587
  /**
588
   * Surrounds the selected text or word under the caret in Markdown markup.
589
   *
590
   * @param token The beginning and ending token for enclosing the text.
591
   */
592
  private void enwrap( final String token ) {
593
    enwrap( token, token );
594
  }
595
596
  /**
597
   * Surrounds the selected text or word under the caret in Markdown markup.
598
   *
599
   * @param began The beginning token for enclosing the text.
600
   * @param ended The ending token for enclosing the text.
601
   */
602
  private void enwrap( final String began, String ended ) {
603
    // Ensure selected text takes precedence over the word at caret position.
604
    final var selected = mTextArea.selectionProperty().getValue();
605
    final var range = selected.getLength() == 0
606
      ? getCaretWord()
607
      : selected;
608
    String text = mTextArea.getText( range );
609
610
    int length = range.getLength();
611
    text = stripStart( text, null );
612
    final int beganIndex = range.getStart() + length - text.length();
613
614
    length = text.length();
615
    text = stripEnd( text, null );
616
    final int endedIndex = range.getEnd() - (length - text.length());
617
618
    mTextArea.replaceText( beganIndex, endedIndex, began + text + ended );
619
  }
620
621
  /**
622
   * Inserts the given block-level markup at the current caret position
623
   * within the document. This will prepend two blank lines to ensure that
624
   * the block element begins at the start of a new line.
625
   *
626
   * @param markup The text to insert at the caret.
627
   */
628
  private void block( final String markup ) {
629
    final int pos = mTextArea.getCaretPosition();
630
    mTextArea.insertText( pos, format( "%n%n%s", markup ) );
631
  }
632
633
  /**
634
   * Returns the caret position within the current paragraph.
635
   *
636
   * @return A value from 0 to the length of the current paragraph.
637
   */
638
  private int getCaretColumn() {
639
    return mTextArea.getCaretColumn();
640
  }
641
642
  @Override
643
  public IndexRange getCaretWord() {
644
    final var paragraph = getCaretParagraph()
645
      .replaceAll( "---", "   " )
646
      .replaceAll( "--", "  " )
647
      .replaceAll( "[\\[\\]{}()]", " " );
648
    final var length = paragraph.length();
649
    final var column = getCaretColumn();
650
651
    var began = column;
652
    var ended = column;
653
654
    while( began > 0 && !isWhitespace( paragraph.charAt( began - 1 ) ) ) {
655
      began--;
656
    }
657
658
    while( ended < length && !isWhitespace( paragraph.charAt( ended ) ) ) {
659
      ended++;
660
    }
661
662
    final var iterator = BreakIterator.getWordInstance();
663
    iterator.setText( paragraph );
664
665
    while( began < length && iterator.isBoundary( began + 1 ) ) {
666
      began++;
667
    }
668
669
    while( ended > 0 && iterator.isBoundary( ended - 1 ) ) {
670
      ended--;
671
    }
672
673
    final var offset = getCaretDocumentOffset( column );
674
675
    return IndexRange.normalize( began + offset, ended + offset );
676
  }
677
678
  private int getCaretDocumentOffset( final int column ) {
679
    return mTextArea.getCaretPosition() - column;
680
  }
681
682
  /**
683
   * Returns the index of the paragraph where the caret resides.
684
   *
685
   * @return A number greater than or equal to 0.
686
   */
687
  private int getCurrentParagraph() {
688
    return mTextArea.getCurrentParagraph();
689
  }
690
691
  /**
692
   * Returns the text for the paragraph that contains the caret.
693
   *
694
   * @return A non-null string, possibly empty.
695
   */
696
  private String getCaretParagraph() {
697
    return getText( getCurrentParagraph() );
698
  }
699
700
  @Override
701
  public String getText( final int paragraph ) {
702
    return mTextArea.getText( paragraph );
703
  }
704
705
  @Override
706
  public String getText( final IndexRange indexes )
707
    throws IndexOutOfBoundsException {
708
    return mTextArea.getText( indexes.getStart(), indexes.getEnd() );
709
  }
710
711
  @Override
712
  public void replaceText( final IndexRange indexes, final String s ) {
713
    mTextArea.replaceText( indexes, s );
714
  }
715
716
  private UndoManager<?> getUndoManager() {
717
    return mTextArea.getUndoManager();
718
  }
719
720
  /**
721
   * Returns the path to a {@link Locale}-specific stylesheet.
722
   *
723
   * @return A non-null string to inject into the HTML document head.
724
   */
725
  private static String getStylesheetPath( final Locale locale ) {
726
    return MessageFormat.format(
727
      sSettings.getSetting( STYLESHEET_MARKDOWN_LOCALE, "" ),
728
      locale.getLanguage(),
729
      locale.getScript(),
730
      locale.getCountry()
731
    );
732
  }
733
734
  private Locale getLocale() {
735
    return localeProperty().toLocale();
736
  }
737
738
  private LocaleProperty localeProperty() {
739
    return mWorkspace.localeProperty( KEY_LANGUAGE_LOCALE );
740
  }
741
742
  /**
743
   * Sets the font family name and font size at the same time. When the
744
   * workspace is loaded, the default font values are changed, which results
745
   * in this method being called.
746
   *
747
   * @param area   Change the font settings for this text area.
748
   * @param name   New font family name to apply.
749
   * @param points New font size to apply (in points, not pixels).
750
   */
751
  private void setFont(
752
    final StyleClassedTextArea area, final String name, final double points ) {
753
    runLater( () -> area.setStyle(
754
      format(
755
        "-fx-font-family:'%s';-fx-font-size:%spx;", name, toPixels( points )
756
      )
757
    ) );
758
  }
759
760
  private String getFontName() {
761
    return fontNameProperty().get();
762
  }
763
764
  private StringProperty fontNameProperty() {
765
    return mWorkspace.stringProperty( KEY_UI_FONT_EDITOR_NAME );
766
  }
767
768
  private double getFontSize() {
769
    return fontSizeProperty().get();
770
  }
771
772
  private DoubleProperty fontSizeProperty() {
773
    return mWorkspace.doubleProperty( KEY_UI_FONT_EDITOR_SIZE );
774
  }
775
776
  /**
777
   * Answers whether the given resource is of compatible {@link MediaType}s.
778
   *
779
   * @param mediaType The {@link MediaType} to compare.
780
   * @return {@code true} if the given {@link MediaType} is suitable for
781
   * editing with this type of editor.
782
   */
783
  @Override
784
  public boolean supports( final MediaType mediaType ) {
785
    return isMediaType( mediaType ) ||
786
      mediaType == TEXT_MARKDOWN ||
787
      mediaType == TEXT_R_MARKDOWN;
45
import static com.keenwrite.util.Strings.trimEnd;
46
import static com.keenwrite.util.Strings.trimStart;
47
import static java.lang.Character.isWhitespace;
48
import static java.lang.String.format;
49
import static java.util.Collections.singletonList;
50
import static javafx.application.Platform.runLater;
51
import static javafx.scene.control.ScrollPane.ScrollBarPolicy.ALWAYS;
52
import static javafx.scene.input.KeyCode.*;
53
import static javafx.scene.input.KeyCombination.CONTROL_DOWN;
54
import static javafx.scene.input.KeyCombination.SHIFT_DOWN;
55
import static org.fxmisc.richtext.Caret.CaretVisibility.ON;
56
import static org.fxmisc.richtext.model.StyleSpans.singleton;
57
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
58
import static org.fxmisc.wellbehaved.event.InputMap.consume;
59
60
/**
61
 * Responsible for editing Markdown documents.
62
 */
63
public final class MarkdownEditor extends BorderPane implements TextEditor {
64
  /**
65
   * Represents a failed index search.
66
   */
67
  private static final int INDEX_NOT_FOUND = -1;
68
69
  /**
70
   * Regular expression that matches the type of markup block. This is used
71
   * when Enter is pressed to continue the block environment.
72
   */
73
  private static final Pattern PATTERN_AUTO_INDENT = Pattern.compile(
74
    "(\\s*[*+-]\\s+|\\s*[0-9]+\\.\\s+|\\s+)(.*)" );
75
76
  private final Workspace mWorkspace;
77
78
  /**
79
   * The text editor.
80
   */
81
  private final StyleClassedTextArea mTextArea =
82
    new StyleClassedTextArea( false );
83
84
  /**
85
   * Wraps the text editor in scrollbars.
86
   */
87
  private final VirtualizedScrollPane<StyleClassedTextArea> mScrollPane =
88
    new VirtualizedScrollPane<>( mTextArea );
89
90
  /**
91
   * Tracks where the caret is located in this document. This offers observable
92
   * properties for caret position changes.
93
   */
94
  private final Caret mCaret = createCaret( mTextArea );
95
96
  /**
97
   * File being edited by this editor instance.
98
   */
99
  private File mFile;
100
101
  /**
102
   * Set to {@code true} upon text or caret position changes. Value is {@code
103
   * false} by default.
104
   */
105
  private final BooleanProperty mDirty = new SimpleBooleanProperty();
106
107
  /**
108
   * Opened file's character encoding, or {@link Constants#DEFAULT_CHARSET} if
109
   * either no encoding could be determined or this is a new (empty) file.
110
   */
111
  private final Charset mEncoding;
112
113
  /**
114
   * Tracks whether the in-memory definitions have changed with respect to the
115
   * persisted definitions.
116
   */
117
  private final BooleanProperty mModified = new SimpleBooleanProperty();
118
119
  public MarkdownEditor( final File file, final Workspace workspace ) {
120
    mEncoding = open( mFile = file );
121
    mWorkspace = workspace;
122
123
    initTextArea( mTextArea );
124
    initStyle( mTextArea );
125
    initScrollPane( mScrollPane );
126
    initHotKeys();
127
    initUndoManager();
128
  }
129
130
  @SuppressWarnings( "unused" )
131
  private void initTextArea( final StyleClassedTextArea textArea ) {
132
    textArea.setShowCaret( ON );
133
    textArea.setWrapText( true );
134
    textArea.requestFollowCaret();
135
    textArea.moveTo( 0 );
136
137
    textArea.textProperty().addListener( ( c, o, n ) -> {
138
      // Fire, regardless of whether the caret position has changed.
139
      mDirty.set( false );
140
141
      // Prevent the subsequent caret position change from raising dirty bits.
142
      mDirty.set( true );
143
    } );
144
145
    textArea.caretPositionProperty().addListener( ( c, o, n ) -> {
146
      // Fire when the caret position has changed and the text has not.
147
      mDirty.set( true );
148
      mDirty.set( false );
149
    } );
150
151
    textArea.focusedProperty().addListener( ( c, o, n ) -> {
152
      if( n != null && n ) {
153
        TextEditorFocusEvent.fire( this );
154
      }
155
    } );
156
  }
157
158
  @SuppressWarnings( "unused" )
159
  private void initStyle( final StyleClassedTextArea textArea ) {
160
    textArea.getStyleClass().add( "markdown" );
161
162
    final var stylesheets = textArea.getStylesheets();
163
    stylesheets.add( getStylesheetPath( getLocale() ) );
164
165
    localeProperty().addListener( ( c, o, n ) -> {
166
      if( n != null ) {
167
        stylesheets.clear();
168
        stylesheets.add( getStylesheetPath( getLocale() ) );
169
      }
170
    } );
171
172
    fontNameProperty().addListener(
173
      ( c, o, n ) ->
174
        setFont( mTextArea, getFontName(), getFontSize() )
175
    );
176
177
    fontSizeProperty().addListener(
178
      ( c, o, n ) ->
179
        setFont( mTextArea, getFontName(), getFontSize() )
180
    );
181
182
    setFont( mTextArea, getFontName(), getFontSize() );
183
  }
184
185
  private void initScrollPane(
186
    final VirtualizedScrollPane<StyleClassedTextArea> scrollpane ) {
187
    scrollpane.setVbarPolicy( ALWAYS );
188
    setCenter( scrollpane );
189
  }
190
191
  private void initHotKeys() {
192
    addEventListener( keyPressed( ENTER ), this::onEnterPressed );
193
    addEventListener( keyPressed( X, CONTROL_DOWN ), this::cut );
194
    addEventListener( keyPressed( TAB ), this::tab );
195
    addEventListener( keyPressed( TAB, SHIFT_DOWN ), this::untab );
196
  }
197
198
  private void initUndoManager() {
199
    final var undoManager = getUndoManager();
200
    final var markedPosition = undoManager.atMarkedPositionProperty();
201
202
    undoManager.forgetHistory();
203
    undoManager.mark();
204
    mModified.bind( Bindings.not( markedPosition ) );
205
  }
206
207
  @Override
208
  public void moveTo( final int offset ) {
209
    assert 0 <= offset && offset <= mTextArea.getLength();
210
211
    if( offset <= mTextArea.getLength() ) {
212
      mTextArea.moveTo( offset );
213
      mTextArea.requestFollowCaret();
214
    }
215
  }
216
217
  /**
218
   * Delegate the focus request to the text area itself.
219
   */
220
  @Override
221
  public void requestFocus() {
222
    mTextArea.requestFocus();
223
  }
224
225
  @Override
226
  public void setText( final String text ) {
227
    mTextArea.clear();
228
    mTextArea.appendText( text );
229
    mTextArea.getUndoManager().mark();
230
  }
231
232
  @Override
233
  public String getText() {
234
    return mTextArea.getText();
235
  }
236
237
  @Override
238
  public Charset getEncoding() {
239
    return mEncoding;
240
  }
241
242
  @Override
243
  public File getFile() {
244
    return mFile;
245
  }
246
247
  @Override
248
  public void rename( final File file ) {
249
    mFile = file;
250
  }
251
252
  @Override
253
  public void undo() {
254
    final var manager = getUndoManager();
255
    xxdo( manager::isUndoAvailable, manager::undo, "Main.status.error.undo" );
256
  }
257
258
  @Override
259
  public void redo() {
260
    final var manager = getUndoManager();
261
    xxdo( manager::isRedoAvailable, manager::redo, "Main.status.error.redo" );
262
  }
263
264
  /**
265
   * Performs an undo or redo action, if possible, otherwise displays an error
266
   * message to the user.
267
   *
268
   * @param ready  Answers whether the action can be executed.
269
   * @param action The action to execute.
270
   * @param key    The informational message key having a value to display if
271
   *               the {@link Supplier} is not ready.
272
   */
273
  private void xxdo(
274
    final Supplier<Boolean> ready, final Runnable action, final String key ) {
275
    if( ready.get() ) {
276
      action.run();
277
    }
278
    else {
279
      clue( key );
280
    }
281
  }
282
283
  @Override
284
  public void cut() {
285
    final var selected = mTextArea.getSelectedText();
286
287
    // Emulate selecting the current line by firing Home then Shift+Down Arrow.
288
    if( selected == null || selected.isEmpty() ) {
289
      // Note: mTextArea.selectLine() does not select empty lines.
290
      mTextArea.fireEvent( keyDown( HOME, false ) );
291
      mTextArea.fireEvent( keyDown( DOWN, true ) );
292
    }
293
294
    mTextArea.cut();
295
  }
296
297
  @Override
298
  public void copy() {
299
    mTextArea.copy();
300
  }
301
302
  @Override
303
  public void paste() {
304
    mTextArea.paste();
305
  }
306
307
  @Override
308
  public void selectAll() {
309
    mTextArea.selectAll();
310
  }
311
312
  @Override
313
  public void bold() {
314
    enwrap( "**" );
315
  }
316
317
  @Override
318
  public void italic() {
319
    enwrap( "*" );
320
  }
321
322
  @Override
323
  public void monospace() {
324
    enwrap( "`" );
325
  }
326
327
  @Override
328
  public void superscript() {
329
    enwrap( "^" );
330
  }
331
332
  @Override
333
  public void subscript() {
334
    enwrap( "~" );
335
  }
336
337
  @Override
338
  public void strikethrough() {
339
    enwrap( "~~" );
340
  }
341
342
  @Override
343
  public void blockquote() {
344
    block( "> " );
345
  }
346
347
  @Override
348
  public void code() {
349
    enwrap( "`" );
350
  }
351
352
  @Override
353
  public void fencedCodeBlock() {
354
    enwrap( "\n\n```\n", "\n```\n\n" );
355
  }
356
357
  @Override
358
  public void heading( final int level ) {
359
    final var hashes = new String( new char[ level ] ).replace( "\0", "#" );
360
    block( format( "%s ", hashes ) );
361
  }
362
363
  @Override
364
  public void unorderedList() {
365
    block( "* " );
366
  }
367
368
  @Override
369
  public void orderedList() {
370
    block( "1. " );
371
  }
372
373
  @Override
374
  public void horizontalRule() {
375
    block( format( "---%n%n" ) );
376
  }
377
378
  @Override
379
  public Node getNode() {
380
    return this;
381
  }
382
383
  @Override
384
  public ReadOnlyBooleanProperty modifiedProperty() {
385
    return mModified;
386
  }
387
388
  @Override
389
  public void clearModifiedProperty() {
390
    getUndoManager().mark();
391
  }
392
393
  @Override
394
  public VirtualizedScrollPane<StyleClassedTextArea> getScrollPane() {
395
    return mScrollPane;
396
  }
397
398
  @Override
399
  public StyleClassedTextArea getTextArea() {
400
    return mTextArea;
401
  }
402
403
  private final Map<String, IndexRange> mStyles = new HashMap<>();
404
405
  @Override
406
  public void stylize( final IndexRange range, final String style ) {
407
    final var began = range.getStart();
408
    final var ended = range.getEnd() + 1;
409
410
    assert 0 <= began && began <= ended;
411
    assert style != null;
412
413
    // TODO: Ensure spell check and find highlights can coexist.
414
//    final var spans = mTextArea.getStyleSpans( range );
415
//    System.out.println( "SPANS: " + spans );
416
417
//    final var spans = mTextArea.getStyleSpans( range );
418
//    mTextArea.setStyleSpans( began, merge( spans, range.getLength(), style
419
//    ) );
420
421
//    final var builder = new StyleSpansBuilder<Collection<String>>();
422
//    builder.add( singleton( style ), range.getLength() + 1 );
423
//    mTextArea.setStyleSpans( began, builder.create() );
424
425
//    final var s = mTextArea.getStyleSpans( began, ended );
426
//    System.out.println( "STYLES: " +s );
427
428
    mStyles.put( style, range );
429
    mTextArea.setStyleClass( began, ended, style );
430
431
    // Ensure that whenever the user interacts with the text that the found
432
    // word will have its highlighting removed. The handler removes itself.
433
    // This won't remove the highlighting if the caret position moves by mouse.
434
    final var handler = mTextArea.getOnKeyPressed();
435
    mTextArea.setOnKeyPressed( event -> {
436
      mTextArea.setOnKeyPressed( handler );
437
      unstylize( style );
438
    } );
439
440
    //mTextArea.setStyleSpans(began, ended, s);
441
  }
442
443
  private static StyleSpans<Collection<String>> merge(
444
    StyleSpans<Collection<String>> spans, int len, String style ) {
445
    spans = spans.overlay(
446
      singleton( singletonList( style ), len ),
447
      ( bottomSpan, list ) -> {
448
        final List<String> l =
449
          new ArrayList<>( bottomSpan.size() + list.size() );
450
        l.addAll( bottomSpan );
451
        l.addAll( list );
452
        return l;
453
      } );
454
455
    return spans;
456
  }
457
458
  @Override
459
  public void unstylize( final String style ) {
460
    final var indexes = mStyles.remove( style );
461
    if( indexes != null ) {
462
      mTextArea.clearStyle( indexes.getStart(), indexes.getEnd() + 1 );
463
    }
464
  }
465
466
  @Override
467
  public Caret getCaret() {
468
    return mCaret;
469
  }
470
471
  /**
472
   * A {@link Caret} instance is not directly coupled ot the GUI because
473
   * document processing does not always require interactive status bar
474
   * updates. This can happen when processing from the command-line. However,
475
   * the processors need the {@link Caret} instance to inject the caret
476
   * position into the document. Making the {@link CaretExtension} optional
477
   * would require more effort than using a {@link Caret} model that is
478
   * decoupled from GUI widgets.
479
   *
480
   * @param editor The text editor containing caret position information.
481
   * @return An instance of {@link Caret} that tracks the GUI caret position.
482
   */
483
  private Caret createCaret( final StyleClassedTextArea editor ) {
484
    return Caret
485
      .builder()
486
      .with( Caret.Mutator::setParagraph,
487
             () -> editor.currentParagraphProperty().getValue() )
488
      .with( Caret.Mutator::setParagraphs,
489
             () -> editor.getParagraphs().size() )
490
      .with( Caret.Mutator::setParaOffset,
491
             () -> editor.caretColumnProperty().getValue() )
492
      .with( Caret.Mutator::setTextOffset,
493
             () -> editor.caretPositionProperty().getValue() )
494
      .with( Caret.Mutator::setTextLength,
495
             () -> editor.lengthProperty().getValue() )
496
      .build();
497
  }
498
499
  /**
500
   * This method adds listeners to editor events.
501
   *
502
   * @param <T>      The event type.
503
   * @param <U>      The consumer type for the given event type.
504
   * @param event    The event of interest.
505
   * @param consumer The method to call when the event happens.
506
   */
507
  public <T extends Event, U extends T> void addEventListener(
508
    final EventPattern<? super T, ? extends U> event,
509
    final Consumer<? super U> consumer ) {
510
    Nodes.addInputMap( mTextArea, consume( event, consumer ) );
511
  }
512
513
  private void onEnterPressed( final KeyEvent ignored ) {
514
    final var currentLine = getCaretParagraph();
515
    final var matcher = PATTERN_AUTO_INDENT.matcher( currentLine );
516
517
    // By default, insert a new line by itself.
518
    String newText = NEWLINE;
519
520
    // If the pattern was matched then determine what block type to continue.
521
    if( matcher.matches() ) {
522
      if( matcher.group( 2 ).isEmpty() ) {
523
        final var pos = mTextArea.getCaretPosition();
524
        mTextArea.selectRange( pos - currentLine.length(), pos );
525
      }
526
      else {
527
        // Indent the new line with the same whitespace characters and
528
        // list markers as current line. This ensures that the indentation
529
        // is propagated.
530
        newText = newText.concat( matcher.group( 1 ) );
531
      }
532
    }
533
534
    mTextArea.replaceSelection( newText );
535
    mTextArea.requestFollowCaret();
536
  }
537
538
  private void cut( final KeyEvent event ) {
539
    cut();
540
  }
541
542
  private void tab( final KeyEvent event ) {
543
    final var range = mTextArea.selectionProperty().getValue();
544
    final var sb = new StringBuilder( 1024 );
545
546
    if( range.getLength() > 0 ) {
547
      final var selection = mTextArea.getSelectedText();
548
549
      selection.lines().forEach(
550
        l -> sb.append( "\t" ).append( l ).append( NEWLINE )
551
      );
552
    }
553
    else {
554
      sb.append( "\t" );
555
    }
556
557
    mTextArea.replaceSelection( sb.toString() );
558
  }
559
560
  private void untab( final KeyEvent event ) {
561
    final var range = mTextArea.selectionProperty().getValue();
562
563
    if( range.getLength() > 0 ) {
564
      final var selection = mTextArea.getSelectedText();
565
      final var sb = new StringBuilder( selection.length() );
566
567
      selection.lines().forEach(
568
        l -> sb.append( l.startsWith( "\t" ) ? l.substring( 1 ) : l )
569
               .append( NEWLINE )
570
      );
571
572
      mTextArea.replaceSelection( sb.toString() );
573
    }
574
    else {
575
      final var p = getCaretParagraph();
576
577
      if( p.startsWith( "\t" ) ) {
578
        mTextArea.selectParagraph();
579
        mTextArea.replaceSelection( p.substring( 1 ) );
580
      }
581
    }
582
  }
583
584
  /**
585
   * Observers may listen for changes to the property returned from this method
586
   * to receive notifications when either the text or caret have changed. This
587
   * should not be used to track whether the text has been modified.
588
   */
589
  public void addDirtyListener( ChangeListener<Boolean> listener ) {
590
    mDirty.addListener( listener );
591
  }
592
593
  /**
594
   * Surrounds the selected text or word under the caret in Markdown markup.
595
   *
596
   * @param token The beginning and ending token for enclosing the text.
597
   */
598
  private void enwrap( final String token ) {
599
    enwrap( token, token );
600
  }
601
602
  /**
603
   * Surrounds the selected text or word under the caret in Markdown markup.
604
   *
605
   * @param began The beginning token for enclosing the text.
606
   * @param ended The ending token for enclosing the text.
607
   */
608
  private void enwrap( final String began, String ended ) {
609
    // Ensure selected text takes precedence over the word at caret position.
610
    final var selected = mTextArea.selectionProperty().getValue();
611
    final var range = selected.getLength() == 0
612
      ? getCaretWord()
613
      : selected;
614
    String text = mTextArea.getText( range );
615
616
    int length = range.getLength();
617
    text = trimStart( text );
618
    final int beganIndex = range.getStart() + length - text.length();
619
620
    length = text.length();
621
    text = trimEnd( text );
622
    final int endedIndex = range.getEnd() - (length - text.length());
623
624
    mTextArea.replaceText( beganIndex, endedIndex, began + text + ended );
625
  }
626
627
  /**
628
   * Inserts the given block-level markup at the current caret position
629
   * within the document. This will prepend two blank lines to ensure that
630
   * the block element begins at the start of a new line.
631
   *
632
   * @param markup The text to insert at the caret.
633
   */
634
  private void block( final String markup ) {
635
    final int pos = mTextArea.getCaretPosition();
636
    mTextArea.insertText( pos, format( "%n%n%s", markup ) );
637
  }
638
639
  /**
640
   * Returns the caret position within the current paragraph.
641
   *
642
   * @return A value from 0 to the length of the current paragraph.
643
   */
644
  private int getCaretColumn() {
645
    return mTextArea.getCaretColumn();
646
  }
647
648
  @Override
649
  public IndexRange getCaretWord() {
650
    final var paragraph = getCaretParagraph()
651
      .replaceAll( "---", "   " )
652
      .replaceAll( "--", "  " )
653
      .replaceAll( "[\\[\\]{}()]", " " );
654
    final var length = paragraph.length();
655
    final var column = getCaretColumn();
656
657
    var began = column;
658
    var ended = column;
659
660
    while( began > 0 && !isWhitespace( paragraph.charAt( began - 1 ) ) ) {
661
      began--;
662
    }
663
664
    while( ended < length && !isWhitespace( paragraph.charAt( ended ) ) ) {
665
      ended++;
666
    }
667
668
    final var iterator = BreakIterator.getWordInstance();
669
    iterator.setText( paragraph );
670
671
    while( began < length && iterator.isBoundary( began + 1 ) ) {
672
      began++;
673
    }
674
675
    while( ended > 0 && iterator.isBoundary( ended - 1 ) ) {
676
      ended--;
677
    }
678
679
    final var offset = getCaretDocumentOffset( column );
680
681
    return IndexRange.normalize( began + offset, ended + offset );
682
  }
683
684
  private int getCaretDocumentOffset( final int column ) {
685
    return mTextArea.getCaretPosition() - column;
686
  }
687
688
  /**
689
   * Returns the index of the paragraph where the caret resides.
690
   *
691
   * @return A number greater than or equal to 0.
692
   */
693
  private int getCurrentParagraph() {
694
    return mTextArea.getCurrentParagraph();
695
  }
696
697
  /**
698
   * Returns the text for the paragraph that contains the caret.
699
   *
700
   * @return A non-null string, possibly empty.
701
   */
702
  private String getCaretParagraph() {
703
    return getText( getCurrentParagraph() );
704
  }
705
706
  @Override
707
  public String getText( final int paragraph ) {
708
    return mTextArea.getText( paragraph );
709
  }
710
711
  @Override
712
  public String getText( final IndexRange indexes )
713
    throws IndexOutOfBoundsException {
714
    return mTextArea.getText( indexes.getStart(), indexes.getEnd() );
715
  }
716
717
  @Override
718
  public void replaceText( final IndexRange indexes, final String s ) {
719
    mTextArea.replaceText( indexes, s );
720
  }
721
722
  private UndoManager<?> getUndoManager() {
723
    return mTextArea.getUndoManager();
724
  }
725
726
  /**
727
   * Returns the path to a {@link Locale}-specific stylesheet.
728
   *
729
   * @return A non-null string to inject into the HTML document head.
730
   */
731
  private static String getStylesheetPath( final Locale locale ) {
732
    return MessageFormat.format(
733
      sSettings.getSetting( STYLESHEET_MARKDOWN_LOCALE, "" ),
734
      locale.getLanguage(),
735
      locale.getScript(),
736
      locale.getCountry()
737
    );
738
  }
739
740
  private Locale getLocale() {
741
    return localeProperty().toLocale();
742
  }
743
744
  private LocaleProperty localeProperty() {
745
    return mWorkspace.localeProperty( KEY_LANGUAGE_LOCALE );
746
  }
747
748
  /**
749
   * Sets the font family name and font size at the same time. When the
750
   * workspace is loaded, the default font values are changed, which results
751
   * in this method being called.
752
   *
753
   * @param area   Change the font settings for this text area.
754
   * @param name   New font family name to apply.
755
   * @param points New font size to apply (in points, not pixels).
756
   */
757
  private void setFont(
758
    final StyleClassedTextArea area, final String name, final double points ) {
759
    runLater( () -> area.setStyle(
760
      format(
761
        "-fx-font-family:'%s';-fx-font-size:%spx;", name, toPixels( points )
762
      )
763
    ) );
764
  }
765
766
  private String getFontName() {
767
    return fontNameProperty().get();
768
  }
769
770
  private StringProperty fontNameProperty() {
771
    return mWorkspace.stringProperty( KEY_UI_FONT_EDITOR_NAME );
772
  }
773
774
  private double getFontSize() {
775
    return fontSizeProperty().get();
776
  }
777
778
  private DoubleProperty fontSizeProperty() {
779
    return mWorkspace.doubleProperty( KEY_UI_FONT_EDITOR_SIZE );
780
  }
781
782
  /**
783
   * Answers whether the given resource is of compatible {@link MediaType}s.
784
   *
785
   * @param mediaType The {@link MediaType} to compare.
786
   * @return {@code true} if the given {@link MediaType} is suitable for
787
   * editing with this type of editor.
788
   */
789
  @Override
790
  public boolean supports( final MediaType mediaType ) {
791
    return isMediaType( mediaType ) ||
792
           mediaType == TEXT_MARKDOWN ||
793
           mediaType == TEXT_R_MARKDOWN;
788794
  }
789795
}
M src/main/java/com/keenwrite/io/MediaTypeSniffer.java
1010
1111
import static com.keenwrite.io.MediaType.*;
12
import static java.lang.Math.min;
1213
import static java.lang.System.arraycopy;
1314
import static java.util.Arrays.fill;
...
108109
    };
109110
110
    for( int i = 0; i < Math.min( data.length, source.length ); i++ ) {
111
    final int length = min( data.length, source.length );
112
113
    for( int i = 0; i < length; i++ ) {
111114
      source[ i ] = data[ i ] & 0xFF;
112115
    }
M src/main/java/com/keenwrite/io/SysFile.java
1919
import static com.keenwrite.io.WindowsRegistry.pathsWindows;
2020
import static com.keenwrite.util.DataTypeConverter.toHex;
21
import static com.keenwrite.util.SystemUtils.IS_OS_WINDOWS;
2122
import static java.lang.System.getenv;
2223
import static java.nio.file.Files.isExecutable;
2324
import static java.util.regex.Pattern.quote;
24
import static org.apache.commons.lang3.SystemUtils.IS_OS_WINDOWS;
2525
2626
/**
...
3333
   */
3434
  private static final String[] EXTENSIONS = new String[]
35
    {"", ".exe", ".bat", ".cmd", ".msi", ".com"};
35
    { "", ".exe", ".bat", ".cmd", ".msi", ".com" };
3636
3737
  private static final String WHERE_COMMAND =
...
165165
  public Optional<Path> where() throws IOException {
166166
    // The "where" command on Windows will automatically add the extension.
167
    final var args = new String[]{WHERE_COMMAND, getName()};
168
    final var output = run( text -> true, args );
167
    final var args = new String[]{ WHERE_COMMAND, getName() };
168
    final var output = run( _ -> true, args );
169169
    final var result = output.lines().findFirst();
170170
...
235235
   */
236236
  @NotNull
237
  public static String run( final Predicate<String> filter,
238
                            final String[] args ) throws IOException {
237
  public static String run(
238
    final Predicate<String> filter,
239
    final String[] args ) throws IOException {
239240
    final var process = Runtime.getRuntime().exec( args );
240241
    final var stream = process.getInputStream();
M src/main/java/com/keenwrite/io/UserDataDir.java
88
99
import static com.keenwrite.io.SysFile.toFile;
10
import static com.keenwrite.util.SystemUtils.*;
1011
import static java.lang.System.getProperty;
1112
import static java.lang.System.getenv;
12
import static org.apache.commons.lang3.SystemUtils.*;
1313
1414
/**
M src/main/java/com/keenwrite/io/downloads/DownloadManager.java
77
import com.keenwrite.io.MediaType;
88
import com.keenwrite.io.MediaTypeSniffer;
9
10
import java.io.*;
11
import java.net.HttpURLConnection;
12
import java.net.URI;
13
import java.net.URISyntaxException;
14
import java.net.URL;
15
import java.time.Duration;
16
import java.util.zip.GZIPInputStream;
17
18
import static java.lang.Math.toIntExact;
19
import static java.lang.String.format;
20
import static java.lang.System.getProperty;
21
import static java.lang.System.setProperty;
22
import static java.net.HttpURLConnection.HTTP_OK;
23
import static java.net.HttpURLConnection.setFollowRedirects;
24
25
/**
26
 * Responsible for downloading files and publishing status updates. This will
27
 * download a resource provided by an instance of {@link URL} into a given
28
 * {@link OutputStream}.
29
 */
30
public final class DownloadManager {
31
  static {
32
    setProperty( "http.keepAlive", "false" );
33
    setFollowRedirects( true );
34
  }
35
36
  /**
37
   * Number of bytes to read at a time.
38
   */
39
  private static final int BUFFER_SIZE = 16384;
40
41
  /**
42
   * HTTP request timeout.
43
   */
44
  private static final Duration TIMEOUT = Duration.ofSeconds( 30 );
45
46
  @FunctionalInterface
47
  public interface ProgressListener {
48
    /**
49
     * Called when a chunk of data has been read. This is called synchronously
50
     * when downloading the data; do not execute long-running tasks in this
51
     * method (a few milliseconds is fine).
52
     *
53
     * @param percentage A value between 0 and 100, inclusive, represents the
54
     *                   percentage of bytes downloaded relative to the total.
55
     *                   A value of -1 means the total number of bytes to
56
     *                   download is unknown.
57
     * @param bytes      When {@code percentage} is greater than or equal to
58
     *                   zero, this is the total number of bytes. When {@code
59
     *                   percentage} equals -1, this is the number of bytes
60
     *                   read so far.
61
     */
62
    void update( int percentage, long bytes );
63
  }
64
65
  /**
66
   * Callers may check the value of isSuccessful
67
   */
68
  public static final class DownloadToken implements Closeable {
69
    private final HttpURLConnection mConn;
70
    private final BufferedInputStream mInput;
71
    private final MediaType mMediaType;
72
    private final long mBytesTotal;
73
74
    private DownloadToken(
75
      final HttpURLConnection conn,
76
      final BufferedInputStream input,
77
      final MediaType mediaType
78
    ) {
79
      assert conn != null;
80
      assert input != null;
81
      assert mediaType != null;
82
83
      mConn = conn;
84
      mInput = input;
85
      mMediaType = mediaType;
86
      mBytesTotal = conn.getContentLength();
87
    }
88
89
    /**
90
     * Provides the ability to download remote files asynchronously while
91
     * being updated regarding the download progress. The given
92
     * {@link OutputStream} will be closed after downloading is complete.
93
     *
94
     * @param file   Where to write the file contents.
95
     * @param listener Receives download progress status updates.
96
     * @return A {@link Runnable} task that can be executed in the background
97
     * to download the resource for this {@link DownloadToken}.
98
     */
99
    public Runnable download(
100
      final File file,
101
      final ProgressListener listener ) {
102
      return () -> {
103
        final var buffer = new byte[ BUFFER_SIZE ];
104
        final var stream = getInputStream();
105
        final var bytesTotal = mBytesTotal;
106
107
        long bytesTally = 0;
108
        int bytesRead;
109
110
        try( final var output = new FileOutputStream( file ) ) {
111
          while( (bytesRead = stream.read( buffer )) != -1 ) {
112
            if( Thread.currentThread().isInterrupted() ) {
113
              throw new InterruptedException();
114
            }
115
116
            bytesTally += bytesRead;
117
118
            if( bytesTotal > 0 ) {
119
              listener.update(
120
                toIntExact( bytesTally * 100 / bytesTotal ),
121
                bytesTotal
122
              );
123
            }
124
            else {
125
              listener.update( -1, bytesRead );
126
            }
127
128
            output.write( buffer, 0, bytesRead );
129
          }
130
        } catch( final Exception ex ) {
131
          throw new RuntimeException( ex );
132
        } finally {
133
          close();
134
        }
135
      };
136
    }
137
138
    public void close() {
139
      try {
140
        getInputStream().close();
141
      } catch( final Exception ignored ) {
142
      } finally {
143
        mConn.disconnect();
144
      }
145
    }
146
147
    /**
148
     * Returns the input stream to the resource to download.
149
     *
150
     * @return The stream to read.
151
     */
152
    public BufferedInputStream getInputStream() {
153
      return mInput;
154
    }
155
156
    public MediaType getMediaType() {
157
      return mMediaType;
158
    }
159
160
    /**
161
     * Answers whether the type of content associated with the download stream
162
     * is a scalable vector graphic.
163
     *
164
     * @return {@code true} if the given {@link MediaType} has SVG contents.
165
     */
166
    public boolean isSvg() {
167
      return getMediaType().isSvg();
168
    }
169
  }
170
171
  /**
172
   * Opens the input stream for the resource to download.
173
   *
174
   * @param uri The {@link URI} resource to download.
175
   * @return A token that can be used for downloading the content with
176
   * periodic updates or retrieving the stream for downloading the content.
177
   * @throws IOException        The stream could not be opened.
178
   * @throws URISyntaxException Invalid URI.
179
   */
180
  public static DownloadToken open( final String uri )
181
    throws IOException, URISyntaxException {
182
    // Pass an undefined media type so that any type of file can be retrieved.
183
    return open( new URI( uri ) );
184
  }
185
186
  public static DownloadToken open( final URI uri )
187
    throws IOException {
188
    return open( uri.toURL() );
189
  }
190
191
  /**
192
   * Opens the input stream for the resource to download and verifies that
193
   * the given {@link MediaType} matches the requested type. Callers are
194
   * responsible for closing the {@link DownloadManager} to close the
195
   * underlying stream and the HTTP connection. Connections must be closed by
196
   * callers if {@link DownloadToken#download(File, ProgressListener)}
197
   * isn't called (i.e., {@link DownloadToken#getMediaType()} is called
198
   * after the transport layer's Content-Type is requested but not contents
199
   * are downloaded).
200
   *
201
   * @param url The {@link URL} resource to download.
202
   * @return A token that can be used for downloading the content with
203
   * periodic updates or retrieving the stream for downloading the content.
204
   * @throws IOException The resource could not be downloaded.
205
   */
206
  public static DownloadToken open( final URL url ) throws IOException {
207
    final var conn = connect( url );
208
    final var contentType = conn.getContentType();
209
210
    MediaType remoteType;
211
212
    try {
213
      remoteType = MediaType.valueFrom( contentType );
214
    } catch( final Exception ex ) {
215
      // If the media type couldn't be detected, try using the stream.
216
      remoteType = MediaType.UNDEFINED;
217
    }
218
219
    final var input = open( conn );
220
221
    // Peek at the magic header bytes to determine the media type.
222
    final var magicType = MediaTypeSniffer.getMediaType( input );
223
224
    // If the transport protocol's Content-Type doesn't align with the
225
    // media type for the magic header, defer to the transport protocol (so
226
    // long as the content type was sent from the remote side).
227
    final MediaType mediaType = remoteType.equals( magicType )
228
      ? remoteType
229
      : contentType != null && !contentType.isBlank()
230
      ? remoteType
231
      : magicType.isUndefined()
232
      ? remoteType
233
      : magicType;
234
235
    return new DownloadToken( conn, input, mediaType );
236
  }
237
238
  /**
239
   * Establishes a connection to the remote {@link URL} resource.
240
   *
241
   * @param url The {@link URL} representing a resource to download.
242
   * @return The connection manager for the {@link URL}.
243
   * @throws IOException         Could not establish a connection.
244
   * @throws ArithmeticException Could not compute a timeout value (this
245
   *                             should never happen because the timeout is
246
   *                             less than a minute).
247
   * @see #TIMEOUT
248
   */
249
  private static HttpURLConnection connect( final URL url )
250
    throws IOException, ArithmeticException {
251
    // Both HTTP and HTTPS are covered by this condition.
252
    if( url.openConnection() instanceof HttpURLConnection conn ) {
253
      conn.setUseCaches( false );
254
      conn.setInstanceFollowRedirects( true );
255
      conn.setRequestProperty( "Accept-Encoding", "gzip" );
256
      conn.setRequestProperty( "User-Agent", getProperty( "http.agent" ) );
257
      conn.setRequestMethod( "GET" );
258
      conn.setConnectTimeout( toIntExact( TIMEOUT.toMillis() ) );
259
      conn.setRequestProperty( "connection", "close" );
260
      conn.connect();
261
262
      final var code = conn.getResponseCode();
263
264
      if( code != HTTP_OK ) {
265
        final var message = format(
266
          "%s [HTTP %d: %s]",
267
          url.getFile(),
268
          code,
269
          conn.getResponseMessage()
270
        );
271
272
        throw new IOException( message );
273
      }
274
275
      return conn;
276
    }
277
278
    throw new UnsupportedOperationException( url.toString() );
279
  }
280
281
  /**
282
   * Returns a stream in an open state. Callers are responsible for closing.
283
   *
284
   * @param conn The connection to open, which could be compressed.
285
   * @return The open stream.
286
   * @throws IOException Could not open the stream.
287
   */
288
  private static BufferedInputStream open( final HttpURLConnection conn )
289
    throws IOException {
290
    return open( conn.getContentEncoding(), conn.getInputStream() );
291
  }
292
293
  /**
294
   * Returns a stream in an open state. Callers are responsible for closing.
295
   * The input stream may be compressed.
296
   *
297
   * @param encoding The content encoding for the stream.
298
   * @param is       The stream to wrap with a suitable decoder.
299
   * @return The open stream, with any gzip content-encoding decoded.
300
   * @throws IOException Could not open the stream.
301
   */
302
  private static BufferedInputStream open(
303
    final String encoding, final InputStream is ) throws IOException {
304
    return new BufferedInputStream(
305
      "gzip".equalsIgnoreCase( encoding )
306
        ? new GZIPInputStream( is )
307
        : is
308
    );
9
import com.keenwrite.io.SysFile;
10
import javafx.concurrent.Task;
11
12
import java.io.*;
13
import java.net.HttpURLConnection;
14
import java.net.URI;
15
import java.net.URISyntaxException;
16
import java.net.URL;
17
import java.nio.file.Paths;
18
import java.time.Duration;
19
import java.util.concurrent.Callable;
20
import java.util.zip.GZIPInputStream;
21
22
import static java.lang.Math.toIntExact;
23
import static java.lang.String.format;
24
import static java.lang.System.getProperty;
25
import static java.lang.System.setProperty;
26
import static java.net.HttpURLConnection.HTTP_OK;
27
import static java.net.HttpURLConnection.setFollowRedirects;
28
29
/**
30
 * Responsible for downloading files and publishing status updates. This will
31
 * download a resource provided by an instance of {@link URL} into a given
32
 * {@link OutputStream}.
33
 */
34
public final class DownloadManager {
35
  static {
36
    setProperty( "http.keepAlive", "false" );
37
    setFollowRedirects( true );
38
  }
39
40
  /**
41
   * Number of bytes to read at a time.
42
   */
43
  private static final int BUFFER_SIZE = 16384;
44
45
  /**
46
   * HTTP request timeout.
47
   */
48
  private static final Duration TIMEOUT = Duration.ofSeconds( 30 );
49
50
  /**
51
   * Use any of the static methods for opening by URI, URL, or string.
52
   */
53
  private DownloadManager() {}
54
55
  @FunctionalInterface
56
  public interface ProgressListener {
57
    /**
58
     * Called when a chunk of data has been read. This is called synchronously
59
     * when downloading the data; do not execute long-running tasks in this
60
     * method (a few milliseconds is fine).
61
     *
62
     * @param percentage A value between 0 and 100, inclusive, represents the
63
     *                   percentage of bytes downloaded relative to the total.
64
     *                   A value of -1 means the total number of bytes to
65
     *                   download is unknown.
66
     * @param bytes      When {@code percentage} is greater than or equal to
67
     *                   zero, this is the total number of bytes. When {@code
68
     *                   percentage} equals -1, this is the number of bytes
69
     *                   read so far.
70
     */
71
    void update( int percentage, long bytes );
72
  }
73
74
  /**
75
   * Callers may check the value of isSuccessful
76
   */
77
  public static final class DownloadToken implements Closeable {
78
    private final HttpURLConnection mConn;
79
    private final BufferedInputStream mInput;
80
    private final MediaType mMediaType;
81
    private final long mBytesTotal;
82
83
    private DownloadToken(
84
      final HttpURLConnection conn,
85
      final BufferedInputStream input,
86
      final MediaType mediaType
87
    ) {
88
      assert conn != null;
89
      assert input != null;
90
      assert mediaType != null;
91
92
      mConn = conn;
93
      mInput = input;
94
      mMediaType = mediaType;
95
      mBytesTotal = conn.getContentLength();
96
    }
97
98
    /**
99
     * Provides the ability to download remote files asynchronously while
100
     * being updated regarding the download progress. The given {@link File}
101
     * will have the contents of the URL to download upon completion.
102
     *
103
     * @param file     Where to write the file contents.
104
     * @param listener Receives download progress status updates.
105
     * @return A {@link Runnable} task that can be executed in the background
106
     * to download the resource for this {@link DownloadToken}.
107
     */
108
    public Runnable download(
109
      final File file,
110
      final ProgressListener listener ) {
111
      return () -> {
112
        final var buffer = new byte[ BUFFER_SIZE ];
113
        final var stream = getInputStream();
114
        final var bytesTotal = mBytesTotal;
115
116
        long bytesTally = 0;
117
        int bytesRead;
118
119
        try( final var output = new FileOutputStream( file ) ) {
120
          while( (bytesRead = stream.read( buffer )) != -1 ) {
121
            if( Thread.currentThread().isInterrupted() ) {
122
              throw new InterruptedException();
123
            }
124
125
            bytesTally += bytesRead;
126
127
            if( bytesTotal > 0 ) {
128
              listener.update(
129
                toIntExact( bytesTally * 100 / bytesTotal ),
130
                bytesTotal
131
              );
132
            }
133
            else {
134
              listener.update( -1, bytesRead );
135
            }
136
137
            output.write( buffer, 0, bytesRead );
138
          }
139
        } catch( final Exception ex ) {
140
          throw new RuntimeException( ex );
141
        } finally {
142
          close();
143
        }
144
      };
145
    }
146
147
    public void close() {
148
      try {
149
        getInputStream().close();
150
      } catch( final Exception ignored ) {
151
      } finally {
152
        mConn.disconnect();
153
      }
154
    }
155
156
    /**
157
     * Returns the input stream to the resource to download.
158
     *
159
     * @return The stream to read.
160
     */
161
    public BufferedInputStream getInputStream() {
162
      return mInput;
163
    }
164
165
    public MediaType getMediaType() {
166
      return mMediaType;
167
    }
168
169
    /**
170
     * Answers whether the type of content associated with the download stream
171
     * is a scalable vector graphic.
172
     *
173
     * @return {@code true} if the given {@link MediaType} has SVG contents.
174
     */
175
    public boolean isSvg() {
176
      return getMediaType().isSvg();
177
    }
178
  }
179
180
  /**
181
   * Opens the input stream for the resource to download.
182
   *
183
   * @param uri The {@link URI} resource to download.
184
   * @return A token that can be used for downloading the content with
185
   * periodic updates or retrieving the stream for downloading the content.
186
   * @throws IOException        The stream could not be opened.
187
   * @throws URISyntaxException Invalid URI.
188
   */
189
  public static DownloadToken open( final String uri )
190
    throws IOException, URISyntaxException {
191
    // Pass an undefined media type so that any type of file can be retrieved.
192
    return open( new URI( uri ) );
193
  }
194
195
  public static DownloadToken open( final URI uri )
196
    throws IOException {
197
    return open( uri.toURL() );
198
  }
199
200
  /**
201
   * Opens the input stream for the resource to download and verifies that
202
   * the given {@link MediaType} matches the requested type. Callers are
203
   * responsible for closing the {@link DownloadManager} to close the
204
   * underlying stream and the HTTP connection. Connections must be closed by
205
   * callers if {@link DownloadToken#download(File, ProgressListener)}
206
   * isn't called (i.e., {@link DownloadToken#getMediaType()} is called
207
   * after the transport layer's Content-Type is requested but not contents
208
   * are downloaded).
209
   *
210
   * @param url The {@link URL} resource to download.
211
   * @return A token that can be used for downloading the content with
212
   * periodic updates or retrieving the stream for downloading the content.
213
   * @throws IOException The resource could not be downloaded.
214
   */
215
  public static DownloadToken open( final URL url ) throws IOException {
216
    final var conn = connect( url );
217
    final var contentType = conn.getContentType();
218
219
    MediaType remoteType;
220
221
    try {
222
      remoteType = MediaType.valueFrom( contentType );
223
    } catch( final Exception ex ) {
224
      // If the media type couldn't be detected, try using the stream.
225
      remoteType = MediaType.UNDEFINED;
226
    }
227
228
    final var input = open( conn );
229
230
    // Peek at the magic header bytes to determine the media type.
231
    final var magicType = MediaTypeSniffer.getMediaType( input );
232
233
    // If the transport protocol's Content-Type doesn't align with the
234
    // media type for the magic header, defer to the transport protocol (so
235
    // long as the content type was sent from the remote side).
236
    final MediaType mediaType = remoteType.equals( magicType )
237
      ? remoteType
238
      : contentType != null && !contentType.isBlank()
239
      ? remoteType
240
      : magicType.isUndefined()
241
      ? remoteType
242
      : magicType;
243
244
    return new DownloadToken( conn, input, mediaType );
245
  }
246
247
  /**
248
   * Establishes a connection to the remote {@link URL} resource.
249
   *
250
   * @param url The {@link URL} representing a resource to download.
251
   * @return The connection manager for the {@link URL}.
252
   * @throws IOException         Could not establish a connection.
253
   * @throws ArithmeticException Could not compute a timeout value (this
254
   *                             should never happen because the timeout is
255
   *                             less than a minute).
256
   * @see #TIMEOUT
257
   */
258
  private static HttpURLConnection connect( final URL url )
259
    throws IOException, ArithmeticException {
260
    // Both HTTP and HTTPS are covered by this condition.
261
    if( url.openConnection() instanceof HttpURLConnection conn ) {
262
      conn.setUseCaches( false );
263
      conn.setInstanceFollowRedirects( true );
264
      conn.setRequestProperty( "Accept-Encoding", "gzip" );
265
      conn.setRequestProperty( "User-Agent", getProperty( "http.agent" ) );
266
      conn.setRequestMethod( "GET" );
267
      conn.setConnectTimeout( toIntExact( TIMEOUT.toMillis() ) );
268
      conn.setRequestProperty( "connection", "close" );
269
      conn.connect();
270
271
      final var code = conn.getResponseCode();
272
273
      if( code != HTTP_OK ) {
274
        final var message = format(
275
          "%s [HTTP %d: %s]",
276
          url.getFile(),
277
          code,
278
          conn.getResponseMessage()
279
        );
280
281
        throw new IOException( message );
282
      }
283
284
      return conn;
285
    }
286
287
    throw new UnsupportedOperationException( url.toString() );
288
  }
289
290
  /**
291
   * Returns a stream in an open state. Callers are responsible for closing.
292
   *
293
   * @param conn The connection to open, which could be compressed.
294
   * @return The open stream.
295
   * @throws IOException Could not open the stream.
296
   */
297
  private static BufferedInputStream open( final HttpURLConnection conn )
298
    throws IOException {
299
    return open( conn.getContentEncoding(), conn.getInputStream() );
300
  }
301
302
  /**
303
   * Returns a stream in an open state. Callers are responsible for closing.
304
   * The input stream may be compressed.
305
   *
306
   * @param encoding The content encoding for the stream.
307
   * @param is       The stream to wrap with a suitable decoder.
308
   * @return The open stream, with any gzip content-encoding decoded.
309
   * @throws IOException Could not open the stream.
310
   */
311
  private static BufferedInputStream open(
312
    final String encoding, final InputStream is ) throws IOException {
313
    return new BufferedInputStream(
314
      "gzip".equalsIgnoreCase( encoding )
315
        ? new GZIPInputStream( is )
316
        : is
317
    );
318
  }
319
320
  public static <T> Task<T> createTask( final Callable<T> callable ) {
321
    return new Task<>() {
322
      @Override
323
      protected T call() throws Exception {
324
        return callable.call();
325
      }
326
    };
327
  }
328
329
  public static <T> Thread createThread( final Task<T> task ) {
330
    final var thread = new Thread( task );
331
    thread.setDaemon( true );
332
    return thread;
333
  }
334
335
  /**
336
   * Downloads a resource to a local file in a separate {@link Thread}.
337
   *
338
   * @param uri      The resource to download.
339
   * @param file     The destination mTarget for the resource.
340
   * @param listener Receives updates as the download proceeds.
341
   */
342
  public static Task<Void> downloadAsync(
343
    final URI uri,
344
    final File file,
345
    final ProgressListener listener ) {
346
    final Task<Void> task = createTask( () -> {
347
      try( final var token = DownloadManager.open( uri ) ) {
348
        token.download( file, listener ).run();
349
      }
350
351
      return null;
352
    } );
353
354
    createThread( task ).start();
355
    return task;
356
  }
357
358
  public static String toFilename( final URI uri ) {
359
    return toFile( uri ).getName();
360
  }
361
362
  public static File toFile( final URI uri ) {
363
    return SysFile.toFile( Paths.get( uri.getPath() ) );
309364
  }
310365
}
M src/main/java/com/keenwrite/preferences/AppKeys.java
9696
  public static final Key KEY_TYPESET_TYPOGRAPHY = key( KEY_TYPESET, "typography" );
9797
  public static final Key KEY_TYPESET_TYPOGRAPHY_QUOTES = key( KEY_TYPESET_TYPOGRAPHY, "quotes" );
98
  public static final Key KEY_TYPESET_MODES = key( KEY_TYPESET, "modes" );
99
  public static final Key KEY_TYPESET_MODES_ENABLED = key( KEY_TYPESET_MODES, "enabled" );
98100
  //@formatter:on
99101
M src/main/java/com/keenwrite/preferences/PreferencesController.java
6262
    final var control = new SimpleFontControl( "Change" );
6363
64
    control.fontSizeProperty().addListener( ( c, o, n ) -> {
64
    control.fontSizeProperty().addListener( ( _, _, n ) -> {
6565
      if( n != null ) {
6666
        fontSize.set( n.doubleValue() );
...
146146
          Setting.of( title( KEY_TYPESET_TYPOGRAPHY_QUOTES ),
147147
                      booleanProperty( KEY_TYPESET_TYPOGRAPHY_QUOTES ) )
148
        ),
149
        Group.of(
150
          get( KEY_TYPESET_MODES ),
151
          Setting.of( label( KEY_TYPESET_MODES_ENABLED ) ),
152
          Setting.of( title( KEY_TYPESET_MODES_ENABLED ),
153
                      stringProperty( KEY_TYPESET_MODES_ENABLED ) )
148154
        )
149155
      ),
...
333339
    final var view = preferences.getView();
334340
    final var nodes = view.getChildrenUnmodifiable();
335
    final var master = (MasterDetailPane) nodes.get( 0 );
341
    final var master = (MasterDetailPane) nodes.getFirst();
336342
    final var detail = (NavigationView) master.getDetailNode();
337343
    final var pane = (DialogPane) view.getParent();
338344
339345
    detail.setOnKeyReleased( key -> {
340346
      switch( key.getCode() ) {
341347
        case ENTER -> ((Button) pane.lookupButton( OK )).fire();
342348
        case ESCAPE -> ((Button) pane.lookupButton( CANCEL )).fire();
343
        default -> { }
349
        default -> {}
344350
      }
345351
    } );
...
353359
  private void initSaveEventHandler( final PreferencesFx preferences ) {
354360
    preferences.addEventHandler(
355
      EVENT_PREFERENCES_SAVED, event -> mWorkspace.save()
361
      EVENT_PREFERENCES_SAVED, _ -> mWorkspace.save()
356362
    );
357363
  }
...
368374
369375
  private Node label( final Key key, final String... values ) {
370
    return new Label( get( key.toString() + ".desc", (Object[]) values ) );
376
    return new Label( get( STR."\{key.toString()}.desc", (Object[]) values ) );
371377
  }
372378
373379
  private String title( final Key key ) {
374
    return get( key.toString() + ".title" );
380
    return get( STR."\{key.toString()}.title" );
375381
  }
376382
M src/main/java/com/keenwrite/preferences/Workspace.java
138138
    entry( KEY_TYPESET_CONTEXT_THEME_SELECTION, asStringProperty( "boschet" ) ),
139139
    entry( KEY_TYPESET_CONTEXT_CHAPTERS, asStringProperty( "" ) ),
140
    entry( KEY_TYPESET_TYPOGRAPHY_QUOTES, asBooleanProperty( true ) )
141
    //@formatter:on
142
  );
143
144
  /**
145
   * Sets of configuration values, all the same type (e.g., file names),
146
   * where the key name doesn't change per set.
147
   */
148
  private final Map<Key, SetProperty<?>> mSets = Map.ofEntries(
149
    entry(
150
      KEY_UI_RECENT_OPEN_PATH,
151
      createSetProperty( new HashSet<String>() )
152
    )
153
  );
154
155
  /**
156
   * Lists of configuration values, such as key-value pairs where both the
157
   * key name and the value must be preserved per list.
158
   */
159
  private final Map<Key, ListProperty<?>> mLists = Map.ofEntries(
160
    entry(
161
      KEY_DOC_META,
162
      createListProperty( new LinkedList<Entry<String, String>>() )
163
    )
164
  );
165
166
  /**
167
   * Helps instantiate {@link Property} instances for XML configuration items.
168
   */
169
  private static final Map<Class<?>, Function<String, Object>> UNMARSHALL =
170
    Map.of(
171
      LocaleProperty.class, LocaleProperty::parseLocale,
172
      SimpleBooleanProperty.class, Boolean::parseBoolean,
173
      SimpleIntegerProperty.class, Integer::parseInt,
174
      SimpleDoubleProperty.class, Double::parseDouble,
175
      SimpleFloatProperty.class, Float::parseFloat,
176
      SimpleStringProperty.class, String::new,
177
      SimpleObjectProperty.class, String::new,
178
      SkinProperty.class, String::new,
179
      FileProperty.class, File::new
180
    );
181
182
  /**
183
   * The asymmetry with respect to {@link #UNMARSHALL} is because most objects
184
   * can simply call {@link Object#toString()} to convert the value to a string.
185
   */
186
  private static final Map<Class<?>, Function<String, Object>> MARSHALL =
187
    Map.of(
188
      LocaleProperty.class, LocaleProperty::toLanguageTag
189
    );
190
191
  /**
192
   * Converts the given {@link Property} value to a string.
193
   *
194
   * @param property The {@link Property} to convert.
195
   * @return A string representation of the given property, or the empty
196
   * string if no conversion was possible.
197
   */
198
  private static String marshall( final Property<?> property ) {
199
    final var v = property.getValue();
200
201
    return v == null
202
      ? ""
203
      : MARSHALL
204
      .getOrDefault( property.getClass(), __ -> property.getValue() )
205
      .apply( v.toString() )
206
      .toString();
207
  }
208
209
  private static Object unmarshall(
210
    final Property<?> property, final Object configValue ) {
211
    final var v = configValue.toString();
212
213
    return UNMARSHALL
214
      .getOrDefault( property.getClass(), value -> property.getValue() )
215
      .apply( v );
216
  }
217
218
  /**
219
   * Creates an instance of {@link ObservableList} that is based on a
220
   * modifiable observable array list for the given items.
221
   *
222
   * @param items The items to wrap in an observable list.
223
   * @param <E>   The type of items to add to the list.
224
   * @return An observable property that can have its contents modified.
225
   */
226
  public static <E> ObservableList<E> listProperty( final Set<E> items ) {
227
    return new SimpleListProperty<>( observableArrayList( items ) );
228
  }
229
230
  private static <E> SetProperty<E> createSetProperty( final Set<E> set ) {
231
    return new SimpleSetProperty<>( observableSet( set ) );
232
  }
233
234
  private static <E> ListProperty<E> createListProperty( final List<E> list ) {
235
    return new SimpleListProperty<>( observableArrayList( list ) );
236
  }
237
238
  private static StringProperty asStringProperty( final String value ) {
239
    return new SimpleStringProperty( value );
240
  }
241
242
  private static BooleanProperty asBooleanProperty() {
243
    return new SimpleBooleanProperty();
244
  }
245
246
  /**
247
   * @param value Default value.
248
   */
249
  @SuppressWarnings( "SameParameterValue" )
250
  private static BooleanProperty asBooleanProperty( final boolean value ) {
251
    return new SimpleBooleanProperty( value );
252
  }
253
254
  /**
255
   * @param value Default value.
256
   */
257
  @SuppressWarnings( "SameParameterValue" )
258
  private static IntegerProperty asIntegerProperty( final int value ) {
259
    return new SimpleIntegerProperty( value );
260
  }
261
262
  /**
263
   * @param value Default value.
264
   */
265
  private static DoubleProperty asDoubleProperty( final double value ) {
266
    return new SimpleDoubleProperty( value );
267
  }
268
269
  /**
270
   * @param value Default value.
271
   */
272
  private static FileProperty asFileProperty( final File value ) {
273
    return new FileProperty( value );
274
  }
275
276
  /**
277
   * @param value Default value.
278
   */
279
  @SuppressWarnings( "SameParameterValue" )
280
  private static LocaleProperty asLocaleProperty( final Locale value ) {
281
    return new LocaleProperty( value );
282
  }
283
284
  /**
285
   * @param value Default value.
286
   */
287
  @SuppressWarnings( "SameParameterValue" )
288
  private static SkinProperty asSkinProperty( final String value ) {
289
    return new SkinProperty( value );
290
  }
291
292
  /**
293
   * Creates a new {@link Workspace} that will attempt to load the users'
294
   * preferences. If the configuration file cannot be loaded, the workspace
295
   * settings returns default values.
296
   */
297
  public Workspace() {
298
    load();
299
  }
300
301
  /**
302
   * Attempts to load the app's configuration file.
303
   */
304
  private void load() {
305
    final var store = createXmlStore();
306
    store.load( FILE_PREFERENCES );
307
308
    mValues.keySet().forEach( key -> {
309
      try {
310
        final var storeValue = store.getValue( key );
311
        final var property = valuesProperty( key );
312
        final var unmarshalled = unmarshall( property, storeValue );
313
314
        property.setValue( unmarshalled );
315
      } catch( final NoSuchElementException ex ) {
316
        // When no configuration (item), use the default value.
317
        clue( ex );
318
      }
319
    } );
320
321
    mSets.keySet().forEach( key -> {
322
      final var set = store.getSet( key );
323
      final SetProperty<String> property = setsProperty( key );
324
325
      property.setValue( observableSet( set ) );
326
    } );
327
328
    mLists.keySet().forEach( key -> {
329
      final var map = store.getMap( key );
330
      final ListProperty<Entry<String, String>> property = listsProperty( key );
331
      final var list = map
332
        .entrySet()
333
        .stream()
334
        .toList();
335
336
      property.setValue( observableArrayList( list ) );
337
    } );
338
339
    WorkspaceLoadedEvent.fire( this );
340
  }
341
342
  /**
343
   * Saves the current workspace.
344
   */
345
  public void save() {
346
    final var store = createXmlStore();
347
348
    try {
349
      // Update the string values to include the application version.
350
      valuesProperty( KEY_META_VERSION ).setValue( getVersion() );
351
352
      mValues.forEach( ( k, v ) -> store.setValue( k, marshall( v ) ) );
353
      mSets.forEach( store::setSet );
354
      mLists.forEach( store::setMap );
355
356
      store.save( FILE_PREFERENCES );
357
    } catch( final Exception ex ) {
358
      clue( ex );
359
    }
360
  }
361
362
  /**
363
   * Returns a value that represents a setting in the application that the user
364
   * may configure, either directly or indirectly.
365
   *
366
   * @param key The reference to the users' preference stored in deference
367
   *            of app reëntrance.
368
   * @return An observable property to be persisted.
369
   */
370
  @SuppressWarnings( "unchecked" )
371
  public <T, U extends Property<T>> U valuesProperty( final Key key ) {
372
    assert key != null;
373
    return (U) mValues.get( key );
374
  }
375
376
  /**
377
   * Returns a set of values that represent a setting in the application that
378
   * the user may configure, either directly or indirectly. The property
379
   * returned is backed by a {@link Set}.
380
   *
381
   * @param key The {@link Key} associated with a preference value.
382
   * @return An observable property to be persisted.
383
   */
384
  @SuppressWarnings( "unchecked" )
385
  public <T> SetProperty<T> setsProperty( final Key key ) {
386
    assert key != null;
387
    return (SetProperty<T>) mSets.get( key );
388
  }
389
390
  /**
391
   * Returns a list of values that represent a setting in the application that
392
   * the user may configure, either directly or indirectly. The property
393
   * returned is backed by a mutable {@link List}.
394
   *
395
   * @param key The {@link Key} associated with a preference value.
396
   * @return An observable property to be persisted.
397
   */
398
  @SuppressWarnings( "unchecked" )
399
  public <K, V> ListProperty<Entry<K, V>> listsProperty( final Key key ) {
400
    assert key != null;
401
    return (ListProperty<Entry<K, V>>) mLists.get( key );
402
  }
403
404
  /**
405
   * Returns the {@link String} {@link Property} associated with the given
406
   * {@link Key} from the internal list of preference values. The caller
407
   * must be sure that the given {@link Key} is associated with a {@link File}
408
   * {@link Property}.
409
   *
410
   * @param key The {@link Key} associated with a preference value.
411
   * @return The value associated with the given {@link Key}.
412
   */
413
  public StringProperty stringProperty( final Key key ) {
414
    assert key != null;
415
    return valuesProperty( key );
416
  }
417
418
  /**
419
   * Returns the {@link Boolean} {@link Property} associated with the given
420
   * {@link Key} from the internal list of preference values. The caller
421
   * must be sure that the given {@link Key} is associated with a {@link File}
422
   * {@link Property}.
423
   *
424
   * @param key The {@link Key} associated with a preference value.
425
   * @return The value associated with the given {@link Key}.
426
   */
427
  public BooleanProperty booleanProperty( final Key key ) {
428
    assert key != null;
429
    return valuesProperty( key );
430
  }
431
432
  /**
433
   * Returns the {@link Integer} {@link Property} associated with the given
434
   * {@link Key} from the internal list of preference values. The caller
435
   * must be sure that the given {@link Key} is associated with a {@link File}
436
   * {@link Property}.
437
   *
438
   * @param key The {@link Key} associated with a preference value.
439
   * @return The value associated with the given {@link Key}.
440
   */
441
  public IntegerProperty integerProperty( final Key key ) {
442
    assert key != null;
443
    return valuesProperty( key );
444
  }
445
446
  /**
447
   * Returns the {@link Double} {@link Property} associated with the given
448
   * {@link Key} from the internal list of preference values. The caller
449
   * must be sure that the given {@link Key} is associated with a {@link File}
450
   * {@link Property}.
451
   *
452
   * @param key The {@link Key} associated with a preference value.
453
   * @return The value associated with the given {@link Key}.
454
   */
455
  public DoubleProperty doubleProperty( final Key key ) {
456
    assert key != null;
457
    return valuesProperty( key );
458
  }
459
460
  /**
461
   * Returns the {@link File} {@link Property} associated with the given
462
   * {@link Key} from the internal list of preference values. The caller
463
   * must be sure that the given {@link Key} is associated with a {@link File}
464
   * {@link Property}.
465
   *
466
   * @param key The {@link Key} associated with a preference value.
467
   * @return The value associated with the given {@link Key}.
468
   */
469
  public ObjectProperty<File> fileProperty( final Key key ) {
470
    assert key != null;
471
    return valuesProperty( key );
472
  }
473
474
  /**
475
   * Returns the {@link Locale} {@link Property} associated with the given
476
   * {@link Key} from the internal list of preference values. The caller
477
   * must be sure that the given {@link Key} is associated with a {@link File}
478
   * {@link Property}.
479
   *
480
   * @param key The {@link Key} associated with a preference value.
481
   * @return The value associated with the given {@link Key}.
482
   */
483
  public LocaleProperty localeProperty( final Key key ) {
484
    assert key != null;
485
    return valuesProperty( key );
486
  }
487
488
  public ObjectProperty<String> skinProperty( final Key key ) {
489
    assert key != null;
490
    return valuesProperty( key );
491
  }
492
493
  public String getString( final Key key ) {
494
    assert key != null;
495
    return stringProperty( key ).get();
496
  }
497
498
  /**
499
   * Returns the {@link Boolean} preference value associated with the given
500
   * {@link Key}. The caller must be sure that the given {@link Key} is
501
   * associated with a value that matches the return type.
502
   *
503
   * @param key The {@link Key} associated with a preference value.
504
   * @return The value associated with the given {@link Key}.
505
   */
506
  public boolean getBoolean( final Key key ) {
507
    assert key != null;
508
    return booleanProperty( key ).get();
509
  }
510
511
  /**
512
   * Returns the {@link Integer} preference value associated with the given
513
   * {@link Key}. The caller must be sure that the given {@link Key} is
514
   * associated with a value that matches the return type.
515
   *
516
   * @param key The {@link Key} associated with a preference value.
517
   * @return The value associated with the given {@link Key}.
518
   */
519
  @SuppressWarnings( "unused" )
520
  public int getInteger( final Key key ) {
521
    assert key != null;
522
    return integerProperty( key ).get();
523
  }
524
525
  /**
526
   * Returns the {@link Double} preference value associated with the given
527
   * {@link Key}. The caller must be sure that the given {@link Key} is
528
   * associated with a value that matches the return type.
529
   *
530
   * @param key The {@link Key} associated with a preference value.
531
   * @return The value associated with the given {@link Key}.
532
   */
533
  public double getDouble( final Key key ) {
534
    assert key != null;
535
    return doubleProperty( key ).get();
536
  }
537
538
  /**
539
   * Returns the {@link File} preference value associated with the given
540
   * {@link Key}. The caller must be sure that the given {@link Key} is
541
   * associated with a value that matches the return type.
542
   *
543
   * @param key The {@link Key} associated with a preference value.
544
   * @return The value associated with the given {@link Key}.
545
   */
546
  public File getFile( final Key key ) {
547
    assert key != null;
548
    return fileProperty( key ).get();
549
  }
550
551
  /**
552
   * Returns the language locale setting for the
553
   * {@link AppKeys#KEY_LANGUAGE_LOCALE} key.
554
   *
555
   * @return The user's current locale setting.
556
   */
557
  public Locale getLocale() {
558
    return localeProperty( KEY_LANGUAGE_LOCALE ).toLocale();
559
  }
560
561
  @SuppressWarnings( "unchecked" )
562
  public <K, V> Map<K, V> getMetadata() {
563
    final var metadata = listsProperty( KEY_DOC_META );
564
    final HashMap<K, V> map;
565
566
    if( metadata != null ) {
567
      map = new HashMap<>( metadata.size() );
568
569
      metadata.forEach(
570
        entry -> map.put( (K) entry.getKey(), (V) entry.getValue() )
571
      );
572
    }
573
    else {
574
      map = new HashMap<>();
575
    }
576
577
    return map;
578
  }
579
580
  public Path getThemesPath() {
581
    final var dir = getFile( KEY_TYPESET_CONTEXT_THEMES_PATH );
582
    final var name = getString( KEY_TYPESET_CONTEXT_THEME_SELECTION );
583
584
    return Path.of( dir.toString(), name );
585
  }
586
587
  /**
588
   * Delegates to {@link #listen(Key, ReadOnlyProperty, BooleanSupplier)},
589
   * providing a value of {@code true} for the {@link BooleanSupplier} to
590
   * indicate the property changes always take effect.
591
   *
592
   * @param key      The value to bind to the internal key property.
593
   * @param property The external property value that sets the internal value.
594
   */
595
  public <T> void listen( final Key key, final ReadOnlyProperty<T> property ) {
596
    assert key != null;
597
    assert property != null;
598
599
    listen( key, property, () -> true );
600
  }
601
602
  /**
603
   * Binds a read-only property to a value in the preferences. This allows
604
   * user interface properties to change and the preferences will be
605
   * synchronized automatically.
606
   * <p>
607
   * This calls {@link Platform#runLater(Runnable)} to ensure that all pending
608
   * application window states are finished before assessing whether property
609
   * changes should be applied. Without this, exiting the application while the
610
   * window is maximized would persist the window's maximum dimensions,
611
   * preventing restoration to its prior, non-maximum size.
612
   *
613
   * @param key      The value to bind to the internal key property.
614
   * @param property The external property value that sets the internal value.
615
   * @param enabled  Indicates whether property changes should be applied.
616
   */
617
  public <T> void listen(
618
    final Key key,
619
    final ReadOnlyProperty<T> property,
620
    final BooleanSupplier enabled ) {
621
    assert key != null;
622
    assert property != null;
623
    assert enabled != null;
624
625
    property.addListener(
626
      ( c, o, n ) -> runLater( () -> {
140
    entry( KEY_TYPESET_TYPOGRAPHY_QUOTES, asBooleanProperty( true ) ),
141
    entry( KEY_TYPESET_MODES_ENABLED, asStringProperty( "" ) )
142
    //@formatter:on
143
  );
144
145
  /**
146
   * Sets of configuration values, all the same type (e.g., file names),
147
   * where the key name doesn't change per set.
148
   */
149
  private final Map<Key, SetProperty<?>> mSets = Map.ofEntries(
150
    entry(
151
      KEY_UI_RECENT_OPEN_PATH,
152
      createSetProperty( new HashSet<String>() )
153
    )
154
  );
155
156
  /**
157
   * Lists of configuration values, such as key-value pairs where both the
158
   * key name and the value must be preserved per list.
159
   */
160
  private final Map<Key, ListProperty<?>> mLists = Map.ofEntries(
161
    entry(
162
      KEY_DOC_META,
163
      createListProperty( new LinkedList<Entry<String, String>>() )
164
    )
165
  );
166
167
  /**
168
   * Helps instantiate {@link Property} instances for XML configuration items.
169
   */
170
  private static final Map<Class<?>, Function<String, Object>> UNMARSHALL =
171
    Map.of(
172
      LocaleProperty.class, LocaleProperty::parseLocale,
173
      SimpleBooleanProperty.class, Boolean::parseBoolean,
174
      SimpleIntegerProperty.class, Integer::parseInt,
175
      SimpleDoubleProperty.class, Double::parseDouble,
176
      SimpleFloatProperty.class, Float::parseFloat,
177
      SimpleStringProperty.class, String::new,
178
      SimpleObjectProperty.class, String::new,
179
      SkinProperty.class, String::new,
180
      FileProperty.class, File::new
181
    );
182
183
  /**
184
   * The asymmetry with respect to {@link #UNMARSHALL} is because most objects
185
   * can simply call {@link Object#toString()} to convert the value to a string.
186
   */
187
  private static final Map<Class<?>, Function<String, Object>> MARSHALL =
188
    Map.of(
189
      LocaleProperty.class, LocaleProperty::toLanguageTag
190
    );
191
192
  /**
193
   * Converts the given {@link Property} value to a string.
194
   *
195
   * @param property The {@link Property} to convert.
196
   * @return A string representation of the given property, or the empty
197
   * string if no conversion was possible.
198
   */
199
  private static String marshall( final Property<?> property ) {
200
    final var v = property.getValue();
201
202
    return v == null
203
      ? ""
204
      : MARSHALL
205
      .getOrDefault( property.getClass(), _ -> property.getValue() )
206
      .apply( v.toString() )
207
      .toString();
208
  }
209
210
  private static Object unmarshall(
211
    final Property<?> property, final Object configValue ) {
212
    final var v = configValue.toString();
213
214
    return UNMARSHALL
215
      .getOrDefault( property.getClass(), _ -> property.getValue() )
216
      .apply( v );
217
  }
218
219
  /**
220
   * Creates an instance of {@link ObservableList} that is based on a
221
   * modifiable observable array list for the given items.
222
   *
223
   * @param items The items to wrap in an observable list.
224
   * @param <E>   The type of items to add to the list.
225
   * @return An observable property that can have its contents modified.
226
   */
227
  public static <E> ObservableList<E> listProperty( final Set<E> items ) {
228
    return new SimpleListProperty<>( observableArrayList( items ) );
229
  }
230
231
  private static <E> SetProperty<E> createSetProperty( final Set<E> set ) {
232
    return new SimpleSetProperty<>( observableSet( set ) );
233
  }
234
235
  private static <E> ListProperty<E> createListProperty( final List<E> list ) {
236
    return new SimpleListProperty<>( observableArrayList( list ) );
237
  }
238
239
  private static StringProperty asStringProperty( final String value ) {
240
    return new SimpleStringProperty( value );
241
  }
242
243
  private static BooleanProperty asBooleanProperty() {
244
    return new SimpleBooleanProperty();
245
  }
246
247
  /**
248
   * @param value Default value.
249
   */
250
  @SuppressWarnings( "SameParameterValue" )
251
  private static BooleanProperty asBooleanProperty( final boolean value ) {
252
    return new SimpleBooleanProperty( value );
253
  }
254
255
  /**
256
   * @param value Default value.
257
   */
258
  @SuppressWarnings( "SameParameterValue" )
259
  private static IntegerProperty asIntegerProperty( final int value ) {
260
    return new SimpleIntegerProperty( value );
261
  }
262
263
  /**
264
   * @param value Default value.
265
   */
266
  private static DoubleProperty asDoubleProperty( final double value ) {
267
    return new SimpleDoubleProperty( value );
268
  }
269
270
  /**
271
   * @param value Default value.
272
   */
273
  private static FileProperty asFileProperty( final File value ) {
274
    return new FileProperty( value );
275
  }
276
277
  /**
278
   * @param value Default value.
279
   */
280
  @SuppressWarnings( "SameParameterValue" )
281
  private static LocaleProperty asLocaleProperty( final Locale value ) {
282
    return new LocaleProperty( value );
283
  }
284
285
  /**
286
   * @param value Default value.
287
   */
288
  @SuppressWarnings( "SameParameterValue" )
289
  private static SkinProperty asSkinProperty( final String value ) {
290
    return new SkinProperty( value );
291
  }
292
293
  /**
294
   * Creates a new {@link Workspace} that will attempt to load the users'
295
   * preferences. If the configuration file cannot be loaded, the workspace
296
   * settings returns default values.
297
   */
298
  public Workspace() {
299
    load();
300
  }
301
302
  /**
303
   * Attempts to load the app's configuration file.
304
   */
305
  private void load() {
306
    final var store = createXmlStore();
307
    store.load( FILE_PREFERENCES );
308
309
    mValues.keySet().forEach( key -> {
310
      try {
311
        final var storeValue = store.getValue( key );
312
        final var property = valuesProperty( key );
313
        final var unmarshalled = unmarshall( property, storeValue );
314
315
        property.setValue( unmarshalled );
316
      } catch( final NoSuchElementException ex ) {
317
        // When no configuration (item), use the default value.
318
        clue( ex );
319
      }
320
    } );
321
322
    mSets.keySet().forEach( key -> {
323
      final var set = store.getSet( key );
324
      final SetProperty<String> property = setsProperty( key );
325
326
      property.setValue( observableSet( set ) );
327
    } );
328
329
    mLists.keySet().forEach( key -> {
330
      final var map = store.getMap( key );
331
      final ListProperty<Entry<String, String>> property = listsProperty( key );
332
      final var list = map
333
        .entrySet()
334
        .stream()
335
        .toList();
336
337
      property.setValue( observableArrayList( list ) );
338
    } );
339
340
    WorkspaceLoadedEvent.fire( this );
341
  }
342
343
  /**
344
   * Saves the current workspace.
345
   */
346
  public void save() {
347
    final var store = createXmlStore();
348
349
    try {
350
      // Update the string values to include the application version.
351
      valuesProperty( KEY_META_VERSION ).setValue( getVersion() );
352
353
      mValues.forEach( ( k, v ) -> store.setValue( k, marshall( v ) ) );
354
      mSets.forEach( store::setSet );
355
      mLists.forEach( store::setMap );
356
357
      store.save( FILE_PREFERENCES );
358
    } catch( final Exception ex ) {
359
      clue( ex );
360
    }
361
  }
362
363
  /**
364
   * Returns a value that represents a setting in the application that the user
365
   * may configure, either directly or indirectly.
366
   *
367
   * @param key The reference to the users' preference stored in deference
368
   *            of app reëntrance.
369
   * @return An observable property to be persisted.
370
   */
371
  @SuppressWarnings( "unchecked" )
372
  public <T, U extends Property<T>> U valuesProperty( final Key key ) {
373
    assert key != null;
374
    return (U) mValues.get( key );
375
  }
376
377
  /**
378
   * Returns a set of values that represent a setting in the application that
379
   * the user may configure, either directly or indirectly. The property
380
   * returned is backed by a {@link Set}.
381
   *
382
   * @param key The {@link Key} associated with a preference value.
383
   * @return An observable property to be persisted.
384
   */
385
  @SuppressWarnings( "unchecked" )
386
  public <T> SetProperty<T> setsProperty( final Key key ) {
387
    assert key != null;
388
    return (SetProperty<T>) mSets.get( key );
389
  }
390
391
  /**
392
   * Returns a list of values that represent a setting in the application that
393
   * the user may configure, either directly or indirectly. The property
394
   * returned is backed by a mutable {@link List}.
395
   *
396
   * @param key The {@link Key} associated with a preference value.
397
   * @return An observable property to be persisted.
398
   */
399
  @SuppressWarnings( "unchecked" )
400
  public <K, V> ListProperty<Entry<K, V>> listsProperty( final Key key ) {
401
    assert key != null;
402
    return (ListProperty<Entry<K, V>>) mLists.get( key );
403
  }
404
405
  /**
406
   * Returns the {@link String} {@link Property} associated with the given
407
   * {@link Key} from the internal list of preference values. The caller
408
   * must be sure that the given {@link Key} is associated with a {@link File}
409
   * {@link Property}.
410
   *
411
   * @param key The {@link Key} associated with a preference value.
412
   * @return The value associated with the given {@link Key}.
413
   */
414
  public StringProperty stringProperty( final Key key ) {
415
    assert key != null;
416
    return valuesProperty( key );
417
  }
418
419
  /**
420
   * Returns the {@link Boolean} {@link Property} associated with the given
421
   * {@link Key} from the internal list of preference values. The caller
422
   * must be sure that the given {@link Key} is associated with a {@link File}
423
   * {@link Property}.
424
   *
425
   * @param key The {@link Key} associated with a preference value.
426
   * @return The value associated with the given {@link Key}.
427
   */
428
  public BooleanProperty booleanProperty( final Key key ) {
429
    assert key != null;
430
    return valuesProperty( key );
431
  }
432
433
  /**
434
   * Returns the {@link Integer} {@link Property} associated with the given
435
   * {@link Key} from the internal list of preference values. The caller
436
   * must be sure that the given {@link Key} is associated with a {@link File}
437
   * {@link Property}.
438
   *
439
   * @param key The {@link Key} associated with a preference value.
440
   * @return The value associated with the given {@link Key}.
441
   */
442
  public IntegerProperty integerProperty( final Key key ) {
443
    assert key != null;
444
    return valuesProperty( key );
445
  }
446
447
  /**
448
   * Returns the {@link Double} {@link Property} associated with the given
449
   * {@link Key} from the internal list of preference values. The caller
450
   * must be sure that the given {@link Key} is associated with a {@link File}
451
   * {@link Property}.
452
   *
453
   * @param key The {@link Key} associated with a preference value.
454
   * @return The value associated with the given {@link Key}.
455
   */
456
  public DoubleProperty doubleProperty( final Key key ) {
457
    assert key != null;
458
    return valuesProperty( key );
459
  }
460
461
  /**
462
   * Returns the {@link File} {@link Property} associated with the given
463
   * {@link Key} from the internal list of preference values. The caller
464
   * must be sure that the given {@link Key} is associated with a {@link File}
465
   * {@link Property}.
466
   *
467
   * @param key The {@link Key} associated with a preference value.
468
   * @return The value associated with the given {@link Key}.
469
   */
470
  public ObjectProperty<File> fileProperty( final Key key ) {
471
    assert key != null;
472
    return valuesProperty( key );
473
  }
474
475
  /**
476
   * Returns the {@link Locale} {@link Property} associated with the given
477
   * {@link Key} from the internal list of preference values. The caller
478
   * must be sure that the given {@link Key} is associated with a {@link File}
479
   * {@link Property}.
480
   *
481
   * @param key The {@link Key} associated with a preference value.
482
   * @return The value associated with the given {@link Key}.
483
   */
484
  public LocaleProperty localeProperty( final Key key ) {
485
    assert key != null;
486
    return valuesProperty( key );
487
  }
488
489
  public ObjectProperty<String> skinProperty( final Key key ) {
490
    assert key != null;
491
    return valuesProperty( key );
492
  }
493
494
  public String getString( final Key key ) {
495
    assert key != null;
496
    return stringProperty( key ).get();
497
  }
498
499
  /**
500
   * Returns the {@link Boolean} preference value associated with the given
501
   * {@link Key}. The caller must be sure that the given {@link Key} is
502
   * associated with a value that matches the return type.
503
   *
504
   * @param key The {@link Key} associated with a preference value.
505
   * @return The value associated with the given {@link Key}.
506
   */
507
  public boolean getBoolean( final Key key ) {
508
    assert key != null;
509
    return booleanProperty( key ).get();
510
  }
511
512
  /**
513
   * Returns the {@link Integer} preference value associated with the given
514
   * {@link Key}. The caller must be sure that the given {@link Key} is
515
   * associated with a value that matches the return type.
516
   *
517
   * @param key The {@link Key} associated with a preference value.
518
   * @return The value associated with the given {@link Key}.
519
   */
520
  @SuppressWarnings( "unused" )
521
  public int getInteger( final Key key ) {
522
    assert key != null;
523
    return integerProperty( key ).get();
524
  }
525
526
  /**
527
   * Returns the {@link Double} preference value associated with the given
528
   * {@link Key}. The caller must be sure that the given {@link Key} is
529
   * associated with a value that matches the return type.
530
   *
531
   * @param key The {@link Key} associated with a preference value.
532
   * @return The value associated with the given {@link Key}.
533
   */
534
  public double getDouble( final Key key ) {
535
    assert key != null;
536
    return doubleProperty( key ).get();
537
  }
538
539
  /**
540
   * Returns the {@link File} preference value associated with the given
541
   * {@link Key}. The caller must be sure that the given {@link Key} is
542
   * associated with a value that matches the return type.
543
   *
544
   * @param key The {@link Key} associated with a preference value.
545
   * @return The value associated with the given {@link Key}.
546
   */
547
  public File getFile( final Key key ) {
548
    assert key != null;
549
    return fileProperty( key ).get();
550
  }
551
552
  /**
553
   * Returns the language locale setting for the
554
   * {@link AppKeys#KEY_LANGUAGE_LOCALE} key.
555
   *
556
   * @return The user's current locale setting.
557
   */
558
  public Locale getLocale() {
559
    return localeProperty( KEY_LANGUAGE_LOCALE ).toLocale();
560
  }
561
562
  @SuppressWarnings( "unchecked" )
563
  public <K, V> Map<K, V> getMetadata() {
564
    final var metadata = listsProperty( KEY_DOC_META );
565
    final HashMap<K, V> map;
566
567
    if( metadata != null ) {
568
      map = new HashMap<>( metadata.size() );
569
570
      metadata.forEach(
571
        entry -> map.put( (K) entry.getKey(), (V) entry.getValue() )
572
      );
573
    }
574
    else {
575
      map = new HashMap<>();
576
    }
577
578
    return map;
579
  }
580
581
  public Path getThemesPath() {
582
    final var dir = getFile( KEY_TYPESET_CONTEXT_THEMES_PATH );
583
    final var name = getString( KEY_TYPESET_CONTEXT_THEME_SELECTION );
584
585
    return Path.of( dir.toString(), name );
586
  }
587
588
  /**
589
   * Delegates to {@link #listen(Key, ReadOnlyProperty, BooleanSupplier)},
590
   * providing a value of {@code true} for the {@link BooleanSupplier} to
591
   * indicate the property changes always take effect.
592
   *
593
   * @param key      The value to bind to the internal key property.
594
   * @param property The external property value that sets the internal value.
595
   */
596
  public <T> void listen( final Key key, final ReadOnlyProperty<T> property ) {
597
    assert key != null;
598
    assert property != null;
599
600
    listen( key, property, () -> true );
601
  }
602
603
  /**
604
   * Binds a read-only property to a value in the preferences. This allows
605
   * user interface properties to change and the preferences will be
606
   * synchronized automatically.
607
   * <p>
608
   * This calls {@link Platform#runLater(Runnable)} to ensure that all pending
609
   * application window states are finished before assessing whether property
610
   * changes should be applied. Without this, exiting the application while the
611
   * window is maximized would persist the window's maximum dimensions,
612
   * preventing restoration to its prior, non-maximum size.
613
   *
614
   * @param key      The value to bind to the internal key property.
615
   * @param property The external property value that sets the internal value.
616
   * @param enabled  Indicates whether property changes should be applied.
617
   */
618
  public <T> void listen(
619
    final Key key,
620
    final ReadOnlyProperty<T> property,
621
    final BooleanSupplier enabled ) {
622
    assert key != null;
623
    assert property != null;
624
    assert enabled != null;
625
626
    property.addListener(
627
      ( _, _, n ) -> runLater( () -> {
627628
        if( enabled.getAsBoolean() ) {
628629
          valuesProperty( key ).setValue( n );
M src/main/java/com/keenwrite/processors/PdfProcessor.java
99
import static com.keenwrite.io.SysFile.normalize;
1010
import static com.keenwrite.typesetting.Typesetter.Mutator;
11
import static com.keenwrite.util.Strings.sanitize;
1112
import static java.nio.charset.StandardCharsets.UTF_8;
1213
import static java.nio.file.Files.deleteIfExists;
...
6465
      final var rWorkDir = normalize( context.getRWorkingDir() );
6566
      clue( "Main.status.typeset.setting", "r-work", rWorkDir );
67
68
      final var enableMode = sanitize( context.getEnableMode() );
69
      clue( "Main.status.typeset.setting", "mode", enableMode );
6670
6771
      final var autoRemove = context.getAutoRemove();
...
7680
        .with( Mutator::setCacheDir, cacheDir )
7781
        .with( Mutator::setFontDir, fontDir )
82
        .with( Mutator::setEnableMode, enableMode )
7883
        .with( Mutator::setAutoRemove, autoRemove )
7984
        .build();
80
81
      typesetter.typeset();
8285
83
      // Smote the temporary file after typesetting the document.
84
      if( typesetter.autoRemove() ) {
85
        deleteIfExists( document );
86
      try {
87
        typesetter.typeset();
88
      }
89
      finally {
90
        // Smote the temporary file after typesetting the document.
91
        if( typesetter.autoRemove() ) {
92
          deleteIfExists( document );
93
        }
8694
      }
8795
    } catch( final Exception ex ) {
M src/main/java/com/keenwrite/processors/ProcessorContext.java
3232
import static com.keenwrite.io.SysFile.toFile;
3333
import static com.keenwrite.predicates.PredicateFactory.createFileTypePredicate;
34
35
/**
36
 * Provides a context for configuring a chain of {@link Processor} instances.
37
 */
38
public final class ProcessorContext {
39
40
  private final Mutator mMutator;
41
42
  /**
43
   * Determines the file type from the path extension. This should only be
44
   * called when it is known that the file type won't be a definition file
45
   * (e.g., YAML or other definition source), but rather an editable file
46
   * (e.g., Markdown, R Markdown, etc.).
47
   *
48
   * @param path The path with a file name extension.
49
   * @return The FileType for the given path.
50
   */
51
  private static FileType lookup( final Path path ) {
52
    assert path != null;
53
54
    final var prefix = GLOB_PREFIX_FILE;
55
    final var keys = sSettings.getKeys( prefix );
56
57
    var found = false;
58
    var fileType = UNKNOWN;
59
60
    while( keys.hasNext() && !found ) {
61
      final var key = keys.next();
62
      final var patterns = sSettings.getStringSettingList( key );
63
      final var predicate = createFileTypePredicate( patterns );
64
65
      if( predicate.test( toFile( path ) ) ) {
66
        // Remove the EXTENSIONS_PREFIX to get the file name extension mapped
67
        // to a standard name (as defined in the settings.properties file).
68
        final String suffix = key.replace( prefix + '.', "" );
69
        fileType = FileType.from( suffix );
70
        found = true;
71
      }
72
    }
73
74
    return fileType;
75
  }
76
77
  public boolean isExportFormat( final ExportFormat exportFormat ) {
78
    return mMutator.mExportFormat == exportFormat;
79
  }
80
81
  /**
82
   * Responsible for populating the instance variables required by the
83
   * context.
84
   */
85
  public static class Mutator {
86
    private Path mSourcePath;
87
    private Path mTargetPath;
88
    private ExportFormat mExportFormat;
89
    private Supplier<Boolean> mConcatenate = () -> true;
90
    private Supplier<String> mChapters = () -> "";
91
92
    private Supplier<Path> mThemeDir = USER_DIRECTORY::toPath;
93
    private Supplier<Locale> mLocale = () -> Locale.ENGLISH;
94
95
    private Supplier<Map<String, String>> mDefinitions = HashMap::new;
96
    private Supplier<Map<String, String>> mMetadata = HashMap::new;
97
    private Supplier<Caret> mCaret = () -> Caret.builder().build();
98
99
    private Supplier<Path> mFontDir = () -> getFontDirectory().toPath();
100
101
    private Supplier<Path> mImageDir = USER_DIRECTORY::toPath;
102
    private Supplier<String> mImageServer = () -> DIAGRAM_SERVER_NAME;
103
    private Supplier<String> mImageOrder = () -> PERSIST_IMAGES_DEFAULT;
104
105
    private Supplier<Path> mCacheDir = USER_CACHE_DIR::toPath;
106
107
    private Supplier<String> mSigilBegan = () -> DEF_DELIM_BEGAN_DEFAULT;
108
    private Supplier<String> mSigilEnded = () -> DEF_DELIM_ENDED_DEFAULT;
109
110
    private Supplier<Path> mRWorkingDir = USER_DIRECTORY::toPath;
111
    private Supplier<String> mRScript = () -> "";
112
113
    private Supplier<Boolean> mCurlQuotes = () -> true;
114
    private Supplier<Boolean> mAutoRemove = () -> true;
115
116
    public void setSourcePath( final Path sourcePath ) {
117
      assert sourcePath != null;
118
      mSourcePath = sourcePath;
119
    }
120
121
    public void setTargetPath( final Path outputPath ) {
122
      assert outputPath != null;
123
      mTargetPath = outputPath;
124
    }
125
126
    public void setThemeDir( final Supplier<Path> themeDir ) {
127
      assert themeDir != null;
128
      mThemeDir = themeDir;
129
    }
130
131
    public void setCacheDir( final Supplier<File> cacheDir ) {
132
      assert cacheDir != null;
133
134
      mCacheDir = () -> {
135
        final var dir = cacheDir.get();
136
137
        return (dir == null ? toFile( USER_DATA_DIR ) : dir).toPath();
138
      };
139
    }
140
141
    public void setImageDir( final Supplier<File> imageDir ) {
142
      assert imageDir != null;
143
144
      mImageDir = () -> {
145
        final var dir = imageDir.get();
146
147
        return (dir == null ? USER_DIRECTORY : dir).toPath();
148
      };
149
    }
150
151
    public void setImageOrder( final Supplier<String> imageOrder ) {
152
      assert imageOrder != null;
153
      mImageOrder = imageOrder;
154
    }
155
156
    public void setImageServer( final Supplier<String> imageServer ) {
157
      assert imageServer != null;
158
      mImageServer = imageServer;
159
    }
160
161
    public void setFontDir( final Supplier<File> fontDir ) {
162
      assert fontDir != null;
163
164
      mFontDir = () -> {
165
        final var dir = fontDir.get();
166
167
        return (dir == null ? USER_DIRECTORY : dir).toPath();
168
      };
169
    }
170
171
    public void setExportFormat( final ExportFormat exportFormat ) {
172
      assert exportFormat != null;
173
      mExportFormat = exportFormat;
174
    }
175
176
    public void setConcatenate( final Supplier<Boolean> concatenate ) {
177
      mConcatenate = concatenate;
178
    }
179
180
    public void setChapters( final Supplier<String> chapters ) {
181
      mChapters = chapters;
182
    }
183
184
    public void setLocale( final Supplier<Locale> locale ) {
185
      assert locale != null;
186
      mLocale = locale;
187
    }
188
189
    /**
190
     * Sets the list of fully interpolated key-value pairs to use when
191
     * substituting variable names back into the document as variable values.
192
     * This uses a {@link Callable} reference so that GUI and command-line
193
     * usage can insert their respective behaviours. That is, this method
194
     * prevents coupling the GUI to the CLI.
195
     *
196
     * @param supplier Defines how to retrieve the definitions.
197
     */
198
    public void setDefinitions( final Supplier<Map<String, String>> supplier ) {
199
      assert supplier != null;
200
      mDefinitions = supplier;
201
    }
202
203
    /**
204
     * Sets metadata to use in the document header. These are made available
205
     * to the typesetting engine as {@code \documentvariable} values.
206
     *
207
     * @param metadata The key/value pairs to publish as document metadata.
208
     */
209
    public void setMetadata( final Supplier<Map<String, String>> metadata ) {
210
      assert metadata != null;
211
      mMetadata = metadata.get() == null ? HashMap::new : metadata;
212
    }
213
214
    /**
215
     * Sets document variables to use when building the document. These
216
     * variables will override existing key/value pairs, or be added as
217
     * new key/value pairs if not already defined. This allows users to
218
     * inject variables into the document from the command-line, allowing
219
     * for dynamic assignment of in-text values when building documents.
220
     *
221
     * @param overrides The key/value pairs to add (or override) as variables.
222
     */
223
    public void setOverrides( final Supplier<Map<String, String>> overrides ) {
224
      assert overrides != null;
225
      assert mDefinitions != null;
226
      assert mDefinitions.get() != null;
227
228
      final var map = overrides.get();
229
230
      if( map != null ) {
231
        mDefinitions.get().putAll( map );
232
      }
233
    }
234
235
    /**
236
     * Sets the source for deriving the {@link Caret}. Typically, this is
237
     * the text editor that has focus.
238
     *
239
     * @param caret The source for the currently active caret.
240
     */
241
    public void setCaret( final Supplier<Caret> caret ) {
242
      assert caret != null;
243
      mCaret = caret;
244
    }
245
246
    public void setSigilBegan( final Supplier<String> sigilBegan ) {
247
      assert sigilBegan != null;
248
      mSigilBegan = sigilBegan;
249
    }
250
251
    public void setSigilEnded( final Supplier<String> sigilEnded ) {
252
      assert sigilEnded != null;
253
      mSigilEnded = sigilEnded;
254
    }
255
256
    public void setRWorkingDir( final Supplier<Path> rWorkingDir ) {
257
      assert rWorkingDir != null;
258
259
      mRWorkingDir = rWorkingDir;
260
    }
261
262
    public void setRScript( final Supplier<String> rScript ) {
263
      assert rScript != null;
264
      mRScript = rScript;
265
    }
266
267
    public void setCurlQuotes( final Supplier<Boolean> curlQuotes ) {
268
      assert curlQuotes != null;
269
      mCurlQuotes = curlQuotes;
270
    }
271
272
    public void setAutoRemove( final Supplier<Boolean> autoRemove ) {
273
      assert autoRemove != null;
274
      mAutoRemove = autoRemove;
275
    }
276
277
    private boolean isExportFormat( final ExportFormat format ) {
278
      return mExportFormat == format;
279
    }
280
  }
281
282
  public static GenericBuilder<Mutator, ProcessorContext> builder() {
283
    return GenericBuilder.of( Mutator::new, ProcessorContext::new );
284
  }
285
286
  /**
287
   * Creates a new context for use by the {@link ProcessorFactory} when
288
   * instantiating new {@link Processor} instances. Although all the
289
   * parameters are required, not all {@link Processor} instances will use
290
   * all parameters.
291
   */
292
  private ProcessorContext( final Mutator mutator ) {
293
    assert mutator != null;
294
295
    mMutator = mutator;
296
  }
297
298
  public Path getSourcePath() {
299
    return mMutator.mSourcePath;
300
  }
301
302
  /**
303
   * Answers what type of input document is to be processed.
304
   *
305
   * @return The input document's {@link MediaType}.
306
   */
307
  public MediaType getSourceType() {
308
    return MediaTypeExtension.fromPath( mMutator.mSourcePath );
309
  }
310
311
  /**
312
   * Fully qualified file name to use when exporting (e.g., document.pdf).
313
   *
314
   * @return Full path to a file name.
315
   */
316
  public Path getTargetPath() {
317
    return mMutator.mTargetPath;
318
  }
319
320
  public ExportFormat getExportFormat() {
321
    return mMutator.mExportFormat;
322
  }
323
324
  public Locale getLocale() {
325
    return mMutator.mLocale.get();
326
  }
327
328
  /**
329
   * Returns the variable map of definitions, without interpolation.
330
   *
331
   * @return A map to help dereference variables.
332
   */
333
  public Map<String, String> getDefinitions() {
334
    return mMutator.mDefinitions.get();
335
  }
336
337
  /**
338
   * Returns the variable map of definitions, with interpolation.
339
   *
340
   * @return A map to help dereference variables.
341
   */
342
  public InterpolatingMap getInterpolatedDefinitions() {
343
    return new InterpolatingMap(
344
      createDefinitionKeyOperator(), getDefinitions()
345
    ).interpolate();
346
  }
347
348
  public Map<String, String> getMetadata() {
349
    return mMutator.mMetadata.get();
350
  }
351
352
  /**
353
   * Returns the current caret position in the document being edited and is
354
   * always up-to-date.
355
   *
356
   * @return Caret position in the document.
357
   */
358
  public Supplier<Caret> getCaret() {
359
    return mMutator.mCaret;
360
  }
361
362
  /**
363
   * Returns the directory that contains the file being edited. When
364
   * {@link Constants#DOCUMENT_DEFAULT} is created, the parent path is
365
   * {@code null}. This will get absolute path to the file before trying to
366
   * get te parent path, which should always be a valid path. In the unlikely
367
   * event that the base path cannot be determined by the path alone, the
368
   * default user directory is returned. This is necessary for the creation
369
   * of new files.
370
   *
371
   * @return Path to the directory containing a file being edited, or the
372
   * default user directory if the base path cannot be determined.
373
   */
374
  public Path getBaseDir() {
375
    final var path = getSourcePath().toAbsolutePath().getParent();
376
    return path == null ? DEFAULT_DIRECTORY : path;
377
  }
378
379
  FileType getSourceFileType() {
380
    return lookup( getSourcePath() );
381
  }
382
383
  public Path getThemeDir() {
384
    return mMutator.mThemeDir.get();
385
  }
386
387
  public Path getImageDir() {
388
    return mMutator.mImageDir.get();
389
  }
390
391
  public Path getCacheDir() {
392
    return mMutator.mCacheDir.get();
393
  }
394
395
  public Iterable<String> getImageOrder() {
396
    assert mMutator.mImageOrder != null;
397
398
    final var order = mMutator.mImageOrder.get();
399
    final var token = order.contains( "," ) ? ',' : ' ';
400
401
    return Splitter.on( token ).split( token + order );
402
  }
403
404
  public String getImageServer() {
405
    return mMutator.mImageServer.get();
406
  }
407
408
  public Path getFontDir() {
409
    return mMutator.mFontDir.get();
34
import static com.keenwrite.processors.IdentityProcessor.IDENTITY;
35
import static com.keenwrite.processors.text.TextReplacementFactory.replace;
36
37
/**
38
 * Provides a context for configuring a chain of {@link Processor} instances.
39
 */
40
public final class ProcessorContext {
41
42
  private final Mutator mMutator;
43
44
  /**
45
   * Determines the file type from the path extension. This should only be
46
   * called when it is known that the file type won't be a definition file
47
   * (e.g., YAML or other definition source), but rather an editable file
48
   * (e.g., Markdown, R Markdown, etc.).
49
   *
50
   * @param path The path with a file name extension.
51
   * @return The FileType for the given path.
52
   */
53
  private static FileType lookup( final Path path ) {
54
    assert path != null;
55
56
    final var prefix = GLOB_PREFIX_FILE;
57
    final var keys = sSettings.getKeys( prefix );
58
59
    var found = false;
60
    var fileType = UNKNOWN;
61
62
    while( keys.hasNext() && !found ) {
63
      final var key = keys.next();
64
      final var patterns = sSettings.getStringSettingList( key );
65
      final var predicate = createFileTypePredicate( patterns );
66
67
      if( predicate.test( toFile( path ) ) ) {
68
        // Remove the EXTENSIONS_PREFIX to get the file name extension mapped
69
        // to a standard name (as defined in the settings.properties file).
70
        final String suffix = key.replace( prefix + '.', "" );
71
        fileType = FileType.from( suffix );
72
        found = true;
73
      }
74
    }
75
76
    return fileType;
77
  }
78
79
  public boolean isExportFormat( final ExportFormat exportFormat ) {
80
    return mMutator.mExportFormat == exportFormat;
81
  }
82
83
  /**
84
   * Responsible for populating the instance variables required by the
85
   * context.
86
   */
87
  public static class Mutator {
88
    private Path mSourcePath;
89
    private Path mTargetPath;
90
    private ExportFormat mExportFormat;
91
    private Supplier<Boolean> mConcatenate = () -> true;
92
    private Supplier<String> mChapters = () -> "";
93
94
    private Supplier<Path> mThemeDir = USER_DIRECTORY::toPath;
95
    private Supplier<Locale> mLocale = () -> Locale.ENGLISH;
96
97
    private Supplier<Map<String, String>> mDefinitions = HashMap::new;
98
    private Supplier<Map<String, String>> mMetadata = HashMap::new;
99
    private Supplier<Caret> mCaret = () -> Caret.builder().build();
100
101
    private Supplier<Path> mImageDir = USER_DIRECTORY::toPath;
102
    private Supplier<String> mImageServer = () -> DIAGRAM_SERVER_NAME;
103
    private Supplier<String> mImageOrder = () -> PERSIST_IMAGES_DEFAULT;
104
    private Supplier<Path> mCacheDir = USER_CACHE_DIR::toPath;
105
    private Supplier<Path> mFontDir = () -> getFontDirectory().toPath();
106
107
    private Supplier<String> mEnableMode = () -> "";
108
109
    private Supplier<String> mSigilBegan = () -> DEF_DELIM_BEGAN_DEFAULT;
110
    private Supplier<String> mSigilEnded = () -> DEF_DELIM_ENDED_DEFAULT;
111
112
    private Supplier<Path> mRWorkingDir = USER_DIRECTORY::toPath;
113
    private Supplier<String> mRScript = () -> "";
114
115
    private Supplier<Boolean> mCurlQuotes = () -> true;
116
    private Supplier<Boolean> mAutoRemove = () -> true;
117
118
    public void setSourcePath( final Path sourcePath ) {
119
      assert sourcePath != null;
120
      mSourcePath = sourcePath;
121
    }
122
123
    public void setTargetPath( final Path outputPath ) {
124
      assert outputPath != null;
125
      mTargetPath = outputPath;
126
    }
127
128
    public void setThemeDir( final Supplier<Path> themeDir ) {
129
      assert themeDir != null;
130
      mThemeDir = themeDir;
131
    }
132
133
    public void setCacheDir( final Supplier<File> cacheDir ) {
134
      assert cacheDir != null;
135
136
      mCacheDir = () -> {
137
        final var dir = cacheDir.get();
138
139
        return (dir == null ? toFile( USER_DATA_DIR ) : dir).toPath();
140
      };
141
    }
142
143
    public void setImageDir( final Supplier<File> imageDir ) {
144
      assert imageDir != null;
145
146
      mImageDir = () -> {
147
        final var dir = imageDir.get();
148
149
        return (dir == null ? USER_DIRECTORY : dir).toPath();
150
      };
151
    }
152
153
    public void setImageOrder( final Supplier<String> imageOrder ) {
154
      assert imageOrder != null;
155
      mImageOrder = imageOrder;
156
    }
157
158
    public void setImageServer( final Supplier<String> imageServer ) {
159
      assert imageServer != null;
160
      mImageServer = imageServer;
161
    }
162
163
    public void setFontDir( final Supplier<File> fontDir ) {
164
      assert fontDir != null;
165
166
      mFontDir = () -> {
167
        final var dir = fontDir.get();
168
169
        return (dir == null ? USER_DIRECTORY : dir).toPath();
170
      };
171
    }
172
173
    public void setEnableMode( final Supplier<String> enableMode ) {
174
      assert enableMode != null;
175
      mEnableMode = enableMode;
176
    }
177
178
    public void setExportFormat( final ExportFormat exportFormat ) {
179
      assert exportFormat != null;
180
      mExportFormat = exportFormat;
181
    }
182
183
    public void setConcatenate( final Supplier<Boolean> concatenate ) {
184
      mConcatenate = concatenate;
185
    }
186
187
    public void setChapters( final Supplier<String> chapters ) {
188
      mChapters = chapters;
189
    }
190
191
    public void setLocale( final Supplier<Locale> locale ) {
192
      assert locale != null;
193
      mLocale = locale;
194
    }
195
196
    /**
197
     * Sets the list of fully interpolated key-value pairs to use when
198
     * substituting variable names back into the document as variable values.
199
     * This uses a {@link Callable} reference so that GUI and command-line
200
     * usage can insert their respective behaviours. That is, this method
201
     * prevents coupling the GUI to the CLI.
202
     *
203
     * @param supplier Defines how to retrieve the definitions.
204
     */
205
    public void setDefinitions( final Supplier<Map<String, String>> supplier ) {
206
      assert supplier != null;
207
      mDefinitions = supplier;
208
    }
209
210
    /**
211
     * Sets metadata to use in the document header. These are made available
212
     * to the typesetting engine as {@code \documentvariable} values.
213
     *
214
     * @param metadata The key/value pairs to publish as document metadata.
215
     */
216
    public void setMetadata( final Supplier<Map<String, String>> metadata ) {
217
      assert metadata != null;
218
      mMetadata = metadata.get() == null ? HashMap::new : metadata;
219
    }
220
221
    /**
222
     * Sets document variables to use when building the document. These
223
     * variables will override existing key/value pairs, or be added as
224
     * new key/value pairs if not already defined. This allows users to
225
     * inject variables into the document from the command-line, allowing
226
     * for dynamic assignment of in-text values when building documents.
227
     *
228
     * @param overrides The key/value pairs to add (or override) as variables.
229
     */
230
    public void setOverrides( final Supplier<Map<String, String>> overrides ) {
231
      assert overrides != null;
232
      assert mDefinitions != null;
233
      assert mDefinitions.get() != null;
234
235
      final var map = overrides.get();
236
237
      if( map != null ) {
238
        mDefinitions.get().putAll( map );
239
      }
240
    }
241
242
    /**
243
     * Sets the source for deriving the {@link Caret}. Typically, this is
244
     * the text editor that has focus.
245
     *
246
     * @param caret The source for the currently active caret.
247
     */
248
    public void setCaret( final Supplier<Caret> caret ) {
249
      assert caret != null;
250
      mCaret = caret;
251
    }
252
253
    public void setSigilBegan( final Supplier<String> sigilBegan ) {
254
      assert sigilBegan != null;
255
      mSigilBegan = sigilBegan;
256
    }
257
258
    public void setSigilEnded( final Supplier<String> sigilEnded ) {
259
      assert sigilEnded != null;
260
      mSigilEnded = sigilEnded;
261
    }
262
263
    public void setRWorkingDir( final Supplier<Path> rWorkingDir ) {
264
      assert rWorkingDir != null;
265
      mRWorkingDir = rWorkingDir;
266
    }
267
268
    public void setRScript( final Supplier<String> rScript ) {
269
      assert rScript != null;
270
      mRScript = rScript;
271
    }
272
273
    public void setCurlQuotes( final Supplier<Boolean> curlQuotes ) {
274
      assert curlQuotes != null;
275
      mCurlQuotes = curlQuotes;
276
    }
277
278
    public void setAutoRemove( final Supplier<Boolean> autoRemove ) {
279
      assert autoRemove != null;
280
      mAutoRemove = autoRemove;
281
    }
282
283
    private boolean isExportFormat( final ExportFormat format ) {
284
      return mExportFormat == format;
285
    }
286
  }
287
288
  public static GenericBuilder<Mutator, ProcessorContext> builder() {
289
    return GenericBuilder.of( Mutator::new, ProcessorContext::new );
290
  }
291
292
  /**
293
   * Creates a new context for use by the {@link ProcessorFactory} when
294
   * instantiating new {@link Processor} instances. Although all the
295
   * parameters are required, not all {@link Processor} instances will use
296
   * all parameters.
297
   */
298
  private ProcessorContext( final Mutator mutator ) {
299
    assert mutator != null;
300
301
    mMutator = mutator;
302
  }
303
304
  public Path getSourcePath() {
305
    return mMutator.mSourcePath;
306
  }
307
308
  /**
309
   * Answers what type of input document is to be processed.
310
   *
311
   * @return The input document's {@link MediaType}.
312
   */
313
  public MediaType getSourceType() {
314
    return MediaTypeExtension.fromPath( mMutator.mSourcePath );
315
  }
316
317
  /**
318
   * Fully qualified file name to use when exporting (e.g., document.pdf).
319
   *
320
   * @return Full path to a file name.
321
   */
322
  public Path getTargetPath() {
323
    return mMutator.mTargetPath;
324
  }
325
326
  public ExportFormat getExportFormat() {
327
    return mMutator.mExportFormat;
328
  }
329
330
  public Locale getLocale() {
331
    return mMutator.mLocale.get();
332
  }
333
334
  /**
335
   * Returns the variable map of definitions, without interpolation.
336
   *
337
   * @return A map to help dereference variables.
338
   */
339
  public Map<String, String> getDefinitions() {
340
    return mMutator.mDefinitions.get();
341
  }
342
343
  /**
344
   * Returns the variable map of definitions, with interpolation.
345
   *
346
   * @return A map to help dereference variables.
347
   */
348
  public InterpolatingMap getInterpolatedDefinitions() {
349
    return new InterpolatingMap(
350
      createDefinitionKeyOperator(), getDefinitions()
351
    ).interpolate();
352
  }
353
354
  public Map<String, String> getMetadata() {
355
    return mMutator.mMetadata.get();
356
  }
357
358
  /**
359
   * Returns the current caret position in the document being edited and is
360
   * always up-to-date.
361
   *
362
   * @return Caret position in the document.
363
   */
364
  public Supplier<Caret> getCaret() {
365
    return mMutator.mCaret;
366
  }
367
368
  /**
369
   * Returns the directory that contains the file being edited. When
370
   * {@link Constants#DOCUMENT_DEFAULT} is created, the parent path is
371
   * {@code null}. This will get absolute path to the file before trying to
372
   * get te parent path, which should always be a valid path. In the unlikely
373
   * event that the base path cannot be determined by the path alone, the
374
   * default user directory is returned. This is necessary for the creation
375
   * of new files.
376
   *
377
   * @return Path to the directory containing a file being edited, or the
378
   * default user directory if the base path cannot be determined.
379
   */
380
  public Path getBaseDir() {
381
    final var path = getSourcePath().toAbsolutePath().getParent();
382
    return path == null ? DEFAULT_DIRECTORY : path;
383
  }
384
385
  FileType getSourceFileType() {
386
    return lookup( getSourcePath() );
387
  }
388
389
  public Path getThemeDir() {
390
    return mMutator.mThemeDir.get();
391
  }
392
393
  public Path getImageDir() {
394
    return mMutator.mImageDir.get();
395
  }
396
397
  public Path getCacheDir() {
398
    return mMutator.mCacheDir.get();
399
  }
400
401
  public Iterable<String> getImageOrder() {
402
    assert mMutator.mImageOrder != null;
403
404
    final var order = mMutator.mImageOrder.get();
405
    final var token = order.contains( "," ) ? ',' : ' ';
406
407
    return Splitter.on( token ).split( token + order );
408
  }
409
410
  public String getImageServer() {
411
    return mMutator.mImageServer.get();
412
  }
413
414
  public Path getFontDir() {
415
    return mMutator.mFontDir.get();
416
  }
417
418
  public String getEnableMode() {
419
    final var processor = new VariableProcessor( IDENTITY, this );
420
    final var needles = processor.getDefinitions();
421
    final var haystack = mMutator.mEnableMode.get();
422
    final var result = replace( haystack, needles );
423
424
    // If no replacement was made, then the mode variable isn't set.
425
    return result.equals( haystack ) ? "" : result;
410426
  }
411427
M src/main/java/com/keenwrite/processors/text/StringUtilsReplacer.java
22
package com.keenwrite.processors.text;
33
4
import org.apache.commons.lang3.StringUtils;
5
64
import java.util.Map;
75
8
import static org.apache.commons.lang3.StringUtils.replaceEach;
6
import static com.keenwrite.util.Strings.replaceEach;
97
108
/**
11
 * Replaces text using a brute-force
12
 * {@link StringUtils#replaceEach(String, String[], String[])}} method.
9
 * Replaces text using a brute-force replacement method.
1310
 */
1411
public class StringUtilsReplacer extends AbstractTextReplacer {
1512
1613
  /**
1714
   * Default (empty) constructor.
1815
   */
19
  protected StringUtilsReplacer() { }
16
  protected StringUtilsReplacer() {}
2017
2118
  @Override
M src/main/java/com/keenwrite/typesetting/GuestTypesetter.java
3333
3434
  private static final String TYPESETTER_VERSION =
35
    TYPESETTER_EXE + " --version > /dev/null";
35
    STR."\{TYPESETTER_EXE} --version > /dev/null";
3636
3737
  public GuestTypesetter( final Mutator mutator ) {
...
100100
        manager.run(
101101
          input -> gobble( input, s -> exitCode.append( s.trim() ) ),
102
          TYPESETTER_VERSION + "; echo $?"
102
          STR."\{TYPESETTER_VERSION}; echo $?"
103103
        );
104104
M src/main/java/com/keenwrite/typesetting/Typesetter.java
2828
 * ({@link GuestTypesetter}).
2929
 */
30
@SuppressWarnings( "SpellCheckingInspection" )
3031
public class Typesetter {
3132
  /**
...
4546
    private Path mCacheDir = USER_CACHE_DIR.toPath();
4647
    private Path mFontDir = getFontDirectory().toPath();
48
    private String mEnableMode = "";
4749
    private boolean mAutoRemove;
4850
...
8890
    public void setFontDir( final Path fontDir ) {
8991
      mFontDir = fontDir;
92
    }
93
94
    public void setEnableMode( final String enableMode ) {
95
      mEnableMode = enableMode;
9096
    }
9197
...
120126
    public Path getFontDir() {
121127
      return mFontDir;
128
    }
129
130
    public String getEnableMode() {
131
      return mEnableMode;
122132
    }
123133
...
153163
154164
    final var outputPath = getTargetPath();
155
    final var prefix = "Main.status.typeset";
165
    final var prefix = "Main.status.typeset.";
156166
157
    clue( prefix + ".began", outputPath );
167
    clue( STR."\{prefix}began", outputPath );
158168
159169
    final var time = currentTimeMillis();
160170
    final var success = typesetter.call();
161
    final var suffix = success ? ".success" : ".failure";
171
    final var suffix = success ? "success" : "failure";
162172
163
    clue( prefix + ".ended" + suffix, outputPath, since( time ) );
173
    clue( STR."\{prefix}ended.\{suffix}", outputPath, since( time ) );
164174
  }
165175
166176
  /**
167
   * Generates the command-line arguments used to invoke the typesetter.
177
   * Generates command-line arguments used to invoke the typesetter.
168178
   */
169179
  @SuppressWarnings( "SpellCheckingInspection" )
...
184194
    args.add( format( "--result='%s'", targetPath ) );
185195
    args.add( sourcePath );
196
197
    final var enableMode = getEnableMode();
198
199
    if( !enableMode.isBlank() ) {
200
      args.add( format( "--mode=%s", enableMode ) );
201
    }
186202
187203
    return args;
188204
  }
189205
190
  @SuppressWarnings( "SpellCheckingInspection" )
191206
  List<String> commonOptions() {
192207
    final var args = new LinkedList<String>();
...
225240
  protected Path getFontDir() {
226241
    return mMutator.getFontDir();
242
  }
243
244
  protected String getEnableMode() {
245
    return mMutator.getEnableMode();
227246
  }
228247
M src/main/java/com/keenwrite/typesetting/containerization/Podman.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2023 White Magic Software, Ltd. -- All rights reserved.
2
 *
3
 * SPDX-License-Identifier: MIT
4
 */
25
package com.keenwrite.typesetting.containerization;
36
...
1417
import static com.keenwrite.events.StatusEvent.clue;
1518
import static com.keenwrite.io.SysFile.toFile;
19
import static com.keenwrite.util.SystemUtils.IS_OS_WINDOWS;
1620
import static java.lang.String.format;
1721
import static java.lang.String.join;
1822
import static java.lang.System.arraycopy;
1923
import static java.util.Arrays.copyOf;
20
import static org.apache.commons.lang3.SystemUtils.IS_OS_WINDOWS;
2124
2225
/**
M src/main/java/com/keenwrite/typesetting/installer/TypesetterInstaller.java
1616
import static com.keenwrite.Messages.get;
1717
import static com.keenwrite.events.Bus.register;
18
import static org.apache.commons.lang3.SystemUtils.*;
18
import static com.keenwrite.util.SystemUtils.*;
1919
2020
/**
M src/main/java/com/keenwrite/typesetting/installer/panes/AbstractDownloadPane.java
2020
import static com.keenwrite.events.StatusEvent.clue;
2121
import static com.keenwrite.io.SysFile.toFile;
22
import static com.keenwrite.io.downloads.DownloadManager.downloadAsync;
23
import static com.keenwrite.io.downloads.DownloadManager.toFilename;
2224
2325
/**
...
3335
3436
  public AbstractDownloadPane() {
35
    mUri = getUri( getPrefix() + ".download.link.url" );
37
    mUri = getUri( STR."\{getPrefix()}.download.link.url" );
3638
    mFilename = toFilename( mUri );
3739
    final var directory = USER_DATA_DIR;
3840
    mTarget = toFile( directory.resolve( mFilename ) );
39
    final var source = labelf( getPrefix() + ".paths", mFilename, directory );
40
    mStatus = labelf( getPrefix() + STATUS + ".progress", 0, 0 );
41
    final var source = labelf( STR."\{getPrefix()}.paths", mFilename, directory );
42
    mStatus = labelf( STR."\{getPrefix()}\{STATUS}.progress", 0, 0 );
4143
4244
    final var border = new BorderPane();
...
7173
      final var suffix = checksumOk ? ".ok" : ".no";
7274
73
      updateStatus( STATUS + ".checksum" + suffix, mFilename );
75
      updateStatus( STR."\{STATUS}.checksum\{suffix}", mFilename );
7476
      disableNext( !checksumOk );
7577
    }
...
8587
      properties.put( threadName, task );
8688
87
      task.setOnSucceeded( e -> onDownloadSucceeded( threadName, properties ) );
88
      task.setOnFailed( e -> onDownloadFailed( threadName, properties ) );
89
      task.setOnCancelled( e -> onDownloadFailed( threadName, properties ) );
89
      task.setOnSucceeded( _ -> onDownloadSucceeded( threadName, properties ) );
90
      task.setOnFailed( _ -> onDownloadFailed( threadName, properties ) );
91
      task.setOnCancelled( _ -> onDownloadFailed( threadName, properties ) );
9092
    }
9193
  }
9294
9395
  protected void updateProperties(
9496
    final ObservableMap<Object, Object> properties ) {
9597
  }
9698
9799
  @Override
98100
  protected String getHeaderKey() {
99
    return getPrefix() + ".header";
101
    return STR."\{getPrefix()}.header";
100102
  }
101103
...
110112
  protected void onDownloadSucceeded(
111113
    final String threadName, final ObservableMap<Object, Object> properties ) {
112
    updateStatus( STATUS + ".success" );
114
    updateStatus( STR."\{STATUS}.success" );
113115
    properties.remove( threadName );
114116
    disableNext( false );
115117
  }
116118
117119
  protected void onDownloadFailed(
118120
    final String threadName, final ObservableMap<Object, Object> properties ) {
119
    updateStatus( STATUS + ".failure" );
121
    updateStatus( STR."\{STATUS}.failure" );
120122
    properties.remove( threadName );
121123
  }
M src/main/java/com/keenwrite/typesetting/installer/panes/InstallerPane.java
66
77
import com.keenwrite.events.HyperlinkOpenEvent;
8
import com.keenwrite.io.downloads.DownloadManager;
9
import com.keenwrite.io.downloads.DownloadManager.ProgressListener;
108
import com.keenwrite.typesetting.containerization.ContainerManager;
119
import com.keenwrite.typesetting.containerization.Podman;
1210
import javafx.animation.Animation;
1311
import javafx.animation.RotateTransition;
14
import javafx.concurrent.Task;
1512
import javafx.geometry.Insets;
1613
import javafx.scene.Node;
1714
import javafx.scene.control.*;
1815
import javafx.scene.image.ImageView;
1916
import javafx.scene.layout.BorderPane;
2017
import javafx.scene.layout.FlowPane;
2118
import javafx.scene.layout.Pane;
2219
import org.controlsfx.dialog.Wizard;
2320
import org.controlsfx.dialog.WizardPane;
24
25
import java.io.File;
26
import java.net.URI;
27
import java.nio.file.Paths;
28
import java.util.concurrent.Callable;
2921
3022
import static com.keenwrite.Messages.get;
3123
import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG;
32
import static com.keenwrite.io.SysFile.toFile;
24
import static com.keenwrite.io.downloads.DownloadManager.createTask;
25
import static com.keenwrite.io.downloads.DownloadManager.createThread;
3326
import static java.lang.System.lineSeparator;
3427
import static javafx.animation.Interpolator.LINEAR;
...
127120
128121
      if( buttonData.equals( NEXT_FORWARD ) &&
129
        lookupButton( buttonType ) instanceof Button button ) {
122
          lookupButton( buttonType ) instanceof Button button ) {
130123
        return button;
131124
      }
...
223216
224217
  static Hyperlink hyperlink( final String prefix ) {
225
    final var label = get( prefix + ".lbl" );
226
    final var url = get( prefix + ".url" );
218
    final var label = get( STR."\{prefix}.lbl" );
219
    final var url = get( STR."\{prefix}.url" );
227220
    final var link = new Hyperlink( label );
228221
229
    link.setOnAction( e -> browse( url ) );
222
    link.setOnAction( _ -> browse( url ) );
230223
    link.setTooltip( new Tooltip( url ) );
231224
...
249242
250243
    thread.start();
251
  }
252
253
  static <T> Task<T> createTask( final Callable<T> callable ) {
254
    return new Task<>() {
255
      @Override
256
      protected T call() throws Exception {
257
        return callable.call();
258
      }
259
    };
260
  }
261
262
  static <T> Thread createThread( final Task<T> task ) {
263
    final var thread = new Thread( task );
264
    thread.setDaemon( true );
265
    return thread;
266244
  }
267245
...
284262
      node.appendText( text );
285263
      node.appendText( lineSeparator() );
286
    } );
287
  }
288
289
  /**
290
   * Downloads a resource to a local file in a separate {@link Thread}.
291
   *
292
   * @param uri      The resource to download.
293
   * @param file     The destination mTarget for the resource.
294
   * @param listener Receives updates as the download proceeds.
295
   */
296
  static Task<Void> downloadAsync(
297
    final URI uri,
298
    final File file,
299
    final ProgressListener listener ) {
300
    final Task<Void> task = createTask( () -> {
301
      try( final var token = DownloadManager.open( uri ) ) {
302
        token.download( file, listener ).run();
303
      }
304
305
      return null;
306264
    } );
307
308
    createThread( task ).start();
309
    return task;
310
  }
311
312
  static String toFilename( final URI uri ) {
313
    return toFile( Paths.get( uri.getPath() ) ).getName();
314265
  }
315266
}
M src/main/java/com/keenwrite/typesetting/installer/panes/ManagerOutputPane.java
1
/* Copyright 2023 White Magic Software, Ltd. -- All rights reserved.
2
 *
3
 * SPDX-License-Identifier: MIT
4
 */
15
package com.keenwrite.typesetting.installer.panes;
26
37
import com.keenwrite.io.CommandNotFoundException;
8
import com.keenwrite.io.downloads.DownloadManager;
49
import com.keenwrite.typesetting.containerization.ContainerManager;
510
import com.keenwrite.typesetting.containerization.StreamProcessor;
11
import com.keenwrite.util.FailableBiConsumer;
12
import javafx.collections.ObservableMap;
613
import javafx.concurrent.Task;
714
import javafx.scene.control.TextArea;
815
import javafx.scene.layout.BorderPane;
9
import org.apache.commons.lang3.function.FailableBiConsumer;
1016
import org.controlsfx.dialog.Wizard;
1117
1218
import static com.keenwrite.Messages.get;
1319
import static com.keenwrite.io.StreamGobbler.gobble;
20
import static com.keenwrite.io.downloads.DownloadManager.createThread;
1421
1522
/**
1623
 * Responsible for showing the output from running commands against a container
1724
 * manager. There are a few installation steps that run different commands
1825
 * against the installer, which are platform-specific and cannot be merged.
1926
 * Common functionality between them is codified in this class.
2027
 */
2128
public abstract class ManagerOutputPane extends InstallerPane {
22
  private final String PROP_EXECUTOR = getClass().getCanonicalName();
29
  private final static String PROP_EXECUTOR =
30
    ManagerOutputPane.class.getCanonicalName();
2331
2432
  private final String mCorrectKey;
...
6068
        return;
6169
      }
62
63
      final Task<Void> task = createTask( () -> {
64
        mFc.accept(
65
          mContainer,
66
          input -> gobble( input, line -> append( mTextArea, line ) )
67
        );
68
        properties.remove( thread );
69
        return null;
70
      } );
71
72
      task.setOnSucceeded( event -> {
73
        append( mTextArea, get( mCorrectKey ) );
74
        properties.remove( thread );
75
        disableNext( false );
76
      } );
77
      task.setOnFailed( event -> append( mTextArea, get( mMissingKey ) ) );
78
      task.setOnCancelled( event -> append( mTextArea, get( mMissingKey ) ) );
7970
71
      final var task = createTask( properties, thread );
8072
      final var executor = createThread( task );
73
8174
      properties.put( PROP_EXECUTOR, executor );
8275
      executor.start();
8376
    } catch( final Exception e ) {
8477
      throw new RuntimeException( e );
8578
    }
79
  }
80
81
  private Task<Void> createTask(
82
    final ObservableMap<Object, Object> properties,
83
    final Object thread ) {
84
    final Task<Void> task = DownloadManager.createTask( () -> {
85
      mFc.accept(
86
        mContainer,
87
        input -> gobble( input, line -> append( mTextArea, line ) )
88
      );
89
      properties.remove( thread );
90
      return null;
91
    } );
92
93
    task.setOnSucceeded( _ -> {
94
      append( mTextArea, get( mCorrectKey ) );
95
      properties.remove( thread );
96
      disableNext( false );
97
    } );
98
    task.setOnFailed( _ -> append( mTextArea, get( mMissingKey ) ) );
99
    task.setOnCancelled( _ -> append( mTextArea, get( mMissingKey ) ) );
100
    return task;
86101
  }
87102
}
M src/main/java/com/keenwrite/typesetting/installer/panes/UnixManagerInstallPane.java
1414
import javafx.scene.layout.HBox;
1515
import javafx.scene.layout.VBox;
16
import org.jetbrains.annotations.NotNull;
1716
1817
import static com.keenwrite.Messages.get;
1918
import static com.keenwrite.Messages.getInt;
19
import static com.keenwrite.util.SystemUtils.IS_OS_MAC;
2020
import static java.lang.String.format;
21
import static org.apache.commons.lang3.SystemUtils.IS_OS_MAC;
2221
2322
public final class UnixManagerInstallPane extends InstallerPane {
...
8887
    final var node = super.createButtonBar();
8988
    final var layout = new BorderPane();
90
    final var copyButton = button( PREFIX + ".copy.began" );
89
    final var copyButton = button( STR."\{PREFIX}.copy.began" );
9190
9291
    // Change the label to indicate clipboard is updated.
93
    copyButton.setOnAction( event -> {
92
    copyButton.setOnAction( _ -> {
9493
      SystemClipboard.write( mCommands.getText() );
95
      copyButton.setText( get( PREFIX + ".copy.ended" ) );
94
      copyButton.setText( get( STR."\{PREFIX}.copy.ended" ) );
9695
    } );
9796
...
109108
  @Override
110109
  protected String getHeaderKey() {
111
    return PREFIX + ".header";
110
    return STR."\{PREFIX}.header";
112111
  }
113112
114113
  private record UnixOsCommand( String name, String command )
115114
    implements Comparable<UnixOsCommand> {
116115
    @Override
117
    public int compareTo(
118
      final @NotNull UnixOsCommand other ) {
116
    public int compareTo( final UnixOsCommand other ) {
119117
      return toString().compareToIgnoreCase( other.toString() );
120118
    }
...
136134
    final var comboBox = new ComboBox<UnixOsCommand>();
137135
    final var items = comboBox.getItems();
138
    final var prefix = PREFIX + ".command";
139
    final var distros = getInt( prefix + ".distros", 14 );
136
    final var prefix = STR."\{PREFIX}.command";
137
    final var distros = getInt( STR."\{prefix}.distros", 14 );
140138
141139
    for( int i = 1; i <= distros; i++ ) {
142140
      final var suffix = format( ".%02d", i );
143
      final var name = get( prefix + ".os.name" + suffix );
144
      final var command = get( prefix + ".os.text" + suffix );
141
      final var name = get( STR."\{prefix}.os.name\{suffix}" );
142
      final var command = get( STR."\{prefix}.os.text\{suffix}" );
145143
146144
      items.add( new UnixOsCommand( name, command ) );
M src/main/java/com/keenwrite/typesetting/installer/panes/WindowsManagerInstallPane.java
1414
1515
import static com.keenwrite.Messages.get;
16
import static com.keenwrite.io.downloads.DownloadManager.createTask;
17
import static com.keenwrite.io.downloads.DownloadManager.createThread;
1618
1719
/**
...
3941
4042
    final var titledPane = titledPane( "Output", mCommands );
41
    append( mCommands, get( PREFIX + ".status.running" ) );
43
    append( mCommands, get( STR."\{PREFIX}.status.running" ) );
4244
4345
    final var stepsPane = new VBox();
4446
    final var steps = stepsPane.getChildren();
45
    steps.add( label( PREFIX + ".step.0" ) );
47
    steps.add( label( STR."\{PREFIX}.step.0" ) );
4648
    steps.add( spacer() );
47
    steps.add( label( PREFIX + ".step.1" ) );
48
    steps.add( label( PREFIX + ".step.2" ) );
49
    steps.add( label( PREFIX + ".step.3" ) );
49
    steps.add( label( STR."\{PREFIX}.step.1" ) );
50
    steps.add( label( STR."\{PREFIX}.step.2" ) );
51
    steps.add( label( STR."\{PREFIX}.step.3" ) );
5052
    steps.add( spacer() );
5153
    steps.add( titledPane );
...
7072
7173
    final var binary = properties.get( WIN_BIN );
72
    final var key = PREFIX + ".status";
74
    final var key = STR."\{PREFIX}.status";
7375
7476
    if( binary instanceof File exe ) {
7577
      final var task = createTask( () -> {
7678
        final var exit = mContainer.install( exe );
7779
7880
        // Remove the installer after installation is finished.
7981
        properties.remove( thread );
8082
8183
        final var msg = exit == 0
82
          ? get( key + ".success" )
83
          : get( key + ".failure", exit );
84
          ? get( STR."\{key}.success" )
85
          : get( STR."\{key}.failure", exit );
8486
8587
        append( mCommands, msg );
...
9496
    }
9597
    else {
96
      append( mCommands, get( PREFIX + ".unknown", binary ) );
98
      append( mCommands, get( STR."\{PREFIX}.unknown", binary ) );
9799
    }
98100
  }
99101
100102
  @Override
101103
  public String getHeaderKey() {
102
    return PREFIX + ".header";
104
    return STR."\{PREFIX}.header";
103105
  }
104106
}
M src/main/java/com/keenwrite/ui/actions/ApplicationBars.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2023 White Magic Software, Ltd. -- All rights reserved.
2
 *
3
 * SPDX-License-Identifier: MIT
4
 */
25
package com.keenwrite.ui.actions;
36
...
6164
      addAction( "file.new", _ -> actions.file_new() ),
6265
      addAction( "file.open", _ -> actions.file_open() ),
66
      addAction( "file.open_url", _ -> actions.file_open_url() ),
6367
      SEPARATOR,
6468
      addAction( "file.close", _ -> actions.file_close() ),
M src/main/java/com/keenwrite/ui/actions/GuiCommands.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.ui.actions;
3
4
import com.keenwrite.ExportFormat;
5
import com.keenwrite.MainPane;
6
import com.keenwrite.MainScene;
7
import com.keenwrite.commands.ConcatenateCommand;
8
import com.keenwrite.editors.TextDefinition;
9
import com.keenwrite.editors.TextEditor;
10
import com.keenwrite.editors.markdown.HyperlinkModel;
11
import com.keenwrite.editors.markdown.LinkVisitor;
12
import com.keenwrite.events.CaretMovedEvent;
13
import com.keenwrite.events.ExportFailedEvent;
14
import com.keenwrite.io.SysFile;
15
import com.keenwrite.preferences.Key;
16
import com.keenwrite.preferences.PreferencesController;
17
import com.keenwrite.preferences.Workspace;
18
import com.keenwrite.processors.markdown.MarkdownProcessor;
19
import com.keenwrite.search.SearchModel;
20
import com.keenwrite.typesetting.Typesetter;
21
import com.keenwrite.ui.controls.SearchBar;
22
import com.keenwrite.ui.dialogs.ExportDialog;
23
import com.keenwrite.ui.dialogs.ExportSettings;
24
import com.keenwrite.ui.dialogs.ImageDialog;
25
import com.keenwrite.ui.dialogs.LinkDialog;
26
import com.keenwrite.ui.explorer.FilePicker;
27
import com.keenwrite.ui.explorer.FilePickerFactory;
28
import com.keenwrite.ui.logging.LogView;
29
import com.vladsch.flexmark.ast.Link;
30
import javafx.concurrent.Service;
31
import javafx.concurrent.Task;
32
import javafx.scene.control.Alert;
33
import javafx.scene.control.Dialog;
34
import javafx.stage.Window;
35
import javafx.stage.WindowEvent;
36
37
import java.io.File;
38
import java.nio.file.Path;
39
import java.util.List;
40
import java.util.Optional;
41
42
import static com.keenwrite.Bootstrap.*;
43
import static com.keenwrite.ExportFormat.*;
44
import static com.keenwrite.Messages.get;
45
import static com.keenwrite.constants.Constants.PDF_DEFAULT;
46
import static com.keenwrite.constants.Constants.USER_DIRECTORY;
47
import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG_NODE;
48
import static com.keenwrite.events.StatusEvent.clue;
49
import static com.keenwrite.preferences.AppKeys.*;
50
import static com.keenwrite.processors.ProcessorFactory.createProcessors;
51
import static com.keenwrite.ui.explorer.FilePickerFactory.SelectionType;
52
import static com.keenwrite.ui.explorer.FilePickerFactory.SelectionType.*;
53
import static java.nio.charset.StandardCharsets.UTF_8;
54
import static java.nio.file.Files.writeString;
55
import static javafx.application.Platform.runLater;
56
import static javafx.event.Event.fireEvent;
57
import static javafx.scene.control.Alert.AlertType.INFORMATION;
58
import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST;
59
import static org.apache.commons.io.FilenameUtils.getExtension;
60
61
/**
62
 * Responsible for abstracting how functionality is mapped to the application.
63
 * This allows users to customize accelerator keys and will provide pluggable
64
 * functionality so that different text markup languages can change documents
65
 * using their respective syntax.
66
 */
67
public final class GuiCommands {
68
  private static final String STYLE_SEARCH = "search";
69
70
  /**
71
   * When an action is executed, this is one of the recipients.
72
   */
73
  private final MainPane mMainPane;
74
75
  private final MainScene mMainScene;
76
77
  private final LogView mLogView;
78
79
  /**
80
   * Tracks finding text in the active document.
81
   */
82
  private final SearchModel mSearchModel;
83
84
  private boolean mCanTypeset;
85
86
  /**
87
   * A {@link Task} can only be run once, so wrap it in a {@link Service} to
88
   * allow re-launching the typesetting task repeatedly.
89
   */
90
  private Service<Path> mTypesetService;
91
92
  /**
93
   * Prevent a race-condition between checking to see if the typesetting task
94
   * is running and restarting the task itself.
95
   */
96
  private final Object mMutex = new Object();
97
98
  public GuiCommands( final MainScene scene, final MainPane pane ) {
99
    mMainScene = scene;
100
    mMainPane = pane;
101
    mLogView = new LogView();
102
    mSearchModel = new SearchModel();
103
    mSearchModel.matchOffsetProperty().addListener( ( c, o, n ) -> {
104
      final var editor = getActiveTextEditor();
105
106
      // Clear highlighted areas before highlighting a new region.
107
      if( o != null ) {
108
        editor.unstylize( STYLE_SEARCH );
109
      }
110
111
      if( n != null ) {
112
        editor.moveTo( n.getStart() );
113
        editor.stylize( n, STYLE_SEARCH );
114
      }
115
    } );
116
117
    // When the active text editor changes ...
118
    mMainPane.textEditorProperty().addListener(
119
      ( c, o, n ) -> {
120
        // ... update the haystack.
121
        mSearchModel.search( getActiveTextEditor().getText() );
122
123
        // ... update the status bar with the current caret position.
124
        if( n != null ) {
125
          final var w = getWorkspace();
126
          final var recentDoc = w.fileProperty( KEY_UI_RECENT_DOCUMENT );
127
128
          // ... preserve the most recent document.
129
          recentDoc.setValue( n.getFile() );
130
          CaretMovedEvent.fire( n.getCaret() );
131
        }
132
      }
133
    );
134
  }
135
136
  public void file_new() {
137
    getMainPane().newTextEditor();
138
  }
139
140
  public void file_open() {
141
    pickFiles( FILE_OPEN_MULTIPLE ).ifPresent( l -> getMainPane().open( l ) );
142
  }
143
144
  public void file_close() {
145
    getMainPane().close();
146
  }
147
148
  public void file_close_all() {
149
    getMainPane().closeAll();
150
  }
151
152
  public void file_save() {
153
    getMainPane().save();
154
  }
155
156
  public void file_save_as() {
157
    pickFiles( FILE_SAVE_AS ).ifPresent( l -> getMainPane().saveAs( l ) );
158
  }
159
160
  public void file_save_all() {
161
    getMainPane().saveAll();
162
  }
163
164
  /**
165
   * Converts the actively edited file in the given file format.
166
   *
167
   * @param format The destination file format.
168
   */
169
  private void file_export( final ExportFormat format ) {
170
    file_export( format, false );
171
  }
172
173
  /**
174
   * Converts one or more files into the given file format. If {@code dir}
175
   * is set to true, this will first append all files in the same directory
176
   * as the actively edited file.
177
   *
178
   * @param format The destination file format.
179
   * @param dir    Export all files in the actively edited file's directory.
180
   */
181
  private void file_export( final ExportFormat format, final boolean dir ) {
182
    final var editor = getMainPane().getTextEditor();
183
    final var exported = getWorkspace().fileProperty( KEY_UI_RECENT_EXPORT );
184
    final var exportParent = exported.get().toPath().getParent();
185
    final var editorParent = editor.getPath().getParent();
186
    final var userHomeParent = USER_DIRECTORY.toPath();
187
    final var exportPath = exportParent != null
188
      ? exportParent
189
      : editorParent != null
190
      ? editorParent
191
      : userHomeParent;
192
193
    final var filename = format.toExportFilename( editor.getPath() );
194
    final var selected = PDF_DEFAULT
195
      .getName()
196
      .equals( exported.get().getName() );
197
    final var selection = pickFile(
198
      selected
199
        ? filename
200
        : exported.get(),
201
      exportPath,
202
      FILE_EXPORT
203
    );
204
205
    selection.ifPresent( files -> file_export( editor, format, files, dir ) );
206
  }
207
208
  private void file_export(
209
    final TextEditor editor,
210
    final ExportFormat format,
211
    final List<File> files,
212
    final boolean dir ) {
213
    editor.save();
214
    final var main = getMainPane();
215
    final var exported = getWorkspace().fileProperty( KEY_UI_RECENT_EXPORT );
216
217
    final var sourceFile = files.get( 0 );
218
    final var sourcePath = sourceFile.toPath();
219
    final var document = dir ? append( editor ) : editor.getText();
220
    final var context = main.createProcessorContext( sourcePath, format );
221
222
    final var service = new Service<Path>() {
223
      @Override
224
      protected Task<Path> createTask() {
225
        final var task = new Task<Path>() {
226
          @Override
227
          protected Path call() throws Exception {
228
            final var chain = createProcessors( context );
229
            final var export = chain.apply( document );
230
231
            // Processors can export binary files. In such cases, processors
232
            // return null to prevent further processing.
233
            return export == null
234
              ? null
235
              : writeString( sourcePath, export, UTF_8 );
236
          }
237
        };
238
239
        task.setOnSucceeded(
240
          e -> {
241
            // Remember the exported file name for next time.
242
            exported.setValue( sourceFile );
243
244
            final var result = task.getValue();
245
246
            // Binary formats must notify users of success independently.
247
            if( result != null ) {
248
              clue( "Main.status.export.success", result );
249
            }
250
          }
251
        );
252
253
        task.setOnFailed( e -> {
254
          final var ex = task.getException();
255
          clue( ex );
256
257
          if( ex instanceof TypeNotPresentException ) {
258
            fireExportFailedEvent();
259
          }
260
        } );
261
262
        return task;
263
      }
264
    };
265
266
    mTypesetService = service;
267
    typeset( service );
268
  }
269
270
  /**
271
   * @param dir {@code true} means to export all files in the active file
272
   *            editor's directory; {@code false} means to export only the
273
   *            actively edited file.
274
   */
275
  private void file_export_pdf( final boolean dir ) {
276
    // Don't re-validate the typesetter installation each time. If the
277
    // user mucks up the typesetter installation, it'll get caught the
278
    // next time the application is started. Don't use |= because it
279
    // won't short-circuit.
280
    mCanTypeset = mCanTypeset || Typesetter.canRun();
281
282
    if( mCanTypeset ) {
283
      final var workspace = getWorkspace();
284
      final var theme = workspace.stringProperty(
285
        KEY_TYPESET_CONTEXT_THEME_SELECTION
286
      );
287
      final var chapters = workspace.stringProperty(
288
        KEY_TYPESET_CONTEXT_CHAPTERS
289
      );
290
291
      final var settings = ExportSettings
292
        .builder()
293
        .with( ExportSettings.Mutator::setTheme, theme )
294
        .with( ExportSettings.Mutator::setChapters, chapters )
295
        .build();
296
297
      final var themes = workspace.getFile(
298
        KEY_TYPESET_CONTEXT_THEMES_PATH
299
      );
300
301
      // If the typesetter is installed, allow the user to select a theme. If
302
      // the themes aren't installed, a status message will appear.
303
      if( ExportDialog.choose( getWindow(), themes, settings, dir ) ) {
304
        file_export( APPLICATION_PDF, dir );
305
      }
306
    }
307
    else {
308
      fireExportFailedEvent();
309
    }
310
  }
311
312
  public void file_export_pdf() {
313
    file_export_pdf( false );
314
  }
315
316
  public void file_export_pdf_dir() {
317
    file_export_pdf( true );
318
  }
319
320
  public void file_export_html_dir() {
321
    file_export( XHTML_TEX, true );
322
  }
323
324
  public void file_export_repeat() {
325
    typeset( mTypesetService );
326
  }
327
328
  public void file_export_html_svg() {
329
    file_export( HTML_TEX_SVG );
330
  }
331
332
  public void file_export_html_tex() {
333
    file_export( HTML_TEX_DELIMITED );
334
  }
335
336
  public void file_export_xhtml_tex() {
337
    file_export( XHTML_TEX );
338
  }
339
340
  private void fireExportFailedEvent() {
341
    runLater( ExportFailedEvent::fire );
342
  }
343
344
  public void file_exit() {
345
    final var window = getWindow();
346
    fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) );
347
  }
348
349
  public void edit_undo() {
350
    getActiveTextEditor().undo();
351
  }
352
353
  public void edit_redo() {
354
    getActiveTextEditor().redo();
355
  }
356
357
  public void edit_cut() {
358
    getActiveTextEditor().cut();
359
  }
360
361
  public void edit_copy() {
362
    getActiveTextEditor().copy();
363
  }
364
365
  public void edit_paste() {
366
    getActiveTextEditor().paste();
367
  }
368
369
  public void edit_select_all() {
370
    getActiveTextEditor().selectAll();
371
  }
372
373
  public void edit_find() {
374
    final var nodes = getMainScene().getStatusBar().getLeftItems();
375
376
    if( nodes.isEmpty() ) {
377
      final var searchBar = new SearchBar();
378
379
      searchBar.matchIndexProperty().bind( mSearchModel.matchIndexProperty() );
380
      searchBar.matchCountProperty().bind( mSearchModel.matchCountProperty() );
381
382
      searchBar.setOnCancelAction( event -> {
383
        final var editor = getActiveTextEditor();
384
        nodes.remove( searchBar );
385
        editor.unstylize( STYLE_SEARCH );
386
        editor.getNode().requestFocus();
387
      } );
388
389
      searchBar.addInputListener( ( c, o, n ) -> {
390
        if( n != null && !n.isEmpty() ) {
391
          mSearchModel.search( n, getActiveTextEditor().getText() );
392
        }
393
      } );
394
395
      searchBar.setOnNextAction( event -> edit_find_next() );
396
      searchBar.setOnPrevAction( event -> edit_find_prev() );
397
398
      nodes.add( searchBar );
399
      searchBar.requestFocus();
400
    }
401
  }
402
403
  public void edit_find_next() {
404
    mSearchModel.advance();
405
  }
406
407
  public void edit_find_prev() {
408
    mSearchModel.retreat();
409
  }
410
411
  public void edit_preferences() {
412
    try {
413
      new PreferencesController( getWorkspace() ).show();
414
    } catch( final Exception ex ) {
415
      clue( ex );
416
    }
417
  }
418
419
  public void format_bold() {
420
    getActiveTextEditor().bold();
421
  }
422
423
  public void format_italic() {
424
    getActiveTextEditor().italic();
425
  }
426
427
  public void format_monospace() {
428
    getActiveTextEditor().monospace();
429
  }
430
431
  public void format_superscript() {
432
    getActiveTextEditor().superscript();
433
  }
434
435
  public void format_subscript() {
436
    getActiveTextEditor().subscript();
437
  }
438
439
  public void format_strikethrough() {
440
    getActiveTextEditor().strikethrough();
441
  }
442
443
  public void insert_blockquote() {
444
    getActiveTextEditor().blockquote();
445
  }
446
447
  public void insert_code() {
448
    getActiveTextEditor().code();
449
  }
450
451
  public void insert_fenced_code_block() {
452
    getActiveTextEditor().fencedCodeBlock();
453
  }
454
455
  public void insert_link() {
456
    insertObject( createLinkDialog() );
457
  }
458
459
  public void insert_image() {
460
    insertObject( createImageDialog() );
461
  }
462
463
  private void insertObject( final Dialog<String> dialog ) {
464
    final var textArea = getActiveTextEditor().getTextArea();
465
    dialog.showAndWait().ifPresent( textArea::replaceSelection );
466
  }
467
468
  private Dialog<String> createLinkDialog() {
469
    return new LinkDialog( getWindow(), createHyperlinkModel() );
470
  }
471
472
  private Dialog<String> createImageDialog() {
473
    final var path = getActiveTextEditor().getPath();
474
    final var parentDir = path.getParent();
475
    return new ImageDialog( getWindow(), parentDir );
476
  }
477
478
  /**
479
   * Returns one of: selected text, word under cursor, or parsed hyperlink from
480
   * the Markdown AST.
481
   *
482
   * @return An instance containing the link URL and display text.
483
   */
484
  private HyperlinkModel createHyperlinkModel() {
485
    final var context = getMainPane().createProcessorContext();
486
    final var editor = getActiveTextEditor();
487
    final var textArea = editor.getTextArea();
488
    final var selectedText = textArea.getSelectedText();
489
490
    // Convert current paragraph to Markdown nodes.
491
    final var mp = MarkdownProcessor.create( context );
492
    final var p = textArea.getCurrentParagraph();
493
    final var paragraph = textArea.getText( p );
494
    final var node = mp.toNode( paragraph );
495
    final var visitor = new LinkVisitor( textArea.getCaretColumn() );
496
    final var link = visitor.process( node );
497
498
    if( link != null ) {
499
      textArea.selectRange( p, link.getStartOffset(), p, link.getEndOffset() );
500
    }
501
502
    return createHyperlinkModel( link, selectedText );
503
  }
504
505
  private HyperlinkModel createHyperlinkModel(
506
    final Link link, final String selection ) {
507
508
    return link == null
509
      ? new HyperlinkModel( selection, "https://localhost" )
510
      : new HyperlinkModel( link );
511
  }
512
513
  public void insert_heading_1() {
514
    insert_heading( 1 );
515
  }
516
517
  public void insert_heading_2() {
518
    insert_heading( 2 );
519
  }
520
521
  public void insert_heading_3() {
522
    insert_heading( 3 );
523
  }
524
525
  private void insert_heading( final int level ) {
526
    getActiveTextEditor().heading( level );
527
  }
528
529
  public void insert_unordered_list() {
530
    getActiveTextEditor().unorderedList();
531
  }
532
533
  public void insert_ordered_list() {
534
    getActiveTextEditor().orderedList();
535
  }
536
537
  public void insert_horizontal_rule() {
538
    getActiveTextEditor().horizontalRule();
539
  }
540
541
  public void definition_create() {
542
    getActiveTextDefinition().createDefinition();
543
  }
544
545
  public void definition_rename() {
546
    getActiveTextDefinition().renameDefinition();
547
  }
548
549
  public void definition_delete() {
550
    getActiveTextDefinition().deleteDefinitions();
551
  }
552
553
  public void definition_autoinsert() {
554
    getMainPane().autoinsert();
555
  }
556
557
  public void view_refresh() {
558
    getMainPane().viewRefresh();
559
  }
560
561
  public void view_preview() {
562
    getMainPane().viewPreview();
563
  }
564
565
  public void view_outline() {
566
    getMainPane().viewOutline();
567
  }
568
569
  public void view_files() { getMainPane().viewFiles(); }
570
571
  public void view_statistics() {
572
    getMainPane().viewStatistics();
573
  }
574
575
  public void view_menubar() {
576
    getMainScene().toggleMenuBar();
577
  }
578
579
  public void view_toolbar() {
580
    getMainScene().toggleToolBar();
581
  }
582
583
  public void view_statusbar() {
584
    getMainScene().toggleStatusBar();
585
  }
586
587
  public void view_log() {
588
    mLogView.view();
589
  }
590
591
  public void help_about() {
592
    final var alert = new Alert( INFORMATION );
593
    final var prefix = "Dialog.about.";
594
    alert.setTitle( get( prefix + "title", APP_TITLE ) );
595
    alert.setHeaderText( get( prefix + "header", APP_TITLE ) );
596
    alert.setContentText( get( prefix + "content", APP_YEAR, APP_VERSION ) );
597
    alert.setGraphic( ICON_DIALOG_NODE );
598
    alert.initOwner( getWindow() );
599
    alert.showAndWait();
600
  }
601
602
  private <T> void typeset( final Service<T> service ) {
603
    synchronized( mMutex ) {
604
      if( service != null && !service.isRunning() ) {
605
        service.reset();
606
        service.start();
607
      }
608
    }
609
  }
610
611
  /**
612
   * Concatenates all the files in the same directory as the given file into
613
   * a string. The extension is determined by the given file name pattern; the
614
   * order files are concatenated is based on their numeric sort order (this
615
   * avoids lexicographic sorting).
616
   * <p>
617
   * If the parent path to the file being edited in the text editor cannot
618
   * be found then this will return the editor's text, without iterating through
619
   * the parent directory. (Should never happen, but who knows?)
620
   * </p>
621
   * <p>
622
   * New lines are automatically appended to separate each file.
623
   * </p>
624
   *
625
   * @param editor The text editor containing
626
   * @return All files in the same directory as the file being edited
627
   * concatenated into a single string.
628
   */
629
  private String append( final TextEditor editor ) {
630
    final var pattern = editor.getPath();
631
    final var parent = pattern.getParent();
632
633
    // Short-circuit because nothing else can be done.
634
    if( parent == null ) {
635
      clue( "Main.status.export.concat.parent", pattern );
636
      return editor.getText();
637
    }
638
639
    final var filename = SysFile.getFileName( pattern );
640
    final var extension = getExtension( filename );
641
642
    if( extension.isBlank() ) {
643
      clue( "Main.status.export.concat.extension", filename );
644
      return editor.getText();
645
    }
646
647
    try {
648
      final var command = new ConcatenateCommand(
649
        parent, extension, getString( KEY_TYPESET_CONTEXT_CHAPTERS ) );
650
      return command.call();
651
    } catch( final Throwable t ) {
652
      clue( t );
653
      return editor.getText();
654
    }
1
/* Copyright 2023 White Magic Software, Ltd. -- All rights reserved.
2
 *
3
 * SPDX-License-Identifier: MIT
4
 */
5
package com.keenwrite.ui.actions;
6
7
import com.keenwrite.ExportFormat;
8
import com.keenwrite.MainPane;
9
import com.keenwrite.MainScene;
10
import com.keenwrite.commands.ConcatenateCommand;
11
import com.keenwrite.editors.TextDefinition;
12
import com.keenwrite.editors.TextEditor;
13
import com.keenwrite.editors.markdown.LinkVisitor;
14
import com.keenwrite.events.CaretMovedEvent;
15
import com.keenwrite.events.ExportFailedEvent;
16
import com.keenwrite.io.SysFile;
17
import com.keenwrite.preferences.Key;
18
import com.keenwrite.preferences.PreferencesController;
19
import com.keenwrite.preferences.Workspace;
20
import com.keenwrite.processors.markdown.MarkdownProcessor;
21
import com.keenwrite.search.SearchModel;
22
import com.keenwrite.typesetting.Typesetter;
23
import com.keenwrite.ui.controls.SearchBar;
24
import com.keenwrite.ui.dialogs.*;
25
import com.keenwrite.ui.explorer.FilePicker;
26
import com.keenwrite.ui.explorer.FilePickerFactory;
27
import com.keenwrite.ui.logging.LogView;
28
import com.keenwrite.ui.models.HyperlinkModel;
29
import com.keenwrite.ui.models.ImageModel;
30
import com.vladsch.flexmark.ast.Link;
31
import javafx.concurrent.Service;
32
import javafx.concurrent.Task;
33
import javafx.scene.control.Alert;
34
import javafx.scene.control.Dialog;
35
import javafx.stage.Window;
36
import javafx.stage.WindowEvent;
37
38
import java.io.File;
39
import java.nio.file.Path;
40
import java.util.List;
41
import java.util.Optional;
42
43
import static com.keenwrite.Bootstrap.*;
44
import static com.keenwrite.ExportFormat.*;
45
import static com.keenwrite.Messages.get;
46
import static com.keenwrite.constants.Constants.PDF_DEFAULT;
47
import static com.keenwrite.constants.Constants.USER_DIRECTORY;
48
import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG_NODE;
49
import static com.keenwrite.events.StatusEvent.clue;
50
import static com.keenwrite.preferences.AppKeys.*;
51
import static com.keenwrite.processors.ProcessorFactory.createProcessors;
52
import static com.keenwrite.ui.explorer.FilePickerFactory.SelectionType;
53
import static com.keenwrite.ui.explorer.FilePickerFactory.SelectionType.*;
54
import static java.nio.charset.StandardCharsets.UTF_8;
55
import static java.nio.file.Files.writeString;
56
import static javafx.application.Platform.runLater;
57
import static javafx.event.Event.fireEvent;
58
import static javafx.scene.control.Alert.AlertType.INFORMATION;
59
import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST;
60
import static org.apache.commons.io.FilenameUtils.getExtension;
61
62
/**
63
 * Responsible for abstracting how functionality is mapped to the application.
64
 * This allows users to customize accelerator keys and will provide pluggable
65
 * functionality so that different text markup languages can change documents
66
 * using their respective syntax.
67
 */
68
public final class GuiCommands {
69
  private static final String STYLE_SEARCH = "search";
70
71
  /**
72
   * When an action is executed, this is one of the recipients.
73
   */
74
  private final MainPane mMainPane;
75
76
  private final MainScene mMainScene;
77
78
  private final LogView mLogView;
79
80
  /**
81
   * Tracks finding text in the active document.
82
   */
83
  private final SearchModel mSearchModel;
84
85
  private boolean mCanTypeset;
86
87
  /**
88
   * A {@link Task} can only be run once, so wrap it in a {@link Service} to
89
   * allow re-launching the typesetting task repeatedly.
90
   */
91
  private Service<Path> mTypesetService;
92
93
  /**
94
   * Prevent a race-condition between checking to see if the typesetting task
95
   * is running and restarting the task itself.
96
   */
97
  private final Object mMutex = new Object();
98
99
  public GuiCommands( final MainScene scene, final MainPane pane ) {
100
    mMainScene = scene;
101
    mMainPane = pane;
102
    mLogView = new LogView();
103
    mSearchModel = new SearchModel();
104
    mSearchModel.matchOffsetProperty().addListener( ( _, o, n ) -> {
105
      final var editor = getActiveTextEditor();
106
107
      // Clear highlighted areas before highlighting a new region.
108
      if( o != null ) {
109
        editor.unstylize( STYLE_SEARCH );
110
      }
111
112
      if( n != null ) {
113
        editor.moveTo( n.getStart() );
114
        editor.stylize( n, STYLE_SEARCH );
115
      }
116
    } );
117
118
    // When the active text editor changes ...
119
    mMainPane.textEditorProperty().addListener(
120
      ( _, _, n ) -> {
121
        // ... update the haystack.
122
        mSearchModel.search( getActiveTextEditor().getText() );
123
124
        // ... update the status bar with the current caret position.
125
        if( n != null ) {
126
          final var w = getWorkspace();
127
          final var recentDoc = w.fileProperty( KEY_UI_RECENT_DOCUMENT );
128
129
          // ... preserve the most recent document.
130
          recentDoc.setValue( n.getFile() );
131
          CaretMovedEvent.fire( n.getCaret() );
132
        }
133
      }
134
    );
135
  }
136
137
  public void file_new() {
138
    getMainPane().newTextEditor();
139
  }
140
141
  public void file_open() {
142
    pickFiles( FILE_OPEN_MULTIPLE ).ifPresent( l -> getMainPane().open( l ) );
143
  }
144
145
  public void file_open_url() {
146
    pickFile().ifPresent( l -> getMainPane().open( List.of( l ) ) );
147
  }
148
149
  public void file_close() {
150
    getMainPane().close();
151
  }
152
153
  public void file_close_all() {
154
    getMainPane().closeAll();
155
  }
156
157
  public void file_save() {
158
    getMainPane().save();
159
  }
160
161
  public void file_save_as() {
162
    pickFiles( FILE_SAVE_AS ).ifPresent( l -> getMainPane().saveAs( l ) );
163
  }
164
165
  public void file_save_all() {
166
    getMainPane().saveAll();
167
  }
168
169
  /**
170
   * Converts the actively edited file in the given file format.
171
   *
172
   * @param format The destination file format.
173
   */
174
  private void file_export( final ExportFormat format ) {
175
    file_export( format, false );
176
  }
177
178
  /**
179
   * Converts one or more files into the given file format. If {@code dir}
180
   * is set to true, this will first append all files in the same directory
181
   * as the actively edited file.
182
   *
183
   * @param format The destination file format.
184
   * @param dir    Export all files in the actively edited file's directory.
185
   */
186
  private void file_export( final ExportFormat format, final boolean dir ) {
187
    final var editor = getMainPane().getTextEditor();
188
    final var exported = getWorkspace().fileProperty( KEY_UI_RECENT_EXPORT );
189
    final var exportParent = exported.get().toPath().getParent();
190
    final var editorParent = editor.getPath().getParent();
191
    final var userHomeParent = USER_DIRECTORY.toPath();
192
    final var exportPath = exportParent != null
193
      ? exportParent
194
      : editorParent != null
195
      ? editorParent
196
      : userHomeParent;
197
198
    final var filename = format.toExportFilename( editor.getPath() );
199
    final var selected = PDF_DEFAULT
200
      .getName()
201
      .equals( exported.get().getName() );
202
    final var selection = pickFile(
203
      selected
204
        ? filename
205
        : exported.get(),
206
      exportPath,
207
      FILE_EXPORT
208
    );
209
210
    selection.ifPresent( files -> file_export( editor, format, files, dir ) );
211
  }
212
213
  private void file_export(
214
    final TextEditor editor,
215
    final ExportFormat format,
216
    final List<File> files,
217
    final boolean dir ) {
218
    editor.save();
219
    final var main = getMainPane();
220
    final var exported = getWorkspace().fileProperty( KEY_UI_RECENT_EXPORT );
221
222
    final var sourceFile = files.getFirst();
223
    final var sourcePath = sourceFile.toPath();
224
    final var document = dir ? append( editor ) : editor.getText();
225
    final var context = main.createProcessorContext( sourcePath, format );
226
227
    final var service = new Service<Path>() {
228
      @Override
229
      protected Task<Path> createTask() {
230
        final var task = new Task<Path>() {
231
          @Override
232
          protected Path call() throws Exception {
233
            final var chain = createProcessors( context );
234
            final var export = chain.apply( document );
235
236
            // Processors can export binary files. In such cases, processors
237
            // return null to prevent further processing.
238
            return export == null
239
              ? null
240
              : writeString( sourcePath, export, UTF_8 );
241
          }
242
        };
243
244
        task.setOnSucceeded(
245
          _ -> {
246
            // Remember the exported file name for next time.
247
            exported.setValue( sourceFile );
248
249
            final var result = task.getValue();
250
251
            // Binary formats must notify users of success independently.
252
            if( result != null ) {
253
              clue( "Main.status.export.success", result );
254
            }
255
          }
256
        );
257
258
        task.setOnFailed( _ -> {
259
          final var ex = task.getException();
260
          clue( ex );
261
262
          if( ex instanceof TypeNotPresentException ) {
263
            fireExportFailedEvent();
264
          }
265
        } );
266
267
        return task;
268
      }
269
    };
270
271
    mTypesetService = service;
272
    typeset( service );
273
  }
274
275
  /**
276
   * @param dir {@code true} means to export all files in the active file
277
   *            editor's directory; {@code false} means to export only the
278
   *            actively edited file.
279
   */
280
  private void file_export_pdf( final boolean dir ) {
281
    // Don't re-validate the typesetter installation each time. If the
282
    // user mucks up the typesetter installation, it'll get caught the
283
    // next time the application is started. Don't use |= because it
284
    // won't short-circuit.
285
    mCanTypeset = mCanTypeset || Typesetter.canRun();
286
287
    if( mCanTypeset ) {
288
      final var workspace = getWorkspace();
289
      final var theme = workspace.stringProperty(
290
        KEY_TYPESET_CONTEXT_THEME_SELECTION
291
      );
292
      final var chapters = workspace.stringProperty(
293
        KEY_TYPESET_CONTEXT_CHAPTERS
294
      );
295
296
      final var settings = ExportSettings
297
        .builder()
298
        .with( ExportSettings.Mutator::setTheme, theme )
299
        .with( ExportSettings.Mutator::setChapters, chapters )
300
        .build();
301
302
      final var themes = workspace.getFile(
303
        KEY_TYPESET_CONTEXT_THEMES_PATH
304
      );
305
306
      // If the typesetter is installed, allow the user to select a theme. If
307
      // the themes aren't installed, a status message will appear.
308
      if( ExportDialog.choose( getWindow(), themes, settings, dir ) ) {
309
        file_export( APPLICATION_PDF, dir );
310
      }
311
    }
312
    else {
313
      fireExportFailedEvent();
314
    }
315
  }
316
317
  public void file_export_pdf() {
318
    file_export_pdf( false );
319
  }
320
321
  public void file_export_pdf_dir() {
322
    file_export_pdf( true );
323
  }
324
325
  public void file_export_html_dir() {
326
    file_export( XHTML_TEX, true );
327
  }
328
329
  public void file_export_repeat() {
330
    typeset( mTypesetService );
331
  }
332
333
  public void file_export_html_svg() {
334
    file_export( HTML_TEX_SVG );
335
  }
336
337
  public void file_export_html_tex() {
338
    file_export( HTML_TEX_DELIMITED );
339
  }
340
341
  public void file_export_xhtml_tex() {
342
    file_export( XHTML_TEX );
343
  }
344
345
  private void fireExportFailedEvent() {
346
    runLater( ExportFailedEvent::fire );
347
  }
348
349
  public void file_exit() {
350
    final var window = getWindow();
351
    fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) );
352
  }
353
354
  public void edit_undo() {
355
    getActiveTextEditor().undo();
356
  }
357
358
  public void edit_redo() {
359
    getActiveTextEditor().redo();
360
  }
361
362
  public void edit_cut() {
363
    getActiveTextEditor().cut();
364
  }
365
366
  public void edit_copy() {
367
    getActiveTextEditor().copy();
368
  }
369
370
  public void edit_paste() {
371
    getActiveTextEditor().paste();
372
  }
373
374
  public void edit_select_all() {
375
    getActiveTextEditor().selectAll();
376
  }
377
378
  public void edit_find() {
379
    final var nodes = getMainScene().getStatusBar().getLeftItems();
380
381
    if( nodes.isEmpty() ) {
382
      final var searchBar = new SearchBar();
383
384
      searchBar.matchIndexProperty().bind( mSearchModel.matchIndexProperty() );
385
      searchBar.matchCountProperty().bind( mSearchModel.matchCountProperty() );
386
387
      searchBar.setOnCancelAction( _ -> {
388
        final var editor = getActiveTextEditor();
389
        nodes.remove( searchBar );
390
        editor.unstylize( STYLE_SEARCH );
391
        editor.getNode().requestFocus();
392
      } );
393
394
      searchBar.addInputListener( ( _, _, n ) -> {
395
        if( n != null && !n.isEmpty() ) {
396
          mSearchModel.search( n, getActiveTextEditor().getText() );
397
        }
398
      } );
399
400
      searchBar.setOnNextAction( _ -> edit_find_next() );
401
      searchBar.setOnPrevAction( _ -> edit_find_prev() );
402
403
      nodes.add( searchBar );
404
      searchBar.requestFocus();
405
    }
406
  }
407
408
  public void edit_find_next() {
409
    mSearchModel.advance();
410
  }
411
412
  public void edit_find_prev() {
413
    mSearchModel.retreat();
414
  }
415
416
  public void edit_preferences() {
417
    try {
418
      new PreferencesController( getWorkspace() ).show();
419
    } catch( final Exception ex ) {
420
      clue( ex );
421
    }
422
  }
423
424
  public void format_bold() {
425
    getActiveTextEditor().bold();
426
  }
427
428
  public void format_italic() {
429
    getActiveTextEditor().italic();
430
  }
431
432
  public void format_monospace() {
433
    getActiveTextEditor().monospace();
434
  }
435
436
  public void format_superscript() {
437
    getActiveTextEditor().superscript();
438
  }
439
440
  public void format_subscript() {
441
    getActiveTextEditor().subscript();
442
  }
443
444
  public void format_strikethrough() {
445
    getActiveTextEditor().strikethrough();
446
  }
447
448
  public void insert_blockquote() {
449
    getActiveTextEditor().blockquote();
450
  }
451
452
  public void insert_code() {
453
    getActiveTextEditor().code();
454
  }
455
456
  public void insert_fenced_code_block() {
457
    getActiveTextEditor().fencedCodeBlock();
458
  }
459
460
  public void insert_link() {
461
    insertObject( createLinkDialog() );
462
  }
463
464
  public void insert_image() {
465
    insertObject( createImageDialog() );
466
  }
467
468
  private void insertObject( final Dialog<String> dialog ) {
469
    final var textArea = getActiveTextEditor().getTextArea();
470
    dialog.showAndWait().ifPresent( textArea::replaceSelection );
471
  }
472
473
  private Dialog<String> createLinkDialog() {
474
    return new HyperlinkDialog( getWindow(), createHyperlinkModel() );
475
  }
476
477
  private Dialog<String> createImageDialog() {
478
    return new ImageDialog( getWindow(), createImageModel() );
479
  }
480
481
  /**
482
   * Returns one of: selected text, word under cursor, or parsed hyperlink from
483
   * the Markdown AST. When a user opts to insert a hyperlink, this will
484
   * populate the insert hyperlink dialog with data from the document, thereby
485
   * allowing a user to edit an existing link.
486
   *
487
   * @return An instance containing the link URL and display text.
488
   */
489
  private HyperlinkModel createHyperlinkModel() {
490
    final var context = getMainPane().createProcessorContext();
491
    final var editor = getActiveTextEditor();
492
    final var textArea = editor.getTextArea();
493
    final var selectedText = textArea.getSelectedText();
494
495
    // Convert current paragraph to Markdown nodes.
496
    final var mp = MarkdownProcessor.create( context );
497
    final var p = textArea.getCurrentParagraph();
498
    final var paragraph = textArea.getText( p );
499
    final var node = mp.toNode( paragraph );
500
    final var visitor = new LinkVisitor( textArea.getCaretColumn() );
501
    final var link = visitor.process( node );
502
503
    if( link != null ) {
504
      textArea.selectRange( p, link.getStartOffset(), p, link.getEndOffset() );
505
    }
506
507
    return createHyperlinkModel( link, selectedText );
508
  }
509
510
  private HyperlinkModel createHyperlinkModel(
511
    final Link link, final String selection ) {
512
513
    return link == null
514
      ? new HyperlinkModel( selection )
515
      : new HyperlinkModel( link );
516
  }
517
518
  private ImageModel createImageModel() {
519
    return new ImageModel( "" );
520
  }
521
522
  public void insert_heading_1() {
523
    insert_heading( 1 );
524
  }
525
526
  public void insert_heading_2() {
527
    insert_heading( 2 );
528
  }
529
530
  public void insert_heading_3() {
531
    insert_heading( 3 );
532
  }
533
534
  private void insert_heading( final int level ) {
535
    getActiveTextEditor().heading( level );
536
  }
537
538
  public void insert_unordered_list() {
539
    getActiveTextEditor().unorderedList();
540
  }
541
542
  public void insert_ordered_list() {
543
    getActiveTextEditor().orderedList();
544
  }
545
546
  public void insert_horizontal_rule() {
547
    getActiveTextEditor().horizontalRule();
548
  }
549
550
  public void definition_create() {
551
    getActiveTextDefinition().createDefinition();
552
  }
553
554
  public void definition_rename() {
555
    getActiveTextDefinition().renameDefinition();
556
  }
557
558
  public void definition_delete() {
559
    getActiveTextDefinition().deleteDefinitions();
560
  }
561
562
  public void definition_autoinsert() {
563
    getMainPane().autoinsert();
564
  }
565
566
  public void view_refresh() {
567
    getMainPane().viewRefresh();
568
  }
569
570
  public void view_preview() {
571
    getMainPane().viewPreview();
572
  }
573
574
  public void view_outline() {
575
    getMainPane().viewOutline();
576
  }
577
578
  public void view_files() {getMainPane().viewFiles();}
579
580
  public void view_statistics() {
581
    getMainPane().viewStatistics();
582
  }
583
584
  public void view_menubar() {
585
    getMainScene().toggleMenuBar();
586
  }
587
588
  public void view_toolbar() {
589
    getMainScene().toggleToolBar();
590
  }
591
592
  public void view_statusbar() {
593
    getMainScene().toggleStatusBar();
594
  }
595
596
  public void view_log() {
597
    mLogView.view();
598
  }
599
600
  public void help_about() {
601
    final var alert = new Alert( INFORMATION );
602
    final var prefix = "Dialog.about.";
603
    alert.setTitle( get( STR."\{prefix}title", APP_TITLE ) );
604
    alert.setHeaderText( get( STR."\{prefix}header", APP_TITLE ) );
605
    alert.setContentText( get( STR."\{prefix}content",
606
                               APP_YEAR,
607
                               APP_VERSION ) );
608
    alert.setGraphic( ICON_DIALOG_NODE );
609
    alert.initOwner( getWindow() );
610
    alert.showAndWait();
611
  }
612
613
  private <T> void typeset( final Service<T> service ) {
614
    synchronized( mMutex ) {
615
      if( service != null && !service.isRunning() ) {
616
        service.reset();
617
        service.start();
618
      }
619
    }
620
  }
621
622
  /**
623
   * Concatenates all the files in the same directory as the given file into
624
   * a string. The extension is determined by the given file name pattern; the
625
   * order files are concatenated is based on their numeric sort order (this
626
   * avoids lexicographic sorting).
627
   * <p>
628
   * If the parent path to the file being edited in the text editor cannot
629
   * be found then this will return the editor's text, without iterating through
630
   * the parent directory. (Should never happen, but who knows?)
631
   * </p>
632
   * <p>
633
   * New lines are automatically appended to separate each file.
634
   * </p>
635
   *
636
   * @param editor The text editor containing
637
   * @return All files in the same directory as the file being edited
638
   * concatenated into a single string.
639
   */
640
  private String append( final TextEditor editor ) {
641
    final var pattern = editor.getPath();
642
    final var parent = pattern.getParent();
643
644
    // Short-circuit because nothing else can be done.
645
    if( parent == null ) {
646
      clue( "Main.status.export.concat.parent", pattern );
647
      return editor.getText();
648
    }
649
650
    final var filename = SysFile.getFileName( pattern );
651
    final var extension = getExtension( filename );
652
653
    if( extension.isBlank() ) {
654
      clue( "Main.status.export.concat.extension", filename );
655
      return editor.getText();
656
    }
657
658
    try {
659
      final var command = new ConcatenateCommand(
660
        parent, extension, getString( KEY_TYPESET_CONTEXT_CHAPTERS ) );
661
      return command.call();
662
    } catch( final Throwable t ) {
663
      clue( t );
664
      return editor.getText();
665
    }
666
  }
667
668
  private Optional<File> pickFile() {
669
    final var editor = getActiveTextEditor();
670
    final var file = editor == null ? USER_DIRECTORY : editor.getFile();
671
    final var path = SysFile.toFile( file.toPath() );
672
    final var parent = Path.of( path.getParent() );
673
674
    return new OpenUrlDialog( getWindow(), parent ).showAndWait();
655675
  }
656676
M src/main/java/com/keenwrite/ui/controls/BrowseButton.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2023 White Magic Software, Ltd. -- All rights reserved.
2
 *
3
 * SPDX-License-Identifier: MIT
4
 */
25
package com.keenwrite.ui.controls;
36
D src/main/java/com/keenwrite/ui/controls/BrowseFileButton.java
1
/*
2
 * Copyright 2015 Karl Tauber <karl at jformdesigner dot com>
3
 * All rights reserved.
4
 *
5
 * Redistribution and use in source and binary forms, with or without
6
 * modification, are permitted provided that the following conditions are met:
7
 *
8
 *  o Redistributions of source code must retain the above copyright
9
 *    notice, this list of conditions and the following disclaimer.
10
 *
11
 *  o Redistributions in binary form must reproduce the above copyright
12
 *    notice, this list of conditions and the following disclaimer in the
13
 *    documentation and/or other materials provided with the distribution.
14
 *
15
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
16
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
17
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
18
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
19
 * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
20
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
21
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
22
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
23
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
24
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
25
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
26
 */
27
28
package com.keenwrite.ui.controls;
29
30
import com.keenwrite.Messages;
31
import javafx.beans.property.ObjectProperty;
32
import javafx.beans.property.SimpleObjectProperty;
33
import javafx.event.ActionEvent;
34
import javafx.scene.control.Button;
35
import javafx.scene.control.Tooltip;
36
import javafx.scene.input.KeyCode;
37
import javafx.scene.input.KeyEvent;
38
import javafx.stage.FileChooser;
39
import javafx.stage.FileChooser.ExtensionFilter;
40
41
import java.io.File;
42
import java.nio.file.Path;
43
import java.util.ArrayList;
44
import java.util.List;
45
46
import static com.keenwrite.io.SysFile.toFile;
47
import static com.keenwrite.ui.fonts.IconFactory.createGraphic;
48
import static org.controlsfx.glyphfont.FontAwesome.Glyph.FILE_ALT;
49
50
/**
51
 * Button that opens a file chooser to select a local file for a URL.
52
 */
53
public class BrowseFileButton extends Button {
54
55
  private final List<ExtensionFilter> mExtensionFilters = new ArrayList<>();
56
  private final ObjectProperty<Path> mBasePath = new SimpleObjectProperty<>();
57
  private final ObjectProperty<String> mUrl = new SimpleObjectProperty<>();
58
59
  public BrowseFileButton() {
60
    setGraphic( createGraphic( FILE_ALT ) );
61
    setTooltip( new Tooltip( Messages.get( "BrowseFileButton.tooltip" ) ) );
62
    setOnAction( this::browse );
63
64
    disableProperty().bind( mBasePath.isNull() );
65
66
    // workaround for a JavaFX bug:
67
    //   avoid closing the dialog that contains this control when the user
68
    //   closes the FileChooser or DirectoryChooser using the ESC key
69
    addEventHandler( KeyEvent.KEY_RELEASED, e -> {
70
      if( e.getCode() == KeyCode.ESCAPE ) {
71
        e.consume();
72
      }
73
    } );
74
  }
75
76
  public void addExtensionFilter( ExtensionFilter extensionFilter ) {
77
    mExtensionFilters.add( extensionFilter );
78
  }
79
80
  public ObjectProperty<String> urlProperty() {
81
    return mUrl;
82
  }
83
84
  private void browse( ActionEvent e ) {
85
    var fileChooser = new FileChooser();
86
    fileChooser.setTitle( Messages.get( "BrowseFileButton.chooser.title" ) );
87
    fileChooser.getExtensionFilters().addAll( mExtensionFilters );
88
    fileChooser.getExtensionFilters()
89
               .add( new ExtensionFilter( Messages.get(
90
                 "BrowseFileButton.chooser.allFilesFilter" ), "*.*" ) );
91
    fileChooser.setInitialDirectory( getInitialDirectory() );
92
    var result = fileChooser.showOpenDialog( getScene().getWindow() );
93
    if( result != null ) {
94
      updateUrl( result );
95
    }
96
  }
97
98
  private File getInitialDirectory() {
99
    //TODO build initial directory based on current value of 'url' property
100
    return toFile( getBasePath() );
101
  }
102
103
  private void updateUrl( File file ) {
104
    String newUrl;
105
    try {
106
      newUrl = getBasePath().relativize( file.toPath() ).toString();
107
    } catch( final Exception ex ) {
108
      newUrl = file.toString();
109
    }
110
    mUrl.set( newUrl.replace( '\\', '/' ) );
111
  }
112
113
  public void setBasePath( Path basePath ) {
114
    this.mBasePath.set( basePath );
115
  }
116
117
  private Path getBasePath() {
118
    return mBasePath.get();
119
  }
120
}
1211
D src/main/java/com/keenwrite/ui/controls/EscapeTextField.java
1
/*
2
 * Copyright 2020 Karl Tauber and White Magic Software, Ltd.
3
 * All rights reserved.
4
 *
5
 * Redistribution and use in source and binary forms, with or without
6
 * modification, are permitted provided that the following conditions are met:
7
 *
8
 *  o Redistributions of source code must retain the above copyright
9
 *    notice, this list of conditions and the following disclaimer.
10
 *
11
 *  o Redistributions in binary form must reproduce the above copyright
12
 *    notice, this list of conditions and the following disclaimer in the
13
 *    documentation and/or other materials provided with the distribution.
14
 *
15
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
16
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
17
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
18
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
19
 * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
20
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
21
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
22
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
23
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
24
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
25
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
26
 */
27
28
package com.keenwrite.ui.controls;
29
30
import javafx.beans.property.SimpleStringProperty;
31
import javafx.beans.property.StringProperty;
32
import javafx.scene.control.TextField;
33
import javafx.util.StringConverter;
34
35
/**
36
 * Responsible for escaping/unescaping characters for Markdown.
37
 */
38
public class EscapeTextField extends TextField {
39
40
  public EscapeTextField() {
41
    escapedText.bindBidirectional(
42
        textProperty(),
43
        new StringConverter<>() {
44
          @Override
45
          public String toString( String object ) {
46
            return escape( object );
47
          }
48
49
          @Override
50
          public String fromString( String string ) {
51
            return unescape( string );
52
          }
53
        }
54
    );
55
    escapeCharacters.addListener(
56
        e -> escapedText.set( escape( textProperty().get() ) )
57
    );
58
  }
59
60
  // 'escapedText' property
61
  private final StringProperty escapedText = new SimpleStringProperty();
62
63
  public StringProperty escapedTextProperty() {
64
    return escapedText;
65
  }
66
67
  // 'escapeCharacters' property
68
  private final StringProperty escapeCharacters = new SimpleStringProperty();
69
70
  public String getEscapeCharacters() {
71
    return escapeCharacters.get();
72
  }
73
74
  public void setEscapeCharacters( String escapeCharacters ) {
75
    this.escapeCharacters.set( escapeCharacters );
76
  }
77
78
  private String escape( final String s ) {
79
    final String escapeChars = getEscapeCharacters();
80
81
    return isEmpty( escapeChars ) ? s :
82
        s.replaceAll( "([" + escapeChars.replaceAll(
83
            "(.)",
84
            "\\\\$1" ) + "])", "\\\\$1" );
85
  }
86
87
  private String unescape( final String s ) {
88
    final String escapeChars = getEscapeCharacters();
89
90
    return isEmpty( escapeChars ) ? s :
91
        s.replaceAll( "\\\\([" + escapeChars
92
            .replaceAll( "(.)", "\\\\$1" ) + "])", "$1" );
93
  }
94
95
  private static boolean isEmpty( final String s ) {
96
    return s == null || s.isEmpty();
97
  }
98
}
991
M src/main/java/com/keenwrite/ui/dialogs/AbstractDialog.java
1
/* Copyright 2017-2021 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2023 White Magic Software, Ltd. -- All rights reserved.
2
 *
3
 * SPDX-License-Identifier: MIT
4
 */
25
package com.keenwrite.ui.dialogs;
36
47
import com.keenwrite.service.events.impl.ButtonOrderPane;
58
import javafx.scene.control.Dialog;
69
import javafx.stage.Stage;
710
import javafx.stage.Window;
811
912
import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG;
1013
import static com.keenwrite.Messages.get;
14
import static com.keenwrite.util.Strings.validate;
1115
import static javafx.scene.control.ButtonType.CANCEL;
1216
import static javafx.scene.control.ButtonType.OK;
...
2529
   * @param title The messages title to display in the title bar.
2630
   */
27
  @SuppressWarnings( "OverridableMethodCallInConstructor" )
2831
  public AbstractDialog( final Window owner, final String title ) {
32
    assert owner != null;
33
    assert validate( title );
34
2935
    setTitle( get( title ) );
3036
    setResizable( true );
...
6672
  protected final void initCloseAction() {
6773
    final var window = getDialogPane().getScene().getWindow();
68
    window.setOnCloseRequest( event -> window.hide() );
74
    window.setOnCloseRequest( _ -> window.hide() );
6975
  }
7076
A src/main/java/com/keenwrite/ui/dialogs/CustomDialog.java
1
/* Copyright 2023 White Magic Software, Ltd. -- All rights reserved.
2
 *
3
 * SPDX-License-Identifier: MIT
4
 */
5
package com.keenwrite.ui.dialogs;
6
7
import com.keenwrite.Messages;
8
import com.keenwrite.service.events.impl.ButtonOrderPane;
9
import javafx.application.Platform;
10
import javafx.beans.value.ChangeListener;
11
import javafx.geometry.Insets;
12
import javafx.scene.control.ButtonBar.ButtonData;
13
import javafx.scene.control.Dialog;
14
import javafx.scene.control.Label;
15
import javafx.scene.control.TextField;
16
import javafx.scene.layout.ColumnConstraints;
17
import javafx.scene.layout.GridPane;
18
import javafx.stage.Window;
19
20
import java.util.LinkedList;
21
import java.util.List;
22
23
import static com.keenwrite.Messages.get;
24
import static com.keenwrite.util.Strings.validate;
25
import static javafx.scene.control.ButtonType.CANCEL;
26
import static javafx.scene.control.ButtonType.OK;
27
import static javafx.scene.layout.Priority.ALWAYS;
28
import static javafx.scene.layout.Priority.NEVER;
29
30
/**
31
 * TODO: This class could be combined with {@link AbstractDialog}, either
32
 *   directly or through inheritance.
33
 *
34
 * @param <T> The type of data returned from the dialog upon acceptance.
35
 */
36
public abstract class CustomDialog<T> extends Dialog<T> {
37
  private final GridPane mContentPane = new GridPane( 10, 10 );
38
  private final List<TextField> mInputFields = new LinkedList<>();
39
40
  public CustomDialog( final Window owner, final String title ) {
41
    assert owner != null;
42
    assert validate( title );
43
44
    initOwner( owner );
45
    setTitle( get( title ) );
46
    setResizable( true );
47
  }
48
49
  /**
50
   * Allows for late binding so that input fields can be populated after
51
   * the constructor is called.
52
   */
53
  protected void initialize() {
54
    initDialogPane();
55
    initDialogButtons();
56
    initInputFields();
57
    initContentPane();
58
59
    assert !mInputFields.isEmpty();
60
61
    final var first = mInputFields.getFirst();
62
    assert first != null;
63
64
    Platform.runLater( first::requestFocus );
65
66
    setResultConverter( button -> {
67
      final ButtonData data = button == null ? null : button.getButtonData();
68
      return data == ButtonData.OK_DONE ? handleAccept() : null;
69
    } );
70
  }
71
72
  /**
73
   * Invoked when the user selects the OK button to confirm the input values.
74
   *
75
   * @return The type of data provided by using the dialog.
76
   */
77
  protected abstract T handleAccept();
78
79
  /**
80
   * Subclasses must call this method at least once.
81
   *
82
   * @param id     The unique identifier for the input field.
83
   * @param label  The input field's label property key.
84
   * @param prompt The prompt property key, which provides context.
85
   * @param value  The initial value to provide for the field.
86
   * @see Messages#get(String)
87
   */
88
  protected void addInputField(
89
    final String id,
90
    final String label,
91
    final String prompt,
92
    final String value,
93
    final ChangeListener<String> listener ) {
94
    assert validate( id );
95
    assert validate( label );
96
    assert validate( prompt );
97
    assert validate( value );
98
99
    final int row = mInputFields.size();
100
    final Label fieldLabel = new Label( get( label ) );
101
    final TextField fieldInput = new TextField();
102
103
    fieldInput.setPromptText( get( prompt ) );
104
    fieldInput.setId( id );
105
    fieldInput.textProperty().addListener( listener );
106
    fieldInput.setText( value );
107
108
    mContentPane.add( fieldLabel, 0, row );
109
    mContentPane.add( fieldInput, 1, row );
110
    mInputFields.add( fieldInput );
111
  }
112
113
  /**
114
   * Subclasses must add at least one input field.
115
   */
116
  protected abstract void initInputFields();
117
118
  /**
119
   * Set the dialog to use a button order pane with an OK and a CANCEL button.
120
   */
121
  protected void initDialogPane() {
122
    setDialogPane( new ButtonOrderPane() );
123
  }
124
125
  /**
126
   * Set an OK and CANCEL button on the dialog.
127
   */
128
  protected void initDialogButtons() {
129
    getDialogPane().getButtonTypes().addAll( OK, CANCEL );
130
  }
131
132
  /**
133
   * Called after the input fields have been added. This adds the input
134
   * fields to the main dialog pane.
135
   */
136
  protected void initContentPane() {
137
    mContentPane.setPadding( new Insets( 20, 10, 10, 10 ) );
138
139
    final var cc1 = new ColumnConstraints();
140
    final var cc2 = new ColumnConstraints();
141
142
    cc1.setHgrow( NEVER );
143
    cc2.setHgrow( ALWAYS );
144
    cc2.setMinWidth( 250 );
145
    mContentPane.getColumnConstraints().addAll( cc1, cc2 );
146
147
    getDialogPane().setContent( mContentPane );
148
  }
149
}
1150
M src/main/java/com/keenwrite/ui/dialogs/ExportDialog.java
4343
import static com.keenwrite.io.SysFile.toFile;
4444
import static com.keenwrite.util.FileWalker.walk;
45
import static com.keenwrite.util.Strings.abbreviate;
4546
import static java.lang.Math.max;
4647
import static java.nio.charset.StandardCharsets.UTF_8;
4748
import static javafx.application.Platform.runLater;
4849
import static javafx.geometry.Pos.CENTER;
4950
import static javafx.scene.control.ButtonType.OK;
50
import static org.apache.commons.lang3.StringUtils.abbreviate;
5151
5252
/**
...
319319
320320
    textField.textProperty().addListener(
321
      ( c, o, n ) -> textField.setText( RangeValidator.normalize( n ) )
321
      ( _, _, n ) -> textField.setText( RangeValidator.normalize( n ) )
322322
    );
323323
324324
    return textField;
325325
  }
326326
327327
  private Label createLabel( final String key ) {
328
    final var label = new Label( get( key ) + ":" );
328
    final var label = new Label( STR."\{get( key )}:" );
329329
    final var font = label.getFont();
330330
    final var upscale = new Font( font.getName(), 14 );
A src/main/java/com/keenwrite/ui/dialogs/HyperlinkDialog.java
1
/* Copyright 2023 White Magic Software, Ltd. -- All rights reserved.
2
 *
3
 * SPDX-License-Identifier: MIT
4
 */
5
package com.keenwrite.ui.dialogs;
6
7
import com.keenwrite.ui.models.HyperlinkModel;
8
import javafx.stage.Window;
9
10
/**
11
 * Dialog to insert or edit a Markdown link.
12
 */
13
public final class HyperlinkDialog extends CustomDialog<String> {
14
  private static final String PREFIX = "Dialog.link.";
15
16
  /**
17
   * Contains information about the hyperlink at the caret position in the
18
   * document, if a hyperlink is present at that location. This allows users
19
   * to edit existing hyperlinks using this {@link HyperlinkDialog}.
20
   */
21
  private final HyperlinkModel mModel;
22
23
  /**
24
   * @param owner {@link Window} responsible for the dialog resource.
25
   * @param model Existing hyperlink data, or blank for a new link.
26
   */
27
  public HyperlinkDialog( final Window owner, final HyperlinkModel model ) {
28
    super( owner, STR."\{PREFIX}title" );
29
30
    mModel = model;
31
32
    super.initialize();
33
  }
34
35
  @Override
36
  protected void initInputFields() {
37
    addInputField(
38
      "text",
39
      STR."\{PREFIX}label.text", STR."\{PREFIX}prompt.text",
40
      mModel.getText(),
41
      ( _, _, n ) -> mModel.setText( n )
42
    );
43
    addInputField(
44
      "url",
45
      STR."\{PREFIX}label.url", STR."\{PREFIX}prompt.url",
46
      mModel.getUrl(),
47
      ( _, _, n ) -> mModel.setUrl( n )
48
    );
49
    addInputField(
50
      "title",
51
      STR."\{PREFIX}label.title", STR."\{PREFIX}prompt.title",
52
      mModel.getTitle(),
53
      ( _, _, n ) -> mModel.setTitle( n )
54
    );
55
  }
56
57
  @Override
58
  protected String handleAccept() {
59
    return mModel.toString();
60
  }
61
}
162
M src/main/java/com/keenwrite/ui/dialogs/ImageDialog.java
1
/*
2
 * Copyright 2015 Karl Tauber <karl at jformdesigner dot com>
3
 * All rights reserved.
4
 *
5
 * Redistribution and use in source and binary forms, with or without
6
 * modification, are permitted provided that the following conditions are met:
7
 *
8
 *  o Redistributions of source code must retain the above copyright
9
 *    notice, this list of conditions and the following disclaimer.
10
 *
11
 *  o Redistributions in binary form must reproduce the above copyright
12
 *    notice, this list of conditions and the following disclaimer in the
13
 *    documentation and/or other materials provided with the distribution.
1
/* Copyright 2023 White Magic Software, Ltd. -- All rights reserved.
142
 *
15
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
16
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
17
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
18
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
19
 * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
20
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
21
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
22
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
23
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
24
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
25
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
3
 * SPDX-License-Identifier: MIT
264
 */
275
package com.keenwrite.ui.dialogs;
286
29
import static com.keenwrite.Messages.get;
30
import com.keenwrite.ui.controls.BrowseFileButton;
31
import com.keenwrite.ui.controls.EscapeTextField;
32
import java.nio.file.Path;
33
import javafx.application.Platform;
34
import javafx.beans.binding.Bindings;
35
import javafx.beans.property.SimpleStringProperty;
36
import javafx.beans.property.StringProperty;
37
import javafx.scene.control.ButtonBar.ButtonData;
38
import static javafx.scene.control.ButtonType.OK;
39
import javafx.scene.control.DialogPane;
40
import javafx.scene.control.Label;
41
import javafx.stage.FileChooser.ExtensionFilter;
7
import com.keenwrite.ui.models.ImageModel;
428
import javafx.stage.Window;
43
import org.tbee.javafx.scene.layout.fxml.MigPane;
449
4510
/**
4611
 * Dialog to enter a Markdown image.
4712
 */
48
public class ImageDialog extends AbstractDialog<String> {
49
50
  private final StringProperty image = new SimpleStringProperty();
51
52
  public ImageDialog( final Window owner, final Path basePath ) {
53
    super(owner, "Dialog.image.title" );
54
    
55
    final DialogPane dialogPane = getDialogPane();
56
    dialogPane.setContent( pane );
57
58
    linkBrowseFileButton.setBasePath( basePath );
59
    linkBrowseFileButton.addExtensionFilter( new ExtensionFilter( get( "Dialog.image.chooser.imagesFilter" ), "*.png", "*.gif", "*.jpg" ) );
60
    linkBrowseFileButton.urlProperty().bindBidirectional( urlField.escapedTextProperty() );
61
62
    dialogPane.lookupButton( OK ).disableProperty().bind(
63
      urlField.escapedTextProperty().isEmpty()
64
      .or( textField.escapedTextProperty().isEmpty() ) );
13
public class ImageDialog extends CustomDialog<String> {
14
  private static final String PREFIX = "Dialog.image.";
6515
66
    image.bind( Bindings.when( titleField.escapedTextProperty().isNotEmpty() )
67
      .then( Bindings.format( "![%s](%s \"%s\")", textField.escapedTextProperty(), urlField.escapedTextProperty(), titleField.escapedTextProperty() ) )
68
      .otherwise( Bindings.format( "![%s](%s)", textField.escapedTextProperty(), urlField.escapedTextProperty() ) ) );
69
    previewField.textProperty().bind( image );
16
  private final ImageModel mModel;
7017
71
    setResultConverter( dialogButton -> {
72
      ButtonData data = dialogButton != null ? dialogButton.getButtonData() : null;
73
      return data == ButtonData.OK_DONE ? image.get() : null;
74
    } );
18
  public ImageDialog( final Window owner, final ImageModel model ) {
19
    super( owner, STR."\{PREFIX}title" );
7520
76
    Platform.runLater( () -> {
77
      urlField.requestFocus();
21
    mModel = model;
7822
79
      if( urlField.getText().startsWith( "http://" ) ) {
80
        urlField.selectRange( "http://".length(), urlField.getLength() );
81
      }
82
    } );
23
    super.initialize();
8324
  }
8425
8526
  @Override
86
  protected void initComponents() {
87
    // JFormDesigner - Component initialization - DO NOT MODIFY  //GEN-BEGIN:initComponents
88
    pane = new MigPane();
89
    Label urlLabel = new Label();
90
    urlField = new EscapeTextField();
91
    linkBrowseFileButton = new BrowseFileButton();
92
    Label textLabel = new Label();
93
    textField = new EscapeTextField();
94
    Label titleLabel = new Label();
95
    titleField = new EscapeTextField();
96
    Label previewLabel = new Label();
97
    previewField = new Label();
98
99
    //======== pane ========
100
    {
101
      pane.setCols( "[shrink 0,fill][300,grow,fill][fill]" );
102
      pane.setRows( "[][][][]" );
103
104
      //---- urlLabel ----
105
      urlLabel.setText( get( "Dialog.image.urlLabel.text" ) );
106
      pane.add( urlLabel, "cell 0 0" );
107
108
      //---- urlField ----
109
      urlField.setEscapeCharacters( "()" );
110
      urlField.setText( "https://yourlink.com" );
111
      urlField.setPromptText( "https://yourlink.com" );
112
      pane.add( urlField, "cell 1 0" );
113
      pane.add( linkBrowseFileButton, "cell 2 0" );
114
115
      //---- textLabel ----
116
      textLabel.setText( get( "Dialog.image.textLabel.text" ) );
117
      pane.add( textLabel, "cell 0 1" );
118
119
      //---- textField ----
120
      textField.setEscapeCharacters( "[]" );
121
      pane.add( textField, "cell 1 1 2 1" );
122
123
      //---- titleLabel ----
124
      titleLabel.setText( get( "Dialog.image.titleLabel.text" ) );
125
      pane.add( titleLabel, "cell 0 2" );
126
      pane.add( titleField, "cell 1 2 2 1" );
127
128
      //---- previewLabel ----
129
      previewLabel.setText( get( "Dialog.image.previewLabel.text" ) );
130
      pane.add( previewLabel, "cell 0 3" );
131
      pane.add( previewField, "cell 1 3 2 1" );
132
    }
133
    // JFormDesigner - End of component initialization  //GEN-END:initComponents
27
  protected void initInputFields() {
28
    addInputField(
29
      "url",
30
      STR."\{PREFIX}label.url", STR."\{PREFIX}prompt.url",
31
      mModel.getUrl(),
32
      ( _, _, n ) -> mModel.setUrl( n )
33
    );
34
    addInputField(
35
      "text",
36
      STR."\{PREFIX}label.text", STR."\{PREFIX}prompt.text",
37
      mModel.getText(),
38
      ( _, _, n ) -> mModel.setText( n )
39
    );
40
    addInputField(
41
      "title",
42
      STR."\{PREFIX}label.title", STR."\{PREFIX}prompt.title",
43
      mModel.getTitle(),
44
      ( _, _, n ) -> mModel.setTitle( n )
45
    );
13446
  }
13547
136
  // JFormDesigner - Variables declaration - DO NOT MODIFY  //GEN-BEGIN:variables
137
  private MigPane pane;
138
  private EscapeTextField urlField;
139
  private BrowseFileButton linkBrowseFileButton;
140
  private EscapeTextField textField;
141
  private EscapeTextField titleField;
142
  private Label previewField;
143
	// JFormDesigner - End of variables declaration  //GEN-END:variables
48
  @Override
49
  protected String handleAccept() {
50
    return mModel.toString();
51
  }
14452
}
14553
D src/main/java/com/keenwrite/ui/dialogs/ImageDialog.jfd
1
JFDML JFormDesigner: "9.9.9.9.9999" Java: "1.8.0_66" encoding: "UTF-8"
2
3
new FormModel {
4
	"i18n.bundlePackage": "com.scrivendor"
5
	"i18n.bundleName": "messages"
6
	"i18n.autoExternalize": true
7
	"i18n.keyPrefix": "ImageDialog"
8
	contentType: "form/javafx"
9
	root: new FormRoot {
10
		add( new FormContainer( "org.tbee.javafx.scene.layout.fxml.MigPane", new FormLayoutManager( class org.tbee.javafx.scene.layout.fxml.MigPane ) {
11
			"$layoutConstraints": ""
12
			"$columnConstraints": "[shrink 0,fill][300,grow,fill][fill]"
13
			"$rowConstraints": "[][][][]"
14
		} ) {
15
			name: "pane"
16
			add( new FormComponent( "javafx.scene.control.Label" ) {
17
				name: "urlLabel"
18
				"text": new FormMessage( null, "ImageDialog.urlLabel.text" )
19
				auxiliary() {
20
					"JavaCodeGenerator.variableLocal": true
21
				}
22
			}, new FormLayoutConstraints( class net.miginfocom.layout.CC ) {
23
				"value": "cell 0 0"
24
			} )
25
			add( new FormComponent( "com.scrivendor.controls.EscapeTextField" ) {
26
				name: "urlField"
27
				"escapeCharacters": "()"
28
				"text": "http://yourlink.com"
29
				"promptText": "http://yourlink.com"
30
			}, new FormLayoutConstraints( class net.miginfocom.layout.CC ) {
31
				"value": "cell 1 0"
32
			} )
33
			add( new FormComponent( "com.scrivendor.controls.BrowseFileButton" ) {
34
				name: "linkBrowseFileButton"
35
			}, new FormLayoutConstraints( class net.miginfocom.layout.CC ) {
36
				"value": "cell 2 0"
37
			} )
38
			add( new FormComponent( "javafx.scene.control.Label" ) {
39
				name: "textLabel"
40
				"text": new FormMessage( null, "ImageDialog.textLabel.text" )
41
				auxiliary() {
42
					"JavaCodeGenerator.variableLocal": true
43
				}
44
			}, new FormLayoutConstraints( class net.miginfocom.layout.CC ) {
45
				"value": "cell 0 1"
46
			} )
47
			add( new FormComponent( "com.scrivendor.controls.EscapeTextField" ) {
48
				name: "textField"
49
				"escapeCharacters": "[]"
50
			}, new FormLayoutConstraints( class net.miginfocom.layout.CC ) {
51
				"value": "cell 1 1 2 1"
52
			} )
53
			add( new FormComponent( "javafx.scene.control.Label" ) {
54
				name: "titleLabel"
55
				"text": new FormMessage( null, "ImageDialog.titleLabel.text" )
56
				auxiliary() {
57
					"JavaCodeGenerator.variableLocal": true
58
				}
59
			}, new FormLayoutConstraints( class net.miginfocom.layout.CC ) {
60
				"value": "cell 0 2"
61
			} )
62
			add( new FormComponent( "com.scrivendor.controls.EscapeTextField" ) {
63
				name: "titleField"
64
			}, new FormLayoutConstraints( class net.miginfocom.layout.CC ) {
65
				"value": "cell 1 2 2 1"
66
			} )
67
			add( new FormComponent( "javafx.scene.control.Label" ) {
68
				name: "previewLabel"
69
				"text": new FormMessage( null, "ImageDialog.previewLabel.text" )
70
				auxiliary() {
71
					"JavaCodeGenerator.variableLocal": true
72
				}
73
			}, new FormLayoutConstraints( class net.miginfocom.layout.CC ) {
74
				"value": "cell 0 3"
75
			} )
76
			add( new FormComponent( "javafx.scene.control.Label" ) {
77
				name: "previewField"
78
			}, new FormLayoutConstraints( class net.miginfocom.layout.CC ) {
79
				"value": "cell 1 3 2 1"
80
			} )
81
		}, new FormLayoutConstraints( null ) {
82
			"location": new javafx.geometry.Point2D( 0.0, 0.0 )
83
			"size": new javafx.geometry.Dimension2D( 500.0, 300.0 )
84
		} )
85
	}
86
}
871
D src/main/java/com/keenwrite/ui/dialogs/LinkDialog.java
1
/*
2
 * Copyright 2016 Karl Tauber and White Magic Software, Ltd.
3
 *
4
 * All rights reserved.
5
 *
6
 * Redistribution and use in source and binary forms, with or without
7
 * modification, are permitted provided that the following conditions are met:
8
 *
9
 *  o Redistributions of source code must retain the above copyright
10
 *    notice, this list of conditions and the following disclaimer.
11
 *
12
 *  o Redistributions in binary form must reproduce the above copyright
13
 *    notice, this list of conditions and the following disclaimer in the
14
 *    documentation and/or other materials provided with the distribution.
15
 *
16
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
17
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
18
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
19
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
20
 * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
21
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
22
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
23
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
24
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
25
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
26
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
27
 */
28
package com.keenwrite.ui.dialogs;
29
30
import com.keenwrite.ui.controls.EscapeTextField;
31
import com.keenwrite.editors.markdown.HyperlinkModel;
32
import javafx.application.Platform;
33
import javafx.beans.binding.Bindings;
34
import javafx.beans.property.SimpleStringProperty;
35
import javafx.beans.property.StringProperty;
36
import javafx.scene.control.ButtonBar.ButtonData;
37
import javafx.scene.control.DialogPane;
38
import javafx.scene.control.Label;
39
import javafx.stage.Window;
40
import org.tbee.javafx.scene.layout.fxml.MigPane;
41
42
import static com.keenwrite.Messages.get;
43
import static javafx.scene.control.ButtonType.OK;
44
45
/**
46
 * Dialog to enter a Markdown link.
47
 */
48
public class LinkDialog extends AbstractDialog<String> {
49
50
  private final StringProperty link = new SimpleStringProperty();
51
52
  public LinkDialog(
53
    final Window owner, final HyperlinkModel hyperlink ) {
54
    super( owner, "Dialog.link.title" );
55
56
    final DialogPane dialogPane = getDialogPane();
57
    dialogPane.setContent( pane );
58
59
    dialogPane.lookupButton( OK ).disableProperty().bind(
60
      urlField.escapedTextProperty().isEmpty() );
61
62
    textField.setText( hyperlink.getText() );
63
    urlField.setText( hyperlink.getUrl() );
64
    titleField.setText( hyperlink.getTitle() );
65
66
    link.bind( Bindings.when( titleField.escapedTextProperty().isNotEmpty() )
67
      .then( Bindings.format( "[%s](%s \"%s\")", textField.escapedTextProperty(), urlField.escapedTextProperty(), titleField.escapedTextProperty() ) )
68
      .otherwise( Bindings.when( textField.escapedTextProperty().isNotEmpty() )
69
        .then( Bindings.format( "[%s](%s)", textField.escapedTextProperty(), urlField.escapedTextProperty() ) )
70
        .otherwise( urlField.escapedTextProperty() ) ) );
71
72
    setResultConverter( dialogButton -> {
73
      ButtonData data = dialogButton != null ? dialogButton.getButtonData() : null;
74
      return data == ButtonData.OK_DONE ? link.get() : null;
75
    } );
76
77
    Platform.runLater( () -> {
78
      urlField.requestFocus();
79
      urlField.selectRange( 0, urlField.getLength() );
80
    } );
81
  }
82
83
  @Override
84
  protected void initComponents() {
85
    // JFormDesigner - Component initialization - DO NOT MODIFY  //GEN-BEGIN:initComponents
86
    pane = new MigPane();
87
    Label urlLabel = new Label();
88
    urlField = new EscapeTextField();
89
    Label textLabel = new Label();
90
    textField = new EscapeTextField();
91
    Label titleLabel = new Label();
92
    titleField = new EscapeTextField();
93
94
    //======== pane ========
95
    {
96
      pane.setCols( "[shrink 0,fill][300,grow,fill][fill][fill]" );
97
      pane.setRows( "[][][][]" );
98
99
      //---- urlLabel ----
100
      urlLabel.setText( get( "Dialog.link.urlLabel.text" ) );
101
      pane.add( urlLabel, "cell 0 0" );
102
103
      //---- urlField ----
104
      urlField.setEscapeCharacters( "()" );
105
      pane.add( urlField, "cell 1 0" );
106
107
      //---- textLabel ----
108
      textLabel.setText( get( "Dialog.link.textLabel.text" ) );
109
      pane.add( textLabel, "cell 0 1" );
110
111
      //---- textField ----
112
      textField.setEscapeCharacters( "[]" );
113
      pane.add( textField, "cell 1 1 3 1" );
114
115
      //---- titleLabel ----
116
      titleLabel.setText( get( "Dialog.link.titleLabel.text" ) );
117
      pane.add( titleLabel, "cell 0 2" );
118
      pane.add( titleField, "cell 1 2 3 1" );
119
    }
120
    // JFormDesigner - End of component initialization  //GEN-END:initComponents
121
  }
122
123
  // JFormDesigner - Variables declaration - DO NOT MODIFY  //GEN-BEGIN:variables
124
  private MigPane pane;
125
  private EscapeTextField urlField;
126
  private EscapeTextField textField;
127
  private EscapeTextField titleField;
128
  // JFormDesigner - End of variables declaration  //GEN-END:variables
129
}
1301
D src/main/java/com/keenwrite/ui/dialogs/LinkDialog.jfd
1
JFDML JFormDesigner: "9.9.9.9.9999" Java: "1.8.0_66" encoding: "UTF-8"
2
3
new FormModel {
4
	"i18n.bundlePackage": "com.scrivendor"
5
	"i18n.bundleName": "messages"
6
	"i18n.autoExternalize": true
7
	"i18n.keyPrefix": "LinkDialog"
8
	contentType: "form/javafx"
9
	root: new FormRoot {
10
		add( new FormContainer( "org.tbee.javafx.scene.layout.fxml.MigPane", new FormLayoutManager( class org.tbee.javafx.scene.layout.fxml.MigPane ) {
11
			"$layoutConstraints": ""
12
			"$columnConstraints": "[shrink 0,fill][300,grow,fill][fill][fill]"
13
			"$rowConstraints": "[][][][]"
14
		} ) {
15
			name: "pane"
16
			add( new FormComponent( "javafx.scene.control.Label" ) {
17
				name: "urlLabel"
18
				"text": new FormMessage( null, "LinkDialog.urlLabel.text" )
19
				auxiliary() {
20
					"JavaCodeGenerator.variableLocal": true
21
				}
22
			}, new FormLayoutConstraints( class net.miginfocom.layout.CC ) {
23
				"value": "cell 0 0"
24
			} )
25
			add( new FormComponent( "com.scrivendor.controls.EscapeTextField" ) {
26
				name: "urlField"
27
				"escapeCharacters": "()"
28
				"text": "http://yourlink.com"
29
				"promptText": "http://yourlink.com"
30
			}, new FormLayoutConstraints( class net.miginfocom.layout.CC ) {
31
				"value": "cell 1 0"
32
			} )
33
			add( new FormComponent( "com.scrivendor.controls.BrowseDirectoryButton" ) {
34
				name: "linkBrowseDirectoyButton"
35
			}, new FormLayoutConstraints( class net.miginfocom.layout.CC ) {
36
				"value": "cell 2 0"
37
			} )
38
			add( new FormComponent( "com.scrivendor.controls.BrowseFileButton" ) {
39
				name: "linkBrowseFileButton"
40
			}, new FormLayoutConstraints( class net.miginfocom.layout.CC ) {
41
				"value": "cell 3 0"
42
			} )
43
			add( new FormComponent( "javafx.scene.control.Label" ) {
44
				name: "textLabel"
45
				"text": new FormMessage( null, "LinkDialog.textLabel.text" )
46
				auxiliary() {
47
					"JavaCodeGenerator.variableLocal": true
48
				}
49
			}, new FormLayoutConstraints( class net.miginfocom.layout.CC ) {
50
				"value": "cell 0 1"
51
			} )
52
			add( new FormComponent( "com.scrivendor.controls.EscapeTextField" ) {
53
				name: "textField"
54
				"escapeCharacters": "[]"
55
			}, new FormLayoutConstraints( class net.miginfocom.layout.CC ) {
56
				"value": "cell 1 1 3 1"
57
			} )
58
			add( new FormComponent( "javafx.scene.control.Label" ) {
59
				name: "titleLabel"
60
				"text": new FormMessage( null, "LinkDialog.titleLabel.text" )
61
				auxiliary() {
62
					"JavaCodeGenerator.variableLocal": true
63
				}
64
			}, new FormLayoutConstraints( class net.miginfocom.layout.CC ) {
65
				"value": "cell 0 2"
66
			} )
67
			add( new FormComponent( "com.scrivendor.controls.EscapeTextField" ) {
68
				name: "titleField"
69
			}, new FormLayoutConstraints( class net.miginfocom.layout.CC ) {
70
				"value": "cell 1 2 3 1"
71
			} )
72
			add( new FormComponent( "javafx.scene.control.Label" ) {
73
				name: "previewLabel"
74
				"text": new FormMessage( null, "LinkDialog.previewLabel.text" )
75
				auxiliary() {
76
					"JavaCodeGenerator.variableLocal": true
77
				}
78
			}, new FormLayoutConstraints( class net.miginfocom.layout.CC ) {
79
				"value": "cell 0 3"
80
			} )
81
			add( new FormComponent( "javafx.scene.control.Label" ) {
82
				name: "previewField"
83
			}, new FormLayoutConstraints( class net.miginfocom.layout.CC ) {
84
				"value": "cell 1 3 3 1"
85
			} )
86
		}, new FormLayoutConstraints( null ) {
87
			"location": new javafx.geometry.Point2D( 0.0, 0.0 )
88
			"size": new javafx.geometry.Dimension2D( 500.0, 300.0 )
89
		} )
90
	}
91
}
921
A src/main/java/com/keenwrite/ui/dialogs/OpenUrlDialog.java
1
/* Copyright 2023 White Magic Software, Ltd. -- All rights reserved.
2
 *
3
 * SPDX-License-Identifier: MIT
4
 */
5
package com.keenwrite.ui.dialogs;
6
7
import com.keenwrite.events.FileOpenEvent;
8
import javafx.stage.Window;
9
10
import java.io.File;
11
import java.net.URI;
12
import java.nio.file.Path;
13
14
import static com.keenwrite.events.StatusEvent.clue;
15
import static com.keenwrite.io.downloads.DownloadManager.*;
16
import static com.keenwrite.util.Strings.sanitize;
17
18
/**
19
 * Dialog to open a remote Markdown file.
20
 */
21
public final class OpenUrlDialog extends CustomDialog<File> {
22
  private static final String PREFIX = "Dialog.open_url.";
23
  private static final String DOWNLOAD = "Main.status.url.request.";
24
  private static final String STATUS = STR."\{DOWNLOAD}status.";
25
26
  private final Path mParent;
27
  private String mUrl = "";
28
29
  /**
30
   * Ensures that all dialogs can be closed.
31
   *
32
   * @param owner  The parent window of this dialog.
33
   * @param parent Directory to store downloaded file.
34
   */
35
  public OpenUrlDialog( final Window owner, final Path parent ) {
36
    super( owner, STR."\{PREFIX}title" );
37
38
    mParent = parent;
39
40
    super.initialize();
41
  }
42
43
  @Override
44
  protected void initInputFields() {
45
    addInputField(
46
      "url",
47
      STR."\{PREFIX}label.url", STR."\{PREFIX}prompt.url",
48
      mUrl,
49
      ( _, _, n ) -> mUrl = sanitize( n )
50
    );
51
  }
52
53
  @Override
54
  protected File handleAccept() {
55
    return mUrl.isBlank() ? null : download( mUrl );
56
  }
57
58
  private File download( final String reference ) {
59
    try {
60
      clue( STR."\{DOWNLOAD}fetch", reference );
61
62
      final var uri = new URI( reference );
63
      final var path = toFile( uri );
64
      final var basedir = path.getName();
65
      final var file = mParent.resolve( basedir ).toFile();
66
67
      if( file.exists() ) {
68
        clue( STR."\{DOWNLOAD}exists", file );
69
      }
70
      else {
71
        final var task = downloadAsync( uri, file, ( progress, bytes ) -> {
72
          final var suffix = progress < 0 ? "bytes" : "progress";
73
74
          clue( STR."\{STATUS}\{suffix}", progress, bytes );
75
        } );
76
77
        task.setOnSucceeded( _ -> {
78
          clue( STR."\{DOWNLOAD}success", file );
79
80
          // Only after the download succeeds can we open the file.
81
          FileOpenEvent.fire( file.toURI() );
82
        } );
83
        task.setOnFailed( _ -> clue( STR."\{DOWNLOAD}failure", uri ) );
84
      }
85
86
      // The return value isn't used because the download happens
87
      // asynchronously. If the download succeeds, an event is fired.
88
      return null;
89
    } catch( final Exception e ) {
90
      throw new RuntimeException( e );
91
    }
92
  }
93
}
194
A src/main/java/com/keenwrite/ui/models/HyperlinkModel.java
1
/* Copyright 2023 White Magic Software, Ltd. -- All rights reserved.
2
 *
3
 * SPDX-License-Identifier: MIT
4
 */
5
package com.keenwrite.ui.models;
6
7
import com.vladsch.flexmark.ast.Link;
8
9
/**
10
 * Represents the model for a hyperlink: text, url, and title.
11
 */
12
public final class HyperlinkModel extends ObjectModel {
13
14
  /**
15
   * Constructs a new hyperlink model in Markdown format by default with no
16
   * title (i.e., tooltip).
17
   *
18
   * @param text The hyperlink text displayed (e.g., displayed to the user).
19
   */
20
  public HyperlinkModel( final String text ) {
21
    super( text );
22
  }
23
24
  /**
25
   * Constructs a new hyperlink model in Markdown format by default.
26
   *
27
   * @param text  The hyperlink text displayed (e.g., displayed to the user).
28
   * @param url   The destination URL (e.g., when clicked).
29
   * @param title The hyperlink title (e.g., shown as a tooltip).
30
   */
31
  public HyperlinkModel(
32
    final String text, final String url, final String title ) {
33
    super( text, url, title );
34
  }
35
36
  /**
37
   * Constructs a new hyperlink model for the given AST link.
38
   *
39
   * @param link A Markdown link.
40
   */
41
  public HyperlinkModel( final Link link ) {
42
    this(
43
      link.getText().toString(),
44
      link.getUrl().toString(),
45
      link.getTitle().toString()
46
    );
47
  }
48
49
  /**
50
   * Returns the string in Markdown format by default.
51
   *
52
   * @return A Markdown version of the hyperlink.
53
   */
54
  @Override
55
  public String toString() {
56
    final String format = hasText()
57
      ? STR."[%s]\{hasTitle() ? "(%s \"%s\")" : "(%s%s)"}"
58
      : "%s%s%s";
59
60
    // Becomes ""+URL+"" if no text is set.
61
    // Becomes [TITLE]+(URL)+"" if no title is set.
62
    // Becomes [TITLE]+(URL+ \"TITLE\") if title is set.
63
    return String.format( format, getText(), getUrl(), getTitle() );
64
  }
65
}
166
A src/main/java/com/keenwrite/ui/models/ImageModel.java
1
/* Copyright 2023 White Magic Software, Ltd. -- All rights reserved.
2
 *
3
 * SPDX-License-Identifier: MIT
4
 */
5
package com.keenwrite.ui.models;
6
7
/**
8
 * Represents the model for an image: text, url, and title.
9
 */
10
public final class ImageModel extends ObjectModel {
11
12
  /**
13
   * Constructs a new image model in Markdown format by default with no
14
   * title (i.e., tooltip).
15
   *
16
   * @param text The alternate text (e.g., displayed to the user).
17
   */
18
  public ImageModel( final String text ) {
19
    super( text );
20
  }
21
22
  /**
23
   * Returns the string in Markdown format by default.
24
   *
25
   * @return An image reference using Markdown syntax.
26
   */
27
  @Override
28
  public String toString() {
29
    final String format = hasText()
30
      ? STR."![%s]\{hasTitle() ? "(%s \"%s\")" : "(%s%s)"}"
31
      : "![%s](%s)%s";
32
33
    return String.format( format, getText(), getUrl(), getTitle() );
34
  }
35
}
136
A src/main/java/com/keenwrite/ui/models/ObjectModel.java
1
/* Copyright 2023 White Magic Software, Ltd. -- All rights reserved.
2
 *
3
 * SPDX-License-Identifier: MIT
4
 */
5
package com.keenwrite.ui.models;
6
7
import static com.keenwrite.util.Strings.sanitize;
8
9
/**
10
 * Represents the model for an object containing text, url, and title.
11
 */
12
class ObjectModel {
13
  private String mText;
14
  private String mUrl;
15
  private String mTitle;
16
17
  /**
18
   * Constructs a new object model in Markdown format by default with no
19
   * title (i.e., tooltip).
20
   *
21
   * @param text The hyperlink text displayed (e.g., displayed to the user).
22
   */
23
  public ObjectModel( final String text ) {
24
    this( text, null, null );
25
  }
26
27
  /**
28
   * Constructs a new object model in Markdown format by default.
29
   *
30
   * @param text  The text displayed (e.g., to the user).
31
   * @param url   The destination URL (e.g., when clicked).
32
   * @param title The text title (e.g., shown as a tooltip).
33
   */
34
  public ObjectModel(
35
    final String text, final String url, final String title ) {
36
    setText( text );
37
    setUrl( url );
38
    setTitle( title );
39
  }
40
41
  public void setText( final String text ) {
42
    mText = sanitize( text );
43
  }
44
45
  public void setUrl( final String url ) {
46
    mUrl = sanitize( url );
47
  }
48
49
  public void setTitle( final String title ) {
50
    mTitle = sanitize( title );
51
  }
52
53
  /**
54
   * Answers whether text has been set for the model.
55
   *
56
   * @return true The text description is set.
57
   */
58
  public boolean hasText() {
59
    return !getText().isEmpty();
60
  }
61
62
  /**
63
   * Answers whether a title (tooltip) has been set for the model.
64
   *
65
   * @return true The title is set.
66
   */
67
  public boolean hasTitle() {
68
    return !getTitle().isEmpty();
69
  }
70
71
  public String getText() {
72
    return mText;
73
  }
74
75
  public String getUrl() {
76
    return mUrl;
77
  }
78
79
  public String getTitle() {
80
    return mTitle;
81
  }
82
}
183
M src/main/java/com/keenwrite/util/CyclicIterator.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2023 White Magic Software, Ltd. -- All rights reserved.
2
 *
3
 * SPDX-License-Identifier: MIT
4
 */
25
package com.keenwrite.util;
36
A src/main/java/com/keenwrite/util/FailableBiConsumer.java
1
/*
2
 * Licensed to the Apache Software Foundation (ASF) under one or more
3
 * contributor license agreements.  See the NOTICE file distributed with
4
 * this work for additional information regarding copyright ownership.
5
 * The ASF licenses this file to You under the Apache License, Version 2.0
6
 * (the "License"); you may not use this file except in compliance with
7
 * the License.  You may obtain a copy of the License at
8
 *
9
 *      http://www.apache.org/licenses/LICENSE-2.0
10
 *
11
 * Unless required by applicable law or agreed to in writing, software
12
 * distributed under the License is distributed on an "AS IS" BASIS,
13
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
 * See the License for the specific language governing permissions and
15
 * limitations under the License.
16
 */
17
package com.keenwrite.util;
18
19
import java.util.function.BiConsumer;
20
21
/**
22
 * A functional interface like {@link BiConsumer} that declares a {@link Throwable}.
23
 *
24
 * @param <T> Consumed type 1.
25
 * @param <U> Consumed type 2.
26
 * @param <E> The kind of thrown exception or error.
27
 */
28
@FunctionalInterface
29
public interface FailableBiConsumer<T, U, E extends Throwable> {
30
31
  /**
32
   * Accepts the given arguments.
33
   *
34
   * @param t the first parameter for the consumable to accept
35
   * @param u the second parameter for the consumable to accept
36
   * @throws E Thrown when the consumer fails.
37
   */
38
  void accept(T t, U u) throws E;
39
}
140
A src/main/java/com/keenwrite/util/Strings.java
1
/*
2
 * Licensed to the Apache Software Foundation (ASF) under one or more
3
 * contributor license agreements.  See the NOTICE file distributed with
4
 * this work for additional information regarding copyright ownership.
5
 * The ASF licenses this file to You under the Apache License, Version 2.0
6
 * (the "License"); you may not use this file except in compliance with
7
 * the License.  You may obtain a copy of the License at
8
 *
9
 *      http://www.apache.org/licenses/LICENSE-2.0
10
 *
11
 * Unless required by applicable law or agreed to in writing, software
12
 * distributed under the License is distributed on an "AS IS" BASIS,
13
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
 * See the License for the specific language governing permissions and
15
 * limitations under the License.
16
 */
17
package com.keenwrite.util;
18
19
import java.lang.reflect.Array;
20
import java.util.Arrays;
21
import java.util.HashSet;
22
import java.util.Set;
23
24
import static java.lang.Character.isWhitespace;
25
import static java.lang.String.format;
26
27
/**
28
 * Java doesn't allow adding behaviour to its {@link String} class, so these
29
 * functions have no alternative home. They are duplicated here to eliminate
30
 * the dependency on an Apache library. Extracting the methods that only
31
 * the application uses may have some small performance gains, as well,
32
 * because numerous if clauses have been removed and other code simplified.
33
 */
34
public class Strings {
35
  /**
36
   * The empty String {@code ""}.
37
   */
38
  private static final String EMPTY = "";
39
40
  /**
41
   * Abbreviates a String using ellipses. This will turn
42
   * "Now is the time for all good men" into "Now is the time for..."
43
   *
44
   * @param str   the String to check, may be {@code null}.
45
   * @param width maximum length of result String, must be at least 4.
46
   * @return abbreviated String, {@code null} if {@code null} String input.
47
   * @throws IllegalArgumentException if the width is too small.
48
   */
49
  public static String abbreviate( final String str, final int width ) {
50
    return abbreviate( str, "...", 0, width );
51
  }
52
53
  /**
54
   * Abbreviates a String using another given String as replacement marker.
55
   * This will turn"Now is the time for all good men" into "Now is the time
56
   * for..." if "..." was defined as the replacement marker.
57
   *
58
   * @param str        the String to check, may be {@code null}.
59
   * @param abbrMarker the String used as replacement marker.
60
   * @param width      maximum length of result String, must be at least
61
   *                   {@code abbrMarker.length + 1}.
62
   * @return abbreviated String, {@code null} if {@code null} String input.
63
   * @throws IllegalArgumentException if the width is too small.
64
   */
65
  public static String abbreviate(
66
    final String str,
67
    final String abbrMarker,
68
    final int width ) {
69
    return abbreviate( str, abbrMarker, 0, width );
70
  }
71
72
  /**
73
   * Abbreviates a String using a given replacement marker. This will turn
74
   * "Now is the time for all good men" into "...is the time for..." if "..."
75
   * was defined as the replacement marker.
76
   *
77
   * @param str        the String to check, may be {@code null}.
78
   * @param abbrMarker the String used as replacement marker.
79
   * @param offset     left edge of source String.
80
   * @param width      maximum length of result String, must be at least 4.
81
   * @return abbreviated String, {@code null} if {@code null} String input.
82
   * @throws IllegalArgumentException if the width is too small.
83
   */
84
  public static String abbreviate(
85
    final String str,
86
    final String abbrMarker,
87
    int offset,
88
    final int width ) {
89
    if( !isEmpty( str ) && EMPTY.equals( abbrMarker ) && width > 0 ) {
90
      return substring( str, width );
91
    }
92
93
    if( isAnyEmpty( str, abbrMarker ) ) {
94
      return str;
95
    }
96
97
    final int abbrMarkerLen = abbrMarker.length();
98
    final int minAbbrWidth = abbrMarkerLen + 1;
99
    final int minAbbrWidthOffset = abbrMarkerLen + abbrMarkerLen + 1;
100
101
    if( width < minAbbrWidth ) {
102
      final String msg = format( "Min abbreviation width: %d", minAbbrWidth );
103
      throw new IllegalArgumentException( msg );
104
    }
105
106
    final int strLen = str.length();
107
108
    if( strLen <= width ) {
109
      return str;
110
    }
111
112
    if( offset > strLen ) {
113
      offset = strLen;
114
    }
115
116
    if( strLen - offset < width - abbrMarkerLen ) {
117
      offset = strLen - (width - abbrMarkerLen);
118
    }
119
120
    if( offset <= abbrMarkerLen + 1 ) {
121
      return str.substring( 0, width - abbrMarkerLen ) + abbrMarker;
122
    }
123
124
    if( width < minAbbrWidthOffset ) {
125
      final String msg = format(
126
        "Min abbreviation width with offset: %d",
127
        minAbbrWidthOffset
128
      );
129
      throw new IllegalArgumentException( msg );
130
    }
131
132
    if( offset + width - abbrMarkerLen < strLen ) {
133
      return abbrMarker + abbreviate(
134
        str.substring( offset ),
135
        abbrMarker,
136
        width - abbrMarkerLen
137
      );
138
    }
139
140
    return abbrMarker + str.substring( strLen - (width - abbrMarkerLen) );
141
  }
142
143
  /**
144
   * Strips whitespace characters from the end of a String.
145
   *
146
   * <p>A {@code null} input String returns {@code null}.
147
   * An empty string ("") input returns the empty string.</p>
148
   *
149
   * @param str the String to remove characters from, may be {@code null}.
150
   * @return the stripped String, {@code null} if {@code null} input.
151
   */
152
  public static String trimEnd( final String str ) {
153
    int end = length( str );
154
155
    if( end == 0 ) {
156
      return str;
157
    }
158
159
    while( end != 0 && isWhitespace( str.charAt( end - 1 ) ) ) {
160
      end--;
161
    }
162
163
    return str.substring( 0, end );
164
  }
165
166
  /**
167
   * Strips whitespace characters from the start of a String.
168
   *
169
   * <p>A {@code null} input returns {@code null}.
170
   * An empty string ("") input returns the empty string.</p>
171
   *
172
   * @param str the String to remove characters from, may be {@code null}.
173
   * @return the stripped String, {@code null} if {@code null} input.
174
   */
175
  public static String trimStart( final String str ) {
176
    final int strLen = length( str );
177
178
    if( strLen == 0 ) {
179
      return str;
180
    }
181
182
    int start = 0;
183
184
    while( start != strLen && isWhitespace( str.charAt( start ) ) ) {
185
      start++;
186
    }
187
188
    return str.substring( start );
189
  }
190
191
  /**
192
   * Replaces all occurrences of Strings within another String.
193
   *
194
   * @param text            the haystack, no-op if {@code null}.
195
   * @param searchList      the needles, no-op if {@code null}.
196
   * @param replacementList the new needles, no-op if {@code null}.
197
   * @return the text with any replacements processed, {@code null} if
198
   * {@code null} String input.
199
   * @throws IllegalArgumentException if the lengths of the arrays are not
200
   *                                  the same ({@code null}  is ok, and/or
201
   *                                  size 0).
202
   */
203
  public static String replaceEach( final String text,
204
                                    final String[] searchList,
205
                                    final String[] replacementList ) {
206
    return replaceEach( text, searchList, replacementList, 0 );
207
  }
208
209
  /**
210
   * Replace all occurrences of Strings within another String.
211
   *
212
   * @param text            the haystack, no-op if {@code null}.
213
   * @param searchList      the needles, no-op if {@code null}.
214
   * @param replacementList the new needles, no-op if {@code null}.
215
   * @param timeToLive      if less than 0 then there is a circular reference
216
   *                        and endless loop
217
   * @return the text with any replacements processed, {@code null} if
218
   * {@code null} String input.
219
   * @throws IllegalStateException    if the search is repeating and there is
220
   *                                  an endless loop due to outputs of one
221
   *                                  being inputs to another
222
   * @throws IllegalArgumentException if the lengths of the arrays are not
223
   *                                  the same ({@code null} is ok, and/or
224
   *                                  size 0)
225
   */
226
  private static String replaceEach(
227
    final String text,
228
    final String[] searchList,
229
    final String[] replacementList,
230
    final int timeToLive
231
  ) {
232
    // If in a recursive call, this shouldn't be less than zero.
233
    if( timeToLive < 0 ) {
234
      final Set<String> searchSet =
235
        new HashSet<>( Arrays.asList( searchList ) );
236
      final Set<String> replacementSet = new HashSet<>( Arrays.asList(
237
        replacementList ) );
238
      searchSet.retainAll( replacementSet );
239
      if( !searchSet.isEmpty() ) {
240
        throw new IllegalStateException(
241
          "Aborting to protect against StackOverflowError - " +
242
          "output of one loop is the input of another" );
243
      }
244
    }
245
246
    if( isEmpty( text ) ||
247
        isEmpty( searchList ) ||
248
        isEmpty( replacementList ) ||
249
        isNotEmpty( searchList ) &&
250
        timeToLive == -1 ) {
251
      return text;
252
    }
253
254
    final int searchLength = searchList.length;
255
    final int replacementLength = replacementList.length;
256
257
    // make sure lengths are ok, these need to be equal
258
    if( searchLength != replacementLength ) {
259
      final String msg = format(
260
        "Search and Replace array lengths don't match: %d vs %d",
261
        searchLength,
262
        replacementLength
263
      );
264
      throw new IllegalArgumentException( msg );
265
    }
266
267
    // keep track of which still have matches
268
    final boolean[] noMoreMatchesForReplIndex = new boolean[ searchLength ];
269
270
    // index on index that the match was found
271
    int textIndex = -1;
272
    int replaceIndex = -1;
273
    int tempIndex;
274
275
    // index of replace array that will replace the search string found
276
    // NOTE: logic duplicated below START
277
    for( int i = 0; i < searchLength; i++ ) {
278
      if( noMoreMatchesForReplIndex[ i ] || isEmpty( searchList[ i ] ) || replacementList[ i ] == null ) {
279
        continue;
280
      }
281
      tempIndex = text.indexOf( searchList[ i ] );
282
283
      // see if we need to keep searching for this
284
      if( tempIndex == -1 ) {
285
        noMoreMatchesForReplIndex[ i ] = true;
286
      }
287
      else if( textIndex == -1 || tempIndex < textIndex ) {
288
        textIndex = tempIndex;
289
        replaceIndex = i;
290
      }
291
    }
292
    // NOTE: logic mostly below END
293
294
    // no search strings found, we are done
295
    if( textIndex == -1 ) {
296
      return text;
297
    }
298
299
    int start = 0;
300
301
    // Guess the result buffer size, to prevent doubling capacity.
302
    final StringBuilder buf = createStringBuilder(
303
      text, searchList, replacementList
304
    );
305
306
    while( textIndex != -1 ) {
307
      for( int i = start; i < textIndex; i++ ) {
308
        buf.append( text.charAt( i ) );
309
      }
310
311
      buf.append( replacementList[ replaceIndex ] );
312
313
      start = textIndex + searchList[ replaceIndex ].length();
314
315
      textIndex = -1;
316
      replaceIndex = -1;
317
318
      // find the next earliest match
319
      // NOTE: logic mostly duplicated above START
320
      for( int i = 0; i < searchLength; i++ ) {
321
        if( noMoreMatchesForReplIndex[ i ] || isEmpty( searchList[ i ] ) || replacementList[ i ] == null ) {
322
          continue;
323
        }
324
        tempIndex = text.indexOf( searchList[ i ], start );
325
326
        // see if we need to keep searching for this
327
        if( tempIndex == -1 ) {
328
          noMoreMatchesForReplIndex[ i ] = true;
329
        }
330
        else if( textIndex == -1 || tempIndex < textIndex ) {
331
          textIndex = tempIndex;
332
          replaceIndex = i;
333
        }
334
      }
335
336
      // NOTE: logic duplicated above END
337
    }
338
339
    final int textLength = text.length();
340
    for( int i = start; i < textLength; i++ ) {
341
      buf.append( text.charAt( i ) );
342
    }
343
344
    return replaceEach(
345
      buf.toString(),
346
      searchList,
347
      replacementList,
348
      timeToLive - 1
349
    );
350
  }
351
352
  private static StringBuilder createStringBuilder(
353
    final String text,
354
    final String[] searchList,
355
    final String[] replacementList ) {
356
    int increase = 0;
357
358
    // count the replacement text elements that are larger than their
359
    // corresponding text being replaced
360
    for( int i = 0; i < searchList.length; i++ ) {
361
      if( searchList[ i ] == null || replacementList[ i ] == null ) {
362
        continue;
363
      }
364
      final int greater =
365
        replacementList[ i ].length() - searchList[ i ].length();
366
      if( greater > 0 ) {
367
        increase += 3 * greater; // assume 3 matches
368
      }
369
    }
370
371
    // have upper-bound at 20% increase, then let Java take over
372
    increase = Math.min( increase, text.length() / 5 );
373
374
    return new StringBuilder( text.length() + increase );
375
  }
376
377
  /**
378
   * Gets a {@link CharSequence} length or {@code 0} if the
379
   * {@link CharSequence} is {@code null}.
380
   *
381
   * @param cs a {@link CharSequence} or {@code null}.
382
   * @return {@link CharSequence} length or {@code 0} if the
383
   * {@link CharSequence} is {@code null}.
384
   */
385
  private static int length( final CharSequence cs ) {
386
    return cs == null ? 0 : cs.length();
387
  }
388
389
  /**
390
   * Checks if a {@link CharSequence} is empty ("") or {@code null}.
391
   *
392
   * @param cs the {@link CharSequence} to check, may be {@code null}.
393
   * @return {@code true} if the {@link CharSequence} is empty or {@code null}.
394
   */
395
  public static boolean isEmpty( final CharSequence cs ) {
396
    return cs == null || cs.isEmpty();
397
  }
398
399
  private static boolean isEmpty( final Object[] array ) {
400
    return array == null || Array.getLength( array ) == 0;
401
  }
402
403
  private static boolean isNotEmpty( final Object[] array ) {
404
    return array != null && Array.getLength( array ) > 0;
405
  }
406
407
  private static boolean isAnyEmpty( final CharSequence... css ) {
408
    if( isNotEmpty( css ) ) {
409
      for( final CharSequence cs : css ) {
410
        if( isEmpty( cs ) ) {
411
          return true;
412
        }
413
      }
414
    }
415
416
    return false;
417
  }
418
419
  /**
420
   * Gets a substring from the specified String avoiding exceptions.
421
   *
422
   * <p>A negative start position can be used to start/end {@code n}
423
   * characters from the end of the String.</p>
424
   *
425
   * <p>The returned substring starts with the character in the {@code start}
426
   * position and ends before the {@code end} position. All position counting
427
   * is zero-based -- i.e., to start at the beginning of the string use
428
   * {@code start = 0}. Negative start and end positions can be used to
429
   * specify offsets relative to the end of the String.</p>
430
   *
431
   * <p>If {@code start} is not strictly to the left of {@code end}, ""
432
   * is returned.</p>
433
   *
434
   * @param str the String to get the substring from, may be {@code null}.
435
   * @param end the position to end at (exclusive), negative means
436
   *            count back from the end of the String by this many characters
437
   * @return substring from start position to end position, {@code null} if
438
   * {@code null} String input
439
   */
440
  private static String substring( final String str, int end ) {
441
    if( str == null ) {
442
      return null;
443
    }
444
445
    final int len = str.length();
446
447
    if( end < 0 ) {
448
      end = len + end;
449
    }
450
451
    if( end > len ) {
452
      end = len;
453
    }
454
455
    final int start = 0;
456
457
    if( start > end ) {
458
      return EMPTY;
459
    }
460
461
    return str.substring( start, end );
462
  }
463
464
  public static boolean validate( final String s ) {
465
    assert s != null;
466
    assert !s.isBlank();
467
468
    return true;
469
  }
470
471
  public static String sanitize( final String s ) {
472
    return s == null ? "" : s;
473
  }
474
}
1475
A src/main/java/com/keenwrite/util/SystemUtils.java
1
/*
2
 * Licensed to the Apache Software Foundation (ASF) under one or more
3
 * contributor license agreements.  See the NOTICE file distributed with
4
 * this work for additional information regarding copyright ownership.
5
 * The ASF licenses this file to You under the Apache License, Version 2.0
6
 * (the "License"); you may not use this file except in compliance with
7
 * the License.  You may obtain a copy of the License at
8
 *
9
 *      http://www.apache.org/licenses/LICENSE-2.0
10
 *
11
 * Unless required by applicable law or agreed to in writing, software
12
 * distributed under the License is distributed on an "AS IS" BASIS,
13
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
 * See the License for the specific language governing permissions and
15
 * limitations under the License.
16
 */
17
package com.keenwrite.util;
18
19
import java.util.Properties;
20
21
import static com.keenwrite.util.Strings.isEmpty;
22
23
/**
24
 * Helpers for {@code java.lang.System}.
25
 */
26
public class SystemUtils {
27
28
  // System property constants
29
  // -----------------------------------------------------------------------
30
  // These MUST be declared first. Other constants depend on this.
31
32
  /**
33
   * The System property name {@value}.
34
   */
35
  public static final String PROPERTY_OS_NAME = "os.name";
36
37
  /**
38
   * Gets the current value from the system properties map.
39
   * <p>
40
   * Returns {@code null} if the property cannot be read due to a
41
   * {@link SecurityException}.
42
   * </p>
43
   *
44
   * @return the current value from the system properties map.
45
   */
46
  @SuppressWarnings( "ConstantValue" )
47
  private static String getOsName() {
48
    assert PROPERTY_OS_NAME != null;
49
    assert !PROPERTY_OS_NAME.isBlank();
50
51
    try {
52
      final String value = System.getProperty( PROPERTY_OS_NAME );
53
54
      return isEmpty( value ) ? "" : value;
55
    } catch( final SecurityException ignore ) {}
56
57
    return "";
58
  }
59
60
  /**
61
   * The Operating System name, derived from Java's system properties.
62
   *
63
   * <p>
64
   * Defaults to empty if the runtime does not have security access to
65
   * read this property or the property does not exist.
66
   * </p>
67
   * <p>
68
   * This value is initialized when the class is loaded. If
69
   * {@link System#setProperty(String, String)} or
70
   * {@link System#setProperties(Properties)} is called after this
71
   * class is loaded, the value will be out of sync with that System property.
72
   * </p>
73
   */
74
  public static final String OS_NAME = getOsName();
75
76
  /**
77
   * Is {@code true} if this is AIX.
78
   *
79
   * <p>
80
   * The field will return {@code false} if {@code OS_NAME} is {@code null}.
81
   * </p>
82
   */
83
  public static final boolean IS_OS_AIX = osNameMatches( "AIX" );
84
85
  /**
86
   * Is {@code true} if this is HP-UX.
87
   *
88
   * <p>
89
   * The field will return {@code false} if {@code OS_NAME} is {@code null}.
90
   * </p>
91
   */
92
  public static final boolean IS_OS_HP_UX = osNameMatches( "HP-UX" );
93
94
  /**
95
   * Is {@code true} if this is Irix.
96
   *
97
   * <p>
98
   * The field will return {@code false} if {@code OS_NAME} is {@code null}.
99
   * </p>
100
   */
101
  public static final boolean IS_OS_IRIX = osNameMatches( "Irix" );
102
103
  /**
104
   * Is {@code true} if this is Linux.
105
   *
106
   * <p>
107
   * The field will return {@code false} if {@code OS_NAME} is {@code null}.
108
   * </p>
109
   */
110
  public static final boolean IS_OS_LINUX =
111
    osNameMatches( "Linux" ) ||
112
    osNameMatches( "LINUX" );
113
114
  /**
115
   * Is {@code true} if this is Mac.
116
   *
117
   * <p>
118
   * The field will return {@code false} if {@code OS_NAME} is {@code null}.
119
   * </p>
120
   */
121
  public static final boolean IS_OS_MAC = osNameMatches( "Mac" );
122
123
  /**
124
   * Is {@code true} if this is Mac.
125
   *
126
   * <p>
127
   * The field will return {@code false} if {@code OS_NAME} is {@code null}.
128
   * </p>
129
   */
130
  public static final boolean IS_OS_MAC_OSX = osNameMatches( "Mac OS X" );
131
132
  /**
133
   * Is {@code true} if this is FreeBSD.
134
   *
135
   * <p>
136
   * The field will return {@code false} if {@code OS_NAME} is {@code null}.
137
   * </p>
138
   */
139
  public static final boolean IS_OS_FREE_BSD = osNameMatches( "FreeBSD" );
140
141
  /**
142
   * Is {@code true} if this is OpenBSD.
143
   *
144
   * <p>
145
   * The field will return {@code false} if {@code OS_NAME} is {@code null}.
146
   * </p>
147
   */
148
  public static final boolean IS_OS_OPEN_BSD = osNameMatches( "OpenBSD" );
149
150
  /**
151
   * Is {@code true} if this is NetBSD.
152
   *
153
   * <p>
154
   * The field will return {@code false} if {@code OS_NAME} is {@code null}.
155
   * </p>
156
   */
157
  public static final boolean IS_OS_NET_BSD = osNameMatches( "NetBSD" );
158
159
  /**
160
   * Is {@code true} if this is Solaris.
161
   *
162
   * <p>
163
   * The field will return {@code false} if {@code OS_NAME} is {@code null}.
164
   * </p>
165
   */
166
  public static final boolean IS_OS_SOLARIS = osNameMatches( "Solaris" );
167
168
  /**
169
   * Is {@code true} if this is SunOS.
170
   *
171
   * <p>
172
   * The field will return {@code false} if {@code OS_NAME} is {@code null}.
173
   * </p>
174
   */
175
  public static final boolean IS_OS_SUN_OS = osNameMatches( "SunOS" );
176
177
  /**
178
   * Is {@code true} if this is a UNIX like system, as in any of AIX, HP-UX,
179
   * Irix, Linux, MacOSX, Solaris or SUN OS.
180
   *
181
   * <p>
182
   * The field will return {@code false} if {@code OS_NAME} is {@code null}.
183
   * </p>
184
   */
185
  public static final boolean IS_OS_UNIX =
186
    IS_OS_AIX ||
187
    IS_OS_HP_UX ||
188
    IS_OS_IRIX ||
189
    IS_OS_LINUX ||
190
    IS_OS_MAC_OSX ||
191
    IS_OS_SOLARIS ||
192
    IS_OS_SUN_OS ||
193
    IS_OS_FREE_BSD ||
194
    IS_OS_OPEN_BSD ||
195
    IS_OS_NET_BSD;
196
197
  /**
198
   * The prefix String for all Windows OS.
199
   */
200
  private static final String OS_NAME_WINDOWS_PREFIX = "Windows";
201
202
  /**
203
   * Is {@code true} if this is Windows.
204
   *
205
   * <p>
206
   * The field will return {@code false} if {@code OS_NAME} is {@code null}.
207
   * </p>
208
   */
209
  public static final boolean IS_OS_WINDOWS =
210
    osNameMatches( OS_NAME_WINDOWS_PREFIX );
211
212
  /**
213
   * Decides if the operating system matches.
214
   * <p>
215
   * This method is package private instead of private to support unit test
216
   * invocation.
217
   * </p>
218
   *
219
   * @param prefix the prefix for the expected OS name
220
   * @return true if matches, or false if not or can't determine
221
   */
222
  private static boolean osNameMatches( final String prefix ) {
223
    return OS_NAME.startsWith( prefix );
224
  }
225
}
1226
M src/main/resources/com/keenwrite/messages.properties
3636
workspace.typeset.typography.quotes.desc=Export straight quotes and apostrophes as curled equivalents.
3737
workspace.typeset.typography.quotes.title=Curl
38
39
workspace.r=R
40
workspace.r.script=Startup Script
41
workspace.r.script.desc=Script runs prior to executing R statements within the document.
42
workspace.r.dir=Working Directory
43
workspace.r.dir.desc=Value assigned to v$application$r$working$directory and usable in the startup script.
44
workspace.r.dir.title=Directory
45
workspace.r.delimiter.began=Delimiter Prefix
46
workspace.r.delimiter.began.desc=Prefix of expression that wraps inserted variables.
47
workspace.r.delimiter.began.title=Opening
48
workspace.r.delimiter.ended=Delimiter Suffix
49
workspace.r.delimiter.ended.desc=Suffix of expression that wraps inserted variables.
50
workspace.r.delimiter.ended.title=Closing
51
52
workspace.images=Images
53
workspace.images.dir=Absolute Directory
54
workspace.images.dir.desc=Path to search for local file system images.
55
workspace.images.dir.title=Directory
56
workspace.images.cache.desc=Path to store remotely retrieved images.
57
workspace.images.cache.title=Directory
58
workspace.images.order=Extensions
59
workspace.images.order.desc=Preferred order of image file types to embed, separated by spaces.
60
workspace.images.order.title=Extensions
61
workspace.images.resize=Resize
62
workspace.images.resize.desc=Scale images to fit the preview panel when resizing, automatically.
63
workspace.images.resize.title=Resize
64
workspace.images.server=Diagram Server
65
workspace.images.server.desc=Server used to generate diagrams (e.g., kroki.io).
66
workspace.images.server.title=Name
67
68
workspace.definition=Variable
69
workspace.definition.path=File name
70
workspace.definition.path.desc=Absolute path to interpolated string variables.
71
workspace.definition.path.title=Path
72
workspace.definition.delimiter.began=Delimiter Prefix
73
workspace.definition.delimiter.began.desc=Indicates when a variable name is starting.
74
workspace.definition.delimiter.began.title=Opening
75
workspace.definition.delimiter.ended=Delimiter Suffix
76
workspace.definition.delimiter.ended.desc=Indicates when a variable name is ending.
77
workspace.definition.delimiter.ended.title=Closing
78
79
workspace.ui.skin=Skins
80
workspace.ui.skin.selection=Bundled
81
workspace.ui.skin.selection.desc=Pre-packaged application style (default: Modena Light).
82
workspace.ui.skin.selection.title=Name
83
workspace.ui.skin.custom=Custom
84
workspace.ui.skin.custom.desc=User-defined JavaFX cascading stylesheet file.
85
workspace.ui.skin.custom.title=Path
86
87
workspace.ui.preview=Preview
88
workspace.ui.preview.stylesheet=Stylesheet
89
workspace.ui.preview.stylesheet.desc=User-defined HTML cascading stylesheet file.
90
workspace.ui.preview.stylesheet.title=Path
91
92
workspace.ui.font=Fonts
93
workspace.ui.font.editor=Editor Font
94
workspace.ui.font.editor.name=Name
95
workspace.ui.font.editor.name.desc=Text editor font name (sans-serif font recommended).
96
workspace.ui.font.editor.name.title=Family
97
workspace.ui.font.editor.size=Size
98
workspace.ui.font.editor.size.desc=Font size.
99
workspace.ui.font.editor.size.title=Points
100
workspace.ui.font.preview=Preview Font
101
workspace.ui.font.preview.name=Name
102
workspace.ui.font.preview.name.desc=Preview pane font name (must support ligatures, serif font recommended).
103
workspace.ui.font.preview.name.title=Family
104
workspace.ui.font.preview.size=Size
105
workspace.ui.font.preview.size.desc=Font size.
106
workspace.ui.font.preview.size.title=Points
107
workspace.ui.font.preview.mono.name=Name
108
workspace.ui.font.preview.mono.name.desc=Monospace font name.
109
workspace.ui.font.preview.mono.name.title=Family
110
workspace.ui.font.preview.mono.size=Size
111
workspace.ui.font.preview.mono.size.desc=Monospace font size.
112
workspace.ui.font.preview.mono.size.title=Points
113
workspace.ui.font.math=Math Font
114
workspace.ui.font.math.size.title=Scale
115
116
workspace.language=Language
117
workspace.language.locale=Internationalization
118
workspace.language.locale.desc=Language for application and HTML export.
119
workspace.language.locale.title=Locale
120
121
# ########################################################################
122
# Editor actions
123
# ########################################################################
124
125
Editor.spelling.check.matches.none=No suggestions for ''{0}'' found.
126
Editor.spelling.check.matches.okay=The spelling for ''{0}'' appears to be correct.
127
128
# ########################################################################
129
# Menu Bar
130
# ########################################################################
131
132
Main.menu.file=_File
133
Main.menu.edit=_Edit
134
Main.menu.insert=_Insert
135
Main.menu.format=Forma_t
136
Main.menu.definition=_Variable
137
Main.menu.view=Vie_w
138
Main.menu.help=_Help
139
140
# ########################################################################
141
# Detachable Tabs
142
# ########################################################################
143
144
# {0} is the application title; {1} is a unique window ID.
145
Detach.tab.title={0} - {1}
146
147
# ########################################################################
148
# Status Bar
149
# ########################################################################
150
151
Main.status.text.offset=offset
152
Main.status.line=Line {0} of {1}, ${Main.status.text.offset} {2}
153
Main.status.state.default=OK
154
Main.status.export.success=Saved as ''{0}''
155
156
Main.status.error.bootstrap.eval=Note: Bootstrap variable of ''{0}'' not found
157
Main.status.error.bootstrap.cache=Could not create cache directory ''{0}''
158
159
Main.status.error.parse=Evaluation error: {0}
160
Main.status.error.def.blank=Move the caret to a word before inserting a variable
161
Main.status.error.def.empty=Create a variable before inserting one
162
Main.status.error.def.missing=No variable value found for ''{0}''
163
Main.status.error.r=Error with [{0}...]: {1}
164
165
Main.status.error.file.missing=Not found: ''{0}''
166
Main.status.error.file.missing.near=Not found: ''{0}'' near line {1}
167
Main.status.error.file.delete=Failed to delete ''{0}''
168
169
Main.status.error.messages.recursion=Lookup depth exceeded, check for loops in ''{0}''
170
Main.status.error.messages.syntax=Missing ''}'' in ''{0}''
171
172
Main.status.error.undo=Cannot undo; beginning of undo history reached
173
Main.status.error.redo=Cannot redo; end of redo history reached
174
175
Main.status.error.theme.missing=Install themes before exporting (no themes found at ''{0}'')
176
Main.status.error.theme.name=Cannot find theme name for ''{0}''
177
178
Main.status.image.request.init=Initializing HTTP request
179
Main.status.image.request.fetch=Downloaded image ''{0}''
180
Main.status.image.request.success=Determined content type ''{0}''
181
Main.status.image.request.resolve=Resolved image path: ''{0}''
182
Main.status.image.request.error.media=No media type for ''{0}''
183
Main.status.image.request.error.cert=Could not accept certificate for ''{0}''
184
Main.status.image.request.error.create=Could not create image for preview document
185
Main.status.image.request.error.resolve=Could not resolve image path: ''{0}''
186
187
Main.status.image.xhtml.image.download=Downloading ''{0}''
188
Main.status.image.xhtml.image.resolve=Qualify path for ''{0}''
189
Main.status.image.xhtml.image.found=Found image ''{0}''
190
Main.status.image.xhtml.image.missing=Missing image ''{0}''
191
Main.status.image.xhtml.image.saved=Saved image ''{0}''
192
Main.status.image.xhtml.image.failed=Cannot save image ''{0}''
193
194
Main.status.font.search.missing=No font name starting with ''{0}'' was found
195
196
Main.status.export.concat=Concatenating ''{0}''
197
Main.status.export.concat.parent=No parent directory found for ''{0}''
198
Main.status.export.concat.extension=File name must have an extension ''{0}''
199
Main.status.export.concat.io=Could not read from ''{0}''
200
201
Main.status.typeset.create=Creating typesetter
202
Main.status.typeset.xhtml=Export document as XHTML
203
Main.status.typeset.began=Started typesetting ''{0}''
204
Main.status.typeset.failed=Could not generate PDF file
205
Main.status.typeset.page=Typesetting page {0} of {1} (pass {2})
206
Main.status.typeset.ended.success=Finished typesetting ''{0}'' ({1} elapsed)
207
Main.status.typeset.ended.failure=Failed to typeset ''{0}'' ({1} elapsed)
208
Main.status.typeset.setting=Set {0} to ''{1}''
209
210
Main.status.lexicon.loading=Loading lexicon: {0} words
211
Main.status.lexicon.loaded=Loaded lexicon: {0} words
212
213
# ########################################################################
214
# Search Bar
215
# ########################################################################
216
217
Main.search.stop.tooltip=Close search bar
218
Main.search.stop.icon=CLOSE
219
Main.search.next.tooltip=Find next match
220
Main.search.next.icon=CHEVRON_DOWN
221
Main.search.prev.tooltip=Find previous match
222
Main.search.prev.icon=CHEVRON_UP
223
Main.search.find.tooltip=Search document for text
224
Main.search.find.icon=SEARCH
225
Main.search.match.none=No matches
226
Main.search.match.some={0} of {1} matches
227
228
# ########################################################################
229
# Definition Pane and its Tree View
230
# ########################################################################
231
232
Definition.menu.add.default=Undefined
233
234
# ########################################################################
235
# Variable Definitions Pane
236
# ########################################################################
237
238
Pane.definition.node.root.title=Variables
239
240
# ########################################################################
241
# HTML Preview Pane
242
# ########################################################################
243
244
Pane.preview.title=Preview
245
246
# ########################################################################
247
# Document Outline Pane
248
# ########################################################################
249
250
Pane.outline.title=Outline
251
252
# ########################################################################
253
# File Manager Pane
254
# ########################################################################
255
256
Pane.files.title=Files
257
258
# ########################################################################
259
# Document Outline Pane
260
# ########################################################################
261
262
Pane.statistics.title=Statistics
263
264
# ########################################################################
265
# Failure messages with respect to YAML files.
266
# ########################################################################
267
268
yaml.error.open=Could not open YAML file (ensure non-empty file).
269
yaml.error.unresolvable=Too much indirection for: ''{0}'' = ''{1}''.
270
yaml.error.missing=Empty variable value for key ''{0}''.
271
yaml.error.tree.form=Unassigned variable near ''{0}''.
272
273
# ########################################################################
274
# Text Resource
275
# ########################################################################
276
277
TextResource.load.error.unsaved=The file ''{0}'' is unsaved or does not exist.
278
TextResource.load.error.permissions=The file ''{0}'' must be readable and writable.
279
280
TextResource.saveFailed.message=Failed to save ''{0}''.\n\nReason: {1}
281
TextResource.saveFailed.title=Save
282
283
# ########################################################################
284
# File Open
285
# ########################################################################
286
287
Dialog.file.choose.open.title=Open File
288
Dialog.file.choose.save.title=Save File
289
Dialog.file.choose.export.title=Export File
290
Dialog.file.choose.import.title=Import File
291
292
Dialog.file.choose.filter.title.source=Source Files
293
Dialog.file.choose.filter.title.definition=Variable Files
294
Dialog.file.choose.filter.title.xml=XML Files
295
Dialog.file.choose.filter.title.all=All Files
296
297
# ########################################################################
298
# Browse File
299
# ########################################################################
300
301
BrowseFileButton.chooser.title=Open local file
302
BrowseFileButton.chooser.allFilesFilter=All Files
303
BrowseFileButton.tooltip=${BrowseFileButton.chooser.title}
304
305
# ########################################################################
306
# Browse Directory
307
# ########################################################################
308
309
BrowseDirectoryButton.chooser.title=Open local directory
310
BrowseDirectoryButton.tooltip=${BrowseDirectoryButton.chooser.title}
311
312
# ########################################################################
313
# Alert Dialog
314
# ########################################################################
315
316
Alert.file.close.title=Close
317
Alert.file.close.text=Save changes to {0}?
318
319
# ########################################################################
320
# Typesetter Installation Wizard
321
# ########################################################################
322
323
Wizard.typesetter.name=ConTeXt
324
Wizard.typesetter.container.name=Podman
325
Wizard.typesetter.container.version=4.8.2
326
Wizard.typesetter.container.checksum=250b12c24444005e09306eda38fa63c60cb1bdadf040f4e3f24f976e213cd462
327
Wizard.typesetter.container.image.name=typesetter
328
Wizard.typesetter.container.image.version=3.1.0
329
Wizard.typesetter.container.image.tag=${Wizard.typesetter.container.image.name}:${Wizard.typesetter.container.image.version}
330
Wizard.typesetter.container.image.url=https://repository.keenwrite.com/containers/${Wizard.typesetter.container.image.tag}
331
Wizard.typesetter.themes.version=1.10.0
332
Wizard.typesetter.themes.checksum=38ce9c130cb8f527465baa3ca1e79c23ff92156c4fe9b842cc04fd80a7e10359
333
334
Wizard.container.install.command=Installing container using: ''{0}''
335
Wizard.container.install.await=Waiting for installer to finish
336
Wizard.container.install.download.started=Download ''{0}'' started
337
Wizard.container.install.download.running=Download in progress, please wait
338
Wizard.container.process.enter=Running ''{0}'' ''{1}''
339
Wizard.container.process.exit=Process exit code (zero means success): {0}
340
Wizard.container.executable.run.scan=''{0}'' is executable: {1}
341
Wizard.container.executable.run.error=Cannot run container
342
Wizard.container.executable.which=Cannot find container using search command
343
Wizard.container.executable.path=Cannot find container using PATH variable
344
Wizard.container.executable.registry=Cannot find container using registry
345
346
# STEP 1: Introduction panel (all)
347
Wizard.typesetter.all.1.install.title=Install typesetting system
348
Wizard.typesetter.all.1.install.header=Install typesetting system
349
Wizard.typesetter.all.1.install.about.container.link.lbl=${Wizard.typesetter.container.name}
350
Wizard.typesetter.all.1.install.about.container.link.url=https://podman.io
351
Wizard.typesetter.all.1.install.about.text.1=manages the container for the extensive
352
Wizard.typesetter.all.1.install.about.typesetter.link.lbl=${Wizard.typesetter.name}
353
Wizard.typesetter.all.1.install.about.typesetter.link.url=https://contextgarden.net
354
Wizard.typesetter.all.1.install.about.text.2=\
355
  typesetting software, which generates PDF files. This wizard\n\
356
  will guide you through the installation process. After each\n\
357
  step, you'll be prompted to click a button. Click Next to begin.
358
359
# STEP 2: Install container manager (Unix)
360
# Append steps to keep numbers stable; sorted programmatically.
361
Wizard.typesetter.unix.2.install.container.header=Install ${Wizard.typesetter.container.name} for Linux / macOS / Unix
362
# Copy button states
363
Wizard.typesetter.unix.2.install.container.copy.began=Copy
364
Wizard.typesetter.unix.2.install.container.copy.ended=Copied
365
Wizard.typesetter.unix.2.install.container.os=Operating System
366
Wizard.typesetter.unix.2.install.container.step.0=Complete the following steps to install ${Wizard.typesetter.container.name}:
367
Wizard.typesetter.unix.2.install.container.step.1=\t1. Select this computer's ${Wizard.typesetter.unix.2.install.container.os}.
368
Wizard.typesetter.unix.2.install.container.step.2=\t2. Open a new terminal.
369
Wizard.typesetter.unix.2.install.container.step.3=\t3. Run the commands provided below in the terminal.
370
Wizard.typesetter.unix.2.install.container.step.4=\t4. Click Next to continue.
371
Wizard.typesetter.unix.2.install.container.details.prefix=See
372
Wizard.typesetter.unix.2.install.container.details.link.lbl=${Wizard.typesetter.container.name}'s instructions
373
Wizard.typesetter.unix.2.install.container.details.link.url=https://podman.io/getting-started/installation
374
Wizard.typesetter.unix.2.install.container.details.suffix=for more details.
375
Wizard.typesetter.unix.2.install.container.command.distros=14
376
Wizard.typesetter.unix.2.install.container.command.os.name.01=Arch Linux & Manjaro Linux
377
Wizard.typesetter.unix.2.install.container.command.os.text.01=sudo pacman -S podman
378
Wizard.typesetter.unix.2.install.container.command.os.name.02=Alpine Linux
379
Wizard.typesetter.unix.2.install.container.command.os.text.02=sudo apk add podman
380
Wizard.typesetter.unix.2.install.container.command.os.name.03=CentOS
381
Wizard.typesetter.unix.2.install.container.command.os.text.03=sudo yum -y install podman
382
Wizard.typesetter.unix.2.install.container.command.os.name.04=Debian
383
Wizard.typesetter.unix.2.install.container.command.os.text.04=sudo apt-get -y install podman
384
Wizard.typesetter.unix.2.install.container.command.os.name.05=Fedora
385
Wizard.typesetter.unix.2.install.container.command.os.text.05=sudo dnf -y install podman
386
Wizard.typesetter.unix.2.install.container.command.os.name.06=Gentoo
387
Wizard.typesetter.unix.2.install.container.command.os.text.06=sudo emerge app-containers/podman
388
Wizard.typesetter.unix.2.install.container.command.os.name.07=OpenEmbedded
389
Wizard.typesetter.unix.2.install.container.command.os.text.07=bitbake podman
390
Wizard.typesetter.unix.2.install.container.command.os.name.08=openSUSE
391
Wizard.typesetter.unix.2.install.container.command.os.text.08=sudo zypper install podman
392
Wizard.typesetter.unix.2.install.container.command.os.name.09=RHEL7
393
Wizard.typesetter.unix.2.install.container.command.os.text.09=\
394
  sudo subscription-manager repos \
395
    --enable=rhel-7-server-extras-rpms\n\
396
  sudo yum -y install podman
397
Wizard.typesetter.unix.2.install.container.command.os.name.10=RHEL8
398
Wizard.typesetter.unix.2.install.container.command.os.text.10=\
399
  sudo yum module enable -y container-tools:rhel8\n\
400
  sudo yum module install -y container-tools:rhel8
401
Wizard.typesetter.unix.2.install.container.command.os.name.11=Ubuntu 20.10+
402
Wizard.typesetter.unix.2.install.container.command.os.text.11=\
403
  sudo apt-get -y update\n\
404
  sudo apt-get -y install podman
405
Wizard.typesetter.unix.2.install.container.command.os.name.12=Linuxmint
406
Wizard.typesetter.unix.2.install.container.command.os.text.12=${Wizard.typesetter.unix.2.install.container.command.os.text.11}
407
Wizard.typesetter.unix.2.install.container.command.os.name.13=Linuxmint LMDE
408
Wizard.typesetter.unix.2.install.container.command.os.text.13=${Wizard.typesetter.unix.2.install.container.command.os.text.04}
409
Wizard.typesetter.unix.2.install.container.command.os.name.14=macOS
410
Wizard.typesetter.unix.2.install.container.command.os.text.14=\
411
  /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" \n\
412
  brew install podman
413
414
# STEP 2 a: Download container manager (Windows)
415
Wizard.typesetter.win.2.download.container.header=Download ${Wizard.typesetter.container.name} for Windows
416
Wizard.typesetter.win.2.download.container.homepage.link.lbl=${Wizard.typesetter.container.name}
417
Wizard.typesetter.win.2.download.container.homepage.link.url=https://podman.io
418
Wizard.typesetter.win.2.download.container.download.link.lbl=repository
419
Wizard.typesetter.win.2.download.container.download.link.url=https://github.com/containers/podman/releases/download/v${Wizard.typesetter.container.version}/podman-${Wizard.typesetter.container.version}-setup.exe
420
Wizard.typesetter.win.2.download.container.paths=Downloading {0} into {1}.
421
# suppress inspection "UnusedMessageFormatParameter"
422
Wizard.typesetter.win.2.download.container.status.bytes=Downloaded {1} bytes (size unknown).
423
Wizard.typesetter.win.2.download.container.status.progress=Downloaded {0} % of {1} bytes.
424
Wizard.typesetter.win.2.download.container.status.checksum.ok=File {0} exists. Click Next to continue.
425
Wizard.typesetter.win.2.download.container.status.checksum.no=Integrity check failed, {0} may be corrupt.
426
Wizard.typesetter.win.2.download.container.status.success=Download successful. Click Next to continue.
427
Wizard.typesetter.win.2.download.container.status.failure=Download failed. Check network then click Previous to try again.
428
429
# STEP 2 b: Install container manager (Windows)
430
Wizard.typesetter.win.2.install.container.header=Install ${Wizard.typesetter.container.name} for Windows
431
Wizard.typesetter.win.2.install.container.step.0=Complete the following steps to install ${Wizard.typesetter.container.name}:
432
Wizard.typesetter.win.2.install.container.step.1=\t1. Open the task bar.
433
Wizard.typesetter.win.2.install.container.step.2=\t2. Click the shield icon to grant permissions.
434
Wizard.typesetter.win.2.install.container.step.3=\t3. Click Yes in the User Account Control dialog to install.
435
Wizard.typesetter.win.2.install.container.status.running=Installing ...
436
Wizard.typesetter.win.2.install.container.status.success=Installation successful.\nClick Next to continue.
437
Wizard.typesetter.win.2.install.container.status.failure=Installation failed with exit code {0}.
438
Wizard.typesetter.win.2.install.container.status.unknown=Could not determine installer file type: {0}
439
440
# STEP 2: Install container manager (Universal, undetected operating system)
441
Wizard.typesetter.all.2.install.container.header=Install ${Wizard.typesetter.container.name}
442
Wizard.typesetter.all.2.install.container.homepage.lbl=${Wizard.typesetter.container.name}
443
Wizard.typesetter.all.2.install.container.homepage.url=https://podman.io
444
445
# STEP 3: Initialize container manager (all except Linux)
446
Wizard.typesetter.all.3.install.container.header=Initialize ${Wizard.typesetter.container.name}
447
Wizard.typesetter.all.3.install.container.correct=${Wizard.typesetter.container.name} initialized.\nClick Next to continue.
448
Wizard.typesetter.all.3.install.container.missing=Install ${Wizard.typesetter.container.name} before continuing.
449
450
# STEP 4: Install typesetter container image (all)
451
Wizard.typesetter.all.4.download.image.header=Download ${Wizard.typesetter.name} image
452
Wizard.typesetter.all.4.download.image.correct=Download successful.\nClick Next to continue.
453
Wizard.typesetter.all.4.download.image.missing=Install ${Wizard.typesetter.container.name} before continuing.
454
455
# STEP 5: Download typesetter themes (all)
456
Wizard.typesetter.all.5.download.themes.header=Download ${Wizard.typesetter.name} themes
457
Wizard.typesetter.all.5.download.themes.download.link.lbl=repository
458
Wizard.typesetter.all.5.download.themes.download.link.url=https://gitlab.com/DaveJarvis/keenwrite-themes/-/releases/${Wizard.typesetter.themes.version}/downloads/theme-pack.zip
459
Wizard.typesetter.all.5.download.themes.paths=Downloading {0} into {1}.
460
Wizard.typesetter.all.5.download.themes.status.bytes=Downloaded {0} bytes (size unknown).
461
Wizard.typesetter.all.5.download.themes.status.progress=Downloaded {0} % of {1} bytes.
462
Wizard.typesetter.all.5.download.themes.status.checksum.ok=File {0} exists. Click Finish to continue.
463
Wizard.typesetter.all.5.download.themes.status.checksum.no=Integrity check failed, {0} may be corrupt.
464
Wizard.typesetter.all.5.download.themes.status.success=Download successful. Click Finish to continue.
465
Wizard.typesetter.all.5.download.themes.status.failure=Download failed. Check network then click Previous to try again.
466
467
# ########################################################################
468
# Image Dialog
469
# ########################################################################
470
471
Dialog.image.title=Image
472
Dialog.image.chooser.imagesFilter=Images
473
Dialog.image.previewLabel.text=Markdown Preview\:
474
Dialog.image.textLabel.text=Alternate Text\:
475
Dialog.image.titleLabel.text=Title (tooltip)\:
476
Dialog.image.urlLabel.text=Image URL\:
477
478
# ########################################################################
479
# Hyperlink Dialog
480
# ########################################################################
481
482
Dialog.link.title=Link
483
Dialog.link.previewLabel.text=Markdown Preview\:
484
Dialog.link.textLabel.text=Link Text\:
485
Dialog.link.titleLabel.text=Title (tooltip)\:
486
Dialog.link.urlLabel.text=Link URL\:
487
488
# ########################################################################
489
# Typesetting Settings Dialog
490
# ########################################################################
491
492
Dialog.typesetting.settings.title=Typesetting export settings
493
Dialog.typesetting.settings.header.single=Export current document
494
Dialog.typesetting.settings.theme=Theme
495
Dialog.typesetting.settings.themes.missing=Install themes into {0}.
496
497
Dialog.typesetting.settings.header.multiple=Export multiple documents
498
Dialog.typesetting.settings.chapters=Chapters (e.g., 1-3, 5, 7-)
499
500
# ########################################################################
501
# About Dialog
502
# ########################################################################
503
504
Dialog.about.title=About {0}
505
Dialog.about.header={0}
506
Dialog.about.content=Copyright 2016-{0} White Magic Software, Ltd.\n\nVersion: {1}
507
508
# ########################################################################
509
# Application Actions
510
# ########################################################################
511
512
Action.file.new.description=Create a new file
513
Action.file.new.accelerator=Shortcut+N
514
Action.file.new.icon=FILE_ALT
515
Action.file.new.text=_New
516
517
Action.file.open.description=Open a new file
518
Action.file.open.accelerator=Shortcut+O
519
Action.file.open.text=_Open...
520
Action.file.open.icon=FOLDER_OPEN_ALT
521
522
Action.file.close.description=Close the current document
523
Action.file.close.accelerator=Shortcut+W
524
Action.file.close.text=_Close
525
526
Action.file.close_all.description=Close all open documents
527
Action.file.close_all.accelerator=Ctrl+F4
528
Action.file.close_all.text=Close All
529
530
Action.file.save.description=Save the document
531
Action.file.save.accelerator=Shortcut+S
532
Action.file.save.text=_Save
533
Action.file.save.icon=FLOPPY_ALT
534
535
Action.file.save_as.description=Rename the current document
536
Action.file.save_as.text=Save _As
537
538
Action.file.save_all.description=Save all open documents
539
Action.file.save_all.accelerator=Shortcut+Shift+S
540
Action.file.save_all.text=Save A_ll
541
542
Action.file.export.pdf.description=Typeset the document
543
Action.file.export.pdf.accelerator=Shortcut+P
544
Action.file.export.pdf.text=_PDF
545
Action.file.export.pdf.icon=FILE_PDF_ALT
546
547
Action.file.export.pdf.dir.description=Typeset files in document directory
548
Action.file.export.pdf.dir.accelerator=Shortcut+Shift+P
549
Action.file.export.pdf.dir.text=_Joined PDF
550
Action.file.export.pdf.dir.icon=FILE_PDF_ALT
551
552
Action.file.export.pdf.repeat.description=Repeat previous typesetting command
553
Action.file.export.pdf.repeat.accelerator=Shortcut+Shift+E
554
Action.file.export.pdf.repeat.text=_Repeat Export
555
Action.file.export.pdf.repeat.icon=FILE_PDF_ALT
556
557
Action.file.export.html.dir.description=Export files in document directory as HTML
558
Action.file.export.html.dir.accelerator=Shortcut+Shift+H
559
Action.file.export.html.dir.text=Joined _HTML
560
Action.file.export.html.dir.icon=HTML5
561
562
Action.file.export.html_svg.description=Export the current document as HTML + SVG
563
Action.file.export.text=_Export As
564
Action.file.export.html_svg.text=HTML and S_VG
565
566
Action.file.export.html_tex.description=Export the current document as HTML + TeX
567
Action.file.export.html_tex.text=HTML and _TeX
568
569
Action.file.export.xhtml_tex.description=Export as XHTML + TeX
570
Action.file.export.xhtml_tex.text=_XHTML and TeX
571
572
Action.file.export.markdown.description=Export the current document as Markdown
573
Action.file.export.markdown.text=Markdown
574
575
Action.file.exit.description=Quit the application
576
Action.file.exit.text=E_xit
577
578
579
Action.edit.undo.description=Undo the previous edit
580
Action.edit.undo.accelerator=Shortcut+Z
581
Action.edit.undo.text=_Undo
582
Action.edit.undo.icon=UNDO
583
584
Action.edit.redo.description=Redo the previous edit
585
Action.edit.redo.accelerator=Shortcut+Y
586
Action.edit.redo.text=_Redo
587
Action.edit.redo.icon=REPEAT
588
589
Action.edit.cut.description=Delete the selected text or line
590
Action.edit.cut.accelerator=Shortcut+X
591
Action.edit.cut.text=Cu_t
592
Action.edit.cut.icon=CUT
593
594
Action.edit.copy.description=Copy the selected text
595
Action.edit.copy.accelerator=Shortcut+C
596
Action.edit.copy.text=_Copy
597
Action.edit.copy.icon=COPY
598
599
Action.edit.paste.description=Paste from the clipboard
600
Action.edit.paste.accelerator=Shortcut+V
601
Action.edit.paste.text=_Paste
602
Action.edit.paste.icon=PASTE
603
604
Action.edit.select_all.description=Highlight the current document text
605
Action.edit.select_all.accelerator=Shortcut+A
606
Action.edit.select_all.text=Select _All
607
608
Action.edit.find.description=Search for text in the document
609
Action.edit.find.accelerator=Shortcut+F
610
Action.edit.find.text=_Find
611
Action.edit.find.icon=SEARCH
612
613
Action.edit.find_next.description=Find next occurrence
614
Action.edit.find_next.accelerator=F3
615
Action.edit.find_next.text=Find _Next
616
617
Action.edit.find_prev.description=Find previous occurrence
618
Action.edit.find_prev.accelerator=Shift+F3
619
Action.edit.find_prev.text=Find _Prev
620
621
Action.edit.preferences.description=Edit user preferences
622
Action.edit.preferences.accelerator=Ctrl+Alt+S
623
Action.edit.preferences.text=_Preferences
624
625
626
Action.format.bold.description=Insert strong text
627
Action.format.bold.accelerator=Shortcut+B
628
Action.format.bold.text=_Bold
629
Action.format.bold.icon=BOLD
630
631
Action.format.italic.description=Insert text emphasis
632
Action.format.italic.accelerator=Shortcut+I
633
Action.format.italic.text=_Italic
634
Action.format.italic.icon=ITALIC
635
636
Action.format.monospace.description=Insert monospace text
637
Action.format.monospace.accelerator=Shortcut+`
638
Action.format.monospace.text=_Monospace
639
640
Action.format.superscript.description=Insert superscript text
641
Action.format.superscript.accelerator=Shortcut+[
642
Action.format.superscript.text=Su_perscript
643
Action.format.superscript.icon=SUPERSCRIPT
644
645
Action.format.subscript.description=Insert subscript text
646
Action.format.subscript.accelerator=Shortcut+]
647
Action.format.subscript.text=Su_bscript
648
Action.format.subscript.icon=SUBSCRIPT
649
650
Action.format.strikethrough.description=Insert struck text
651
Action.format.strikethrough.accelerator=Shortcut+T
652
Action.format.strikethrough.text=Stri_kethrough
653
Action.format.strikethrough.icon=STRIKETHROUGH
654
655
656
Action.insert.blockquote.description=Insert blockquote
657
Action.insert.blockquote.accelerator=Ctrl+Q
658
Action.insert.blockquote.text=_Blockquote
659
Action.insert.blockquote.icon=QUOTE_LEFT
660
661
Action.insert.code.description=Insert inline code
662
Action.insert.code.accelerator=Shortcut+K
663
Action.insert.code.text=Inline _Code
664
Action.insert.code.icon=CODE
665
666
Action.insert.fenced_code_block.description=Insert code block
667
Action.insert.fenced_code_block.accelerator=Shortcut+Shift+K
668
Action.insert.fenced_code_block.text=_Fenced Code Block
669
Action.insert.fenced_code_block.prompt.text=Enter code here
670
Action.insert.fenced_code_block.icon=FILE_CODE_ALT
671
672
Action.insert.link.description=Insert hyperlink
673
Action.insert.link.accelerator=Shortcut+L
674
Action.insert.link.text=_Link...
675
Action.insert.link.icon=LINK
676
677
Action.insert.image.description=Insert image
678
Action.insert.image.accelerator=Shortcut+G
679
Action.insert.image.text=_Image...
680
Action.insert.image.icon=PICTURE_ALT
681
682
Action.insert.heading.description=Insert heading level
683
Action.insert.heading.accelerator=Shortcut+
684
Action.insert.heading.icon=HEADER
685
686
Action.insert.heading_1.description=${Action.insert.heading.description} 1
687
Action.insert.heading_1.accelerator=${Action.insert.heading.accelerator}1
688
Action.insert.heading_1.text=Heading _1
689
Action.insert.heading_1.icon=${Action.insert.heading.icon}
690
691
Action.insert.heading_2.description=${Action.insert.heading.description} 2
692
Action.insert.heading_2.accelerator=${Action.insert.heading.accelerator}2
693
Action.insert.heading_2.text=Heading _2
694
Action.insert.heading_2.icon=${Action.insert.heading.icon}
695
696
Action.insert.heading_3.description=${Action.insert.heading.description} 3
697
Action.insert.heading_3.accelerator=${Action.insert.heading.accelerator}3
698
Action.insert.heading_3.text=Heading _3
699
Action.insert.heading_3.icon=${Action.insert.heading.icon}
700
701
Action.insert.unordered_list.description=Insert bulleted list
702
Action.insert.unordered_list.accelerator=Shortcut+U
703
Action.insert.unordered_list.text=_Unordered List
704
Action.insert.unordered_list.icon=LIST_UL
705
706
Action.insert.ordered_list.description=Insert enumerated list
707
Action.insert.ordered_list.accelerator=Shortcut+Shift+O
708
Action.insert.ordered_list.text=_Ordered List
709
Action.insert.ordered_list.icon=LIST_OL
710
711
Action.insert.horizontal_rule.description=Insert horizontal rule
712
Action.insert.horizontal_rule.accelerator=Shortcut+H
713
Action.insert.horizontal_rule.text=_Horizontal Rule
714
Action.insert.horizontal_rule.icon=LIST_OL
715
716
717
Action.definition.create.description=Create a new variable
718
Action.definition.create.text=_Create
719
Action.definition.create.icon=TREE
720
Action.definition.create.tooltip=Add new item (Insert)
721
722
Action.definition.rename.description=Rename the selected variable
723
Action.definition.rename.text=_Rename
724
Action.definition.rename.icon=EDIT
725
Action.definition.rename.tooltip=Rename selected item (F2)
726
727
Action.definition.delete.description=Delete the selected variables
728
Action.definition.delete.text=De_lete
729
Action.definition.delete.icon=TRASH
730
Action.definition.delete.tooltip=Delete selected items (Delete)
731
732
Action.definition.insert.description=Insert a variable
733
Action.definition.insert.accelerator=Ctrl+Space
734
Action.definition.insert.text=_Insert
735
Action.definition.insert.icon=STAR
736
737
738
Action.view.refresh.description=Clear all caches
739
Action.view.refresh.accelerator=F5
740
Action.view.refresh.text=Refresh
741
742
Action.view.preview.description=Open document preview
743
Action.view.preview.accelerator=F6
744
Action.view.preview.text=Preview
745
746
Action.view.outline.description=Open document outline
747
Action.view.outline.accelerator=F7
748
Action.view.outline.text=Outline
749
750
Action.view.statistics.description=Open document word counts
751
Action.view.statistics.accelerator=F8
752
Action.view.statistics.text=Statistics
753
754
Action.view.files.description=Open file manager
755
Action.view.files.accelerator=Ctrl+F8
756
Action.view.files.text=Files
757
758
Action.view.menubar.description=Toggle menu bar
759
Action.view.menubar.accelerator=Ctrl+F9
760
Action.view.menubar.text=Menu bar
761
762
Action.view.toolbar.description=Toggle toolbar
763
Action.view.toolbar.accelerator=Ctrl+Shift+F9
764
Action.view.toolbar.text=Toolbar
765
766
Action.view.statusbar.description=Toggle status bar
767
Action.view.statusbar.accelerator=Ctrl+Shift+Alt+F9
38
workspace.typeset.modes=Modes
39
workspace.typeset.modes.enabled=Enabled
40
workspace.typeset.modes.enabled.desc=Enable typesetting modes, separated by commas; values may use variables (e.g., '{{'document.category'}}').
41
workspace.typeset.modes.enabled.title=Enable
42
43
workspace.r=R
44
workspace.r.script=Startup Script
45
workspace.r.script.desc=Script runs prior to executing R statements within the document.
46
workspace.r.dir=Working Directory
47
workspace.r.dir.desc=Value assigned to v$application$r$working$directory and usable in the startup script.
48
workspace.r.dir.title=Directory
49
workspace.r.delimiter.began=Delimiter Prefix
50
workspace.r.delimiter.began.desc=Prefix of expression that wraps inserted variables.
51
workspace.r.delimiter.began.title=Opening
52
workspace.r.delimiter.ended=Delimiter Suffix
53
workspace.r.delimiter.ended.desc=Suffix of expression that wraps inserted variables.
54
workspace.r.delimiter.ended.title=Closing
55
56
workspace.images=Images
57
workspace.images.dir=Absolute Directory
58
workspace.images.dir.desc=Path to search for local file system images.
59
workspace.images.dir.title=Directory
60
workspace.images.cache.desc=Path to store remotely retrieved images.
61
workspace.images.cache.title=Directory
62
workspace.images.order=Extensions
63
workspace.images.order.desc=Preferred order of image file types to embed, separated by spaces.
64
workspace.images.order.title=Extensions
65
workspace.images.resize=Resize
66
workspace.images.resize.desc=Scale images to fit the preview panel when resizing, automatically.
67
workspace.images.resize.title=Resize
68
workspace.images.server=Diagram Server
69
workspace.images.server.desc=Server used to generate diagrams (e.g., kroki.io).
70
workspace.images.server.title=Name
71
72
workspace.definition=Variable
73
workspace.definition.path=File name
74
workspace.definition.path.desc=Absolute path to interpolated string variables.
75
workspace.definition.path.title=Path
76
workspace.definition.delimiter.began=Delimiter Prefix
77
workspace.definition.delimiter.began.desc=Indicates when a variable name is starting.
78
workspace.definition.delimiter.began.title=Opening
79
workspace.definition.delimiter.ended=Delimiter Suffix
80
workspace.definition.delimiter.ended.desc=Indicates when a variable name is ending.
81
workspace.definition.delimiter.ended.title=Closing
82
83
workspace.ui.skin=Skins
84
workspace.ui.skin.selection=Bundled
85
workspace.ui.skin.selection.desc=Pre-packaged application style (default: Modena Light).
86
workspace.ui.skin.selection.title=Name
87
workspace.ui.skin.custom=Custom
88
workspace.ui.skin.custom.desc=User-defined JavaFX cascading stylesheet file.
89
workspace.ui.skin.custom.title=Path
90
91
workspace.ui.preview=Preview
92
workspace.ui.preview.stylesheet=Stylesheet
93
workspace.ui.preview.stylesheet.desc=User-defined HTML cascading stylesheet file.
94
workspace.ui.preview.stylesheet.title=Path
95
96
workspace.ui.font=Fonts
97
workspace.ui.font.editor=Editor Font
98
workspace.ui.font.editor.name=Name
99
workspace.ui.font.editor.name.desc=Text editor font name (sans-serif font recommended).
100
workspace.ui.font.editor.name.title=Family
101
workspace.ui.font.editor.size=Size
102
workspace.ui.font.editor.size.desc=Font size.
103
workspace.ui.font.editor.size.title=Points
104
workspace.ui.font.preview=Preview Font
105
workspace.ui.font.preview.name=Name
106
workspace.ui.font.preview.name.desc=Preview pane font name (must support ligatures, serif font recommended).
107
workspace.ui.font.preview.name.title=Family
108
workspace.ui.font.preview.size=Size
109
workspace.ui.font.preview.size.desc=Font size.
110
workspace.ui.font.preview.size.title=Points
111
workspace.ui.font.preview.mono.name=Name
112
workspace.ui.font.preview.mono.name.desc=Monospace font name.
113
workspace.ui.font.preview.mono.name.title=Family
114
workspace.ui.font.preview.mono.size=Size
115
workspace.ui.font.preview.mono.size.desc=Monospace font size.
116
workspace.ui.font.preview.mono.size.title=Points
117
workspace.ui.font.math=Math Font
118
workspace.ui.font.math.size.title=Scale
119
120
workspace.language=Language
121
workspace.language.locale=Internationalization
122
workspace.language.locale.desc=Language for application and HTML export.
123
workspace.language.locale.title=Locale
124
125
# ########################################################################
126
# Editor actions
127
# ########################################################################
128
129
Editor.spelling.check.matches.none=No suggestions for ''{0}'' found.
130
Editor.spelling.check.matches.okay=The spelling for ''{0}'' appears to be correct.
131
132
# ########################################################################
133
# Menu Bar
134
# ########################################################################
135
136
Main.menu.file=_File
137
Main.menu.edit=_Edit
138
Main.menu.insert=_Insert
139
Main.menu.format=Forma_t
140
Main.menu.definition=_Variable
141
Main.menu.view=Vie_w
142
Main.menu.help=_Help
143
144
# ########################################################################
145
# Detachable Tabs
146
# ########################################################################
147
148
# {0} is the application title; {1} is a unique window ID.
149
Detach.tab.title={0} - {1}
150
151
# ########################################################################
152
# Status Bar
153
# ########################################################################
154
155
Main.status.text.offset=offset
156
Main.status.line=Line {0} of {1}, ${Main.status.text.offset} {2}
157
Main.status.state.default=OK
158
Main.status.export.success=Saved as ''{0}''
159
160
Main.status.error.bootstrap.eval=Note: Bootstrap variable of ''{0}'' not found
161
Main.status.error.bootstrap.cache=Could not create cache directory ''{0}''
162
163
Main.status.error.parse=Evaluation error: {0}
164
Main.status.error.def.blank=Move the caret to a word before inserting a variable
165
Main.status.error.def.empty=Create a variable before inserting one
166
Main.status.error.def.missing=No variable value found for ''{0}''
167
Main.status.error.r=Error with [{0}...]: {1}
168
169
Main.status.error.file.missing=Not found: ''{0}''
170
Main.status.error.file.missing.near=Not found: ''{0}'' near line {1}
171
Main.status.error.file.delete=Failed to delete ''{0}''
172
173
Main.status.error.messages.recursion=Lookup depth exceeded, check for loops in ''{0}''
174
Main.status.error.messages.syntax=Missing ''}'' in ''{0}''
175
176
Main.status.error.undo=Cannot undo; beginning of undo history reached
177
Main.status.error.redo=Cannot redo; end of redo history reached
178
179
Main.status.error.theme.missing=Install themes before exporting (no themes found at ''{0}'')
180
Main.status.error.theme.name=Cannot find theme name for ''{0}''
181
182
Main.status.image.request.init=Initializing HTTP request
183
Main.status.image.request.fetch=Downloaded image ''{0}''
184
Main.status.image.request.success=Determined content type ''{0}''
185
Main.status.image.request.resolve=Resolved image path: ''{0}''
186
Main.status.image.request.error.media=No media type for ''{0}''
187
Main.status.image.request.error.cert=Could not accept certificate for ''{0}''
188
Main.status.image.request.error.create=Could not create image for preview document
189
Main.status.image.request.error.resolve=Could not resolve image path: ''{0}''
190
191
Main.status.image.xhtml.image.download=Downloading ''{0}''
192
Main.status.image.xhtml.image.resolve=Qualify path for ''{0}''
193
Main.status.image.xhtml.image.found=Found image ''{0}''
194
Main.status.image.xhtml.image.missing=Missing image ''{0}''
195
Main.status.image.xhtml.image.saved=Saved image ''{0}''
196
Main.status.image.xhtml.image.failed=Cannot save image ''{0}''
197
198
Main.status.url.request.fetch=Download Markdown file from: ''{0}''
199
Main.status.url.request.success=Downloaded Markdown file ''{0}''
200
Main.status.url.request.failure=Could not save Markdown file to: ''{0}''
201
Main.status.url.request.exists=Download aborted; file exists: ''{0}''
202
# suppress inspection "UnusedMessageFormatParameter"
203
Main.status.url.request.status.bytes=Downloaded {1} bytes (size unknown).
204
Main.status.url.request.status.progress=Downloaded {0} % of {1} bytes.
205
206
Main.status.font.search.missing=No font name starting with ''{0}'' was found
207
208
Main.status.export.concat=Concatenating ''{0}''
209
Main.status.export.concat.parent=No parent directory found for ''{0}''
210
Main.status.export.concat.extension=File name must have an extension ''{0}''
211
Main.status.export.concat.io=Could not read from ''{0}''
212
213
Main.status.typeset.create=Creating typesetter
214
Main.status.typeset.xhtml=Export document as XHTML
215
Main.status.typeset.began=Started typesetting ''{0}''
216
Main.status.typeset.failed=Could not generate PDF file
217
Main.status.typeset.page=Typesetting page {0} of {1} (pass {2})
218
Main.status.typeset.ended.success=Finished typesetting ''{0}'' ({1} elapsed)
219
Main.status.typeset.ended.failure=Failed to typeset ''{0}'' ({1} elapsed)
220
Main.status.typeset.setting=Set {0} to ''{1}''
221
222
Main.status.lexicon.loading=Loading lexicon: {0} words
223
Main.status.lexicon.loaded=Loaded lexicon: {0} words
224
225
# ########################################################################
226
# Search Bar
227
# ########################################################################
228
229
Main.search.stop.tooltip=Close search bar
230
Main.search.stop.icon=CLOSE
231
Main.search.next.tooltip=Find next match
232
Main.search.next.icon=CHEVRON_DOWN
233
Main.search.prev.tooltip=Find previous match
234
Main.search.prev.icon=CHEVRON_UP
235
Main.search.find.tooltip=Search document for text
236
Main.search.find.icon=SEARCH
237
Main.search.match.none=No matches
238
Main.search.match.some={0} of {1} matches
239
240
# ########################################################################
241
# Definition Pane and its Tree View
242
# ########################################################################
243
244
Definition.menu.add.default=Undefined
245
246
# ########################################################################
247
# Variable Definitions Pane
248
# ########################################################################
249
250
Pane.definition.node.root.title=Variables
251
252
# ########################################################################
253
# HTML Preview Pane
254
# ########################################################################
255
256
Pane.preview.title=Preview
257
258
# ########################################################################
259
# Document Outline Pane
260
# ########################################################################
261
262
Pane.outline.title=Outline
263
264
# ########################################################################
265
# File Manager Pane
266
# ########################################################################
267
268
Pane.files.title=Files
269
270
# ########################################################################
271
# Document Outline Pane
272
# ########################################################################
273
274
Pane.statistics.title=Statistics
275
276
# ########################################################################
277
# Failure messages with respect to YAML files.
278
# ########################################################################
279
280
yaml.error.open=Could not open YAML file (ensure non-empty file).
281
yaml.error.unresolvable=Too much indirection for: ''{0}'' = ''{1}''.
282
yaml.error.missing=Empty variable value for key ''{0}''.
283
yaml.error.tree.form=Unassigned variable near ''{0}''.
284
285
# ########################################################################
286
# Text Resource
287
# ########################################################################
288
289
TextResource.load.error.unsaved=The file ''{0}'' is unsaved or does not exist.
290
TextResource.load.error.permissions=The file ''{0}'' must be readable and writable.
291
292
TextResource.saveFailed.message=Failed to save ''{0}''.\n\nReason: {1}
293
TextResource.saveFailed.title=Save
294
295
# ########################################################################
296
# File Open
297
# ########################################################################
298
299
Dialog.file.choose.open.title=Open File
300
Dialog.file.choose.save.title=Save File
301
Dialog.file.choose.export.title=Export File
302
Dialog.file.choose.import.title=Import File
303
304
Dialog.file.choose.filter.title.source=Source Files
305
Dialog.file.choose.filter.title.definition=Variable Files
306
Dialog.file.choose.filter.title.xml=XML Files
307
Dialog.file.choose.filter.title.all=All Files
308
309
# ########################################################################
310
# Browse Directory
311
# ########################################################################
312
313
BrowseDirectoryButton.chooser.title=Open local directory
314
BrowseDirectoryButton.tooltip=${BrowseDirectoryButton.chooser.title}
315
316
# ########################################################################
317
# Alert Dialog
318
# ########################################################################
319
320
Alert.file.close.title=Close
321
Alert.file.close.text=Save changes to {0}?
322
323
# ########################################################################
324
# Typesetter Installation Wizard
325
# ########################################################################
326
327
Wizard.typesetter.name=ConTeXt
328
Wizard.typesetter.container.name=Podman
329
Wizard.typesetter.container.version=4.8.2
330
Wizard.typesetter.container.checksum=250b12c24444005e09306eda38fa63c60cb1bdadf040f4e3f24f976e213cd462
331
Wizard.typesetter.container.image.name=typesetter
332
Wizard.typesetter.container.image.version=3.2.0
333
Wizard.typesetter.container.image.tag=${Wizard.typesetter.container.image.name}:${Wizard.typesetter.container.image.version}
334
Wizard.typesetter.container.image.url=https://repository.keenwrite.com/containers/${Wizard.typesetter.container.image.tag}
335
Wizard.typesetter.themes.version=1.10.2
336
Wizard.typesetter.themes.checksum=d2d3674434d914378af9a845fc363194cb4bc7a983eb3f8b7af38309faae19f6
337
338
Wizard.container.install.command=Installing container using: ''{0}''
339
Wizard.container.install.await=Waiting for installer to finish
340
Wizard.container.install.download.started=Download ''{0}'' started
341
Wizard.container.install.download.running=Download in progress, please wait
342
Wizard.container.process.enter=Running ''{0}'' ''{1}''
343
Wizard.container.process.exit=Process exit code (zero means success): {0}
344
Wizard.container.executable.run.scan=''{0}'' is executable: {1}
345
Wizard.container.executable.run.error=Cannot run container
346
Wizard.container.executable.which=Cannot find container using search command
347
Wizard.container.executable.path=Cannot find container using PATH variable
348
Wizard.container.executable.registry=Cannot find container using registry
349
350
# STEP 1: Introduction panel (all)
351
Wizard.typesetter.all.1.install.title=Install typesetting system
352
Wizard.typesetter.all.1.install.header=Install typesetting system
353
Wizard.typesetter.all.1.install.about.container.link.lbl=${Wizard.typesetter.container.name}
354
Wizard.typesetter.all.1.install.about.container.link.url=https://podman.io
355
Wizard.typesetter.all.1.install.about.text.1=manages the container for the extensive
356
Wizard.typesetter.all.1.install.about.typesetter.link.lbl=${Wizard.typesetter.name}
357
Wizard.typesetter.all.1.install.about.typesetter.link.url=https://contextgarden.net
358
Wizard.typesetter.all.1.install.about.text.2=\
359
  typesetting software, which generates PDF files. This wizard\n\
360
  will guide you through the installation process. After each\n\
361
  step, you'll be prompted to click a button. Click Next to begin.
362
363
# STEP 2: Install container manager (Unix)
364
# Append steps to keep numbers stable; sorted programmatically.
365
Wizard.typesetter.unix.2.install.container.header=Install ${Wizard.typesetter.container.name} for Linux / macOS / Unix
366
# Copy button states
367
Wizard.typesetter.unix.2.install.container.copy.began=Copy
368
Wizard.typesetter.unix.2.install.container.copy.ended=Copied
369
Wizard.typesetter.unix.2.install.container.os=Operating System
370
Wizard.typesetter.unix.2.install.container.step.0=Complete the following steps to install ${Wizard.typesetter.container.name}:
371
Wizard.typesetter.unix.2.install.container.step.1=\t1. Select this computer's ${Wizard.typesetter.unix.2.install.container.os}.
372
Wizard.typesetter.unix.2.install.container.step.2=\t2. Open a new terminal.
373
Wizard.typesetter.unix.2.install.container.step.3=\t3. Run the commands provided below in the terminal.
374
Wizard.typesetter.unix.2.install.container.step.4=\t4. Click Next to continue.
375
Wizard.typesetter.unix.2.install.container.details.prefix=See
376
Wizard.typesetter.unix.2.install.container.details.link.lbl=${Wizard.typesetter.container.name}'s instructions
377
Wizard.typesetter.unix.2.install.container.details.link.url=https://podman.io/getting-started/installation
378
Wizard.typesetter.unix.2.install.container.details.suffix=for more details.
379
Wizard.typesetter.unix.2.install.container.command.distros=14
380
Wizard.typesetter.unix.2.install.container.command.os.name.01=Arch Linux & Manjaro Linux
381
Wizard.typesetter.unix.2.install.container.command.os.text.01=sudo pacman -S podman
382
Wizard.typesetter.unix.2.install.container.command.os.name.02=Alpine Linux
383
Wizard.typesetter.unix.2.install.container.command.os.text.02=sudo apk add podman
384
Wizard.typesetter.unix.2.install.container.command.os.name.03=CentOS
385
Wizard.typesetter.unix.2.install.container.command.os.text.03=sudo yum -y install podman
386
Wizard.typesetter.unix.2.install.container.command.os.name.04=Debian
387
Wizard.typesetter.unix.2.install.container.command.os.text.04=sudo apt-get -y install podman
388
Wizard.typesetter.unix.2.install.container.command.os.name.05=Fedora
389
Wizard.typesetter.unix.2.install.container.command.os.text.05=sudo dnf -y install podman
390
Wizard.typesetter.unix.2.install.container.command.os.name.06=Gentoo
391
Wizard.typesetter.unix.2.install.container.command.os.text.06=sudo emerge app-containers/podman
392
Wizard.typesetter.unix.2.install.container.command.os.name.07=OpenEmbedded
393
Wizard.typesetter.unix.2.install.container.command.os.text.07=bitbake podman
394
Wizard.typesetter.unix.2.install.container.command.os.name.08=openSUSE
395
Wizard.typesetter.unix.2.install.container.command.os.text.08=sudo zypper install podman
396
Wizard.typesetter.unix.2.install.container.command.os.name.09=RHEL7
397
Wizard.typesetter.unix.2.install.container.command.os.text.09=\
398
  sudo subscription-manager repos \
399
    --enable=rhel-7-server-extras-rpms\n\
400
  sudo yum -y install podman
401
Wizard.typesetter.unix.2.install.container.command.os.name.10=RHEL8
402
Wizard.typesetter.unix.2.install.container.command.os.text.10=\
403
  sudo yum module enable -y container-tools:rhel8\n\
404
  sudo yum module install -y container-tools:rhel8
405
Wizard.typesetter.unix.2.install.container.command.os.name.11=Ubuntu 20.10+
406
Wizard.typesetter.unix.2.install.container.command.os.text.11=\
407
  sudo apt-get -y update\n\
408
  sudo apt-get -y install podman
409
Wizard.typesetter.unix.2.install.container.command.os.name.12=Linuxmint
410
Wizard.typesetter.unix.2.install.container.command.os.text.12=${Wizard.typesetter.unix.2.install.container.command.os.text.11}
411
Wizard.typesetter.unix.2.install.container.command.os.name.13=Linuxmint LMDE
412
Wizard.typesetter.unix.2.install.container.command.os.text.13=${Wizard.typesetter.unix.2.install.container.command.os.text.04}
413
Wizard.typesetter.unix.2.install.container.command.os.name.14=macOS
414
Wizard.typesetter.unix.2.install.container.command.os.text.14=\
415
  /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" \n\
416
  brew install podman
417
418
# STEP 2 a: Download container manager (Windows)
419
Wizard.typesetter.win.2.download.container.header=Download ${Wizard.typesetter.container.name} for Windows
420
Wizard.typesetter.win.2.download.container.homepage.link.lbl=${Wizard.typesetter.container.name}
421
Wizard.typesetter.win.2.download.container.homepage.link.url=https://podman.io
422
Wizard.typesetter.win.2.download.container.download.link.lbl=repository
423
Wizard.typesetter.win.2.download.container.download.link.url=https://github.com/containers/podman/releases/download/v${Wizard.typesetter.container.version}/podman-${Wizard.typesetter.container.version}-setup.exe
424
Wizard.typesetter.win.2.download.container.paths=Downloading {0} into {1}.
425
# suppress inspection "UnusedMessageFormatParameter"
426
Wizard.typesetter.win.2.download.container.status.bytes=Downloaded {1} bytes (size unknown).
427
Wizard.typesetter.win.2.download.container.status.progress=Downloaded {0} % of {1} bytes.
428
Wizard.typesetter.win.2.download.container.status.checksum.ok=File {0} exists. Click Next to continue.
429
Wizard.typesetter.win.2.download.container.status.checksum.no=Integrity check failed, {0} may be corrupt.
430
Wizard.typesetter.win.2.download.container.status.success=Download successful. Click Next to continue.
431
Wizard.typesetter.win.2.download.container.status.failure=Download failed. Check network then click Previous to try again.
432
433
# STEP 2 b: Install container manager (Windows)
434
Wizard.typesetter.win.2.install.container.header=Install ${Wizard.typesetter.container.name} for Windows
435
Wizard.typesetter.win.2.install.container.step.0=Complete the following steps to install ${Wizard.typesetter.container.name}:
436
Wizard.typesetter.win.2.install.container.step.1=\t1. Open the task bar.
437
Wizard.typesetter.win.2.install.container.step.2=\t2. Click the shield icon to grant permissions.
438
Wizard.typesetter.win.2.install.container.step.3=\t3. Click Yes in the User Account Control dialog to install.
439
Wizard.typesetter.win.2.install.container.status.running=Installing ...
440
Wizard.typesetter.win.2.install.container.status.success=Installation successful.\nClick Next to continue.
441
Wizard.typesetter.win.2.install.container.status.failure=Installation failed with exit code {0}.
442
Wizard.typesetter.win.2.install.container.status.unknown=Could not determine installer file type: {0}
443
444
# STEP 2: Install container manager (Universal, undetected operating system)
445
Wizard.typesetter.all.2.install.container.header=Install ${Wizard.typesetter.container.name}
446
Wizard.typesetter.all.2.install.container.homepage.lbl=${Wizard.typesetter.container.name}
447
Wizard.typesetter.all.2.install.container.homepage.url=https://podman.io
448
449
# STEP 3: Initialize container manager (all except Linux)
450
Wizard.typesetter.all.3.install.container.header=Initialize ${Wizard.typesetter.container.name}
451
Wizard.typesetter.all.3.install.container.correct=${Wizard.typesetter.container.name} initialized.\nClick Next to continue.
452
Wizard.typesetter.all.3.install.container.missing=Install ${Wizard.typesetter.container.name} before continuing.
453
454
# STEP 4: Install typesetter container image (all)
455
Wizard.typesetter.all.4.download.image.header=Download ${Wizard.typesetter.name} image
456
Wizard.typesetter.all.4.download.image.correct=Download successful.\nClick Next to continue.
457
Wizard.typesetter.all.4.download.image.missing=Install ${Wizard.typesetter.container.name} before continuing.
458
459
# STEP 5: Download typesetter themes (all)
460
Wizard.typesetter.all.5.download.themes.header=Download ${Wizard.typesetter.name} themes
461
Wizard.typesetter.all.5.download.themes.download.link.lbl=repository
462
Wizard.typesetter.all.5.download.themes.download.link.url=https://gitlab.com/DaveJarvis/keenwrite-themes/-/releases/${Wizard.typesetter.themes.version}/downloads/theme-pack.zip
463
Wizard.typesetter.all.5.download.themes.paths=Downloading {0} into {1}.
464
Wizard.typesetter.all.5.download.themes.status.bytes=Downloaded {0} bytes (size unknown).
465
Wizard.typesetter.all.5.download.themes.status.progress=Downloaded {0} % of {1} bytes.
466
Wizard.typesetter.all.5.download.themes.status.checksum.ok=File {0} exists. Click Finish to continue.
467
Wizard.typesetter.all.5.download.themes.status.checksum.no=Integrity check failed, {0} may be corrupt.
468
Wizard.typesetter.all.5.download.themes.status.success=Download successful. Click Finish to continue.
469
Wizard.typesetter.all.5.download.themes.status.failure=Download failed. Check network then click Previous to try again.
470
471
# ########################################################################
472
# Open URL dialog
473
# ########################################################################
474
475
Dialog.open_url.title=Open URL
476
Dialog.open_url.label.url=URL\:
477
Dialog.open_url.prompt.url=https://example.com/filename.md
478
479
# ########################################################################
480
# Insert image dialog
481
# ########################################################################
482
483
Dialog.image.title=Insert image
484
Dialog.image.label.url=File or URL\:
485
Dialog.image.label.text=Alternate text\:
486
Dialog.image.label.title=Title\:
487
Dialog.image.prompt.url=Image resource
488
Dialog.image.prompt.text=Image description
489
Dialog.image.prompt.title=Image tooltip
490
491
# ########################################################################
492
# Insert hyperlink dialog
493
# ########################################################################
494
495
Dialog.link.title=Insert hyperlink
496
Dialog.link.label.text=Text\:
497
Dialog.link.label.url=URL\:
498
Dialog.link.label.title=Title\:
499
Dialog.link.prompt.text=Hyperlink text
500
Dialog.link.prompt.url=https://example.com/index.html
501
Dialog.link.prompt.title=Hyperlink tooltip
502
503
# ########################################################################
504
# Typesetting settings dialog
505
# ########################################################################
506
507
Dialog.typesetting.settings.title=Typesetting export settings
508
Dialog.typesetting.settings.header.single=Export current document
509
Dialog.typesetting.settings.theme=Theme
510
Dialog.typesetting.settings.themes.missing=Install themes into {0}.
511
512
Dialog.typesetting.settings.header.multiple=Export multiple documents
513
Dialog.typesetting.settings.chapters=Chapters (e.g., 1-3, 5, 7-)
514
515
# ########################################################################
516
# About dialog
517
# ########################################################################
518
519
Dialog.about.title=About {0}
520
Dialog.about.header={0}
521
Dialog.about.content=Copyright 2016-{0} White Magic Software, Ltd.\n\nVersion: {1}
522
523
# ########################################################################
524
# Application Actions
525
# ########################################################################
526
527
Action.file.new.description=Create a new file
528
Action.file.new.accelerator=Shortcut+N
529
Action.file.new.icon=FILE_ALT
530
Action.file.new.text=_New
531
532
Action.file.open.description=Open a new file
533
Action.file.open.accelerator=Shortcut+O
534
Action.file.open.text=_Open...
535
Action.file.open.icon=FOLDER_OPEN_ALT
536
537
Action.file.open_url.description=Open a URL
538
Action.file.open_url.accelerator=Shortcut+Alt+O
539
Action.file.open_url.text=Open _URL...
540
Action.file.open_url.icon=FOLDER_OPEN_ALT
541
542
Action.file.close.description=Close the current document
543
Action.file.close.accelerator=Shortcut+W
544
Action.file.close.text=_Close
545
546
Action.file.close_all.description=Close all open documents
547
Action.file.close_all.accelerator=Shortcut+F4
548
Action.file.close_all.text=Close All
549
550
Action.file.save.description=Save the document
551
Action.file.save.accelerator=Shortcut+S
552
Action.file.save.text=_Save
553
Action.file.save.icon=FLOPPY_ALT
554
555
Action.file.save_as.description=Rename the current document
556
Action.file.save_as.text=Save _As
557
558
Action.file.save_all.description=Save all open documents
559
Action.file.save_all.accelerator=Shortcut+Shift+S
560
Action.file.save_all.text=Save A_ll
561
562
Action.file.export.pdf.description=Typeset the document
563
Action.file.export.pdf.accelerator=Shortcut+P
564
Action.file.export.pdf.text=_PDF
565
Action.file.export.pdf.icon=FILE_PDF_ALT
566
567
Action.file.export.pdf.dir.description=Typeset files in document directory
568
Action.file.export.pdf.dir.accelerator=Shortcut+Shift+P
569
Action.file.export.pdf.dir.text=_Joined PDF
570
Action.file.export.pdf.dir.icon=FILE_PDF_ALT
571
572
Action.file.export.pdf.repeat.description=Repeat previous typesetting command
573
Action.file.export.pdf.repeat.accelerator=Shortcut+Shift+E
574
Action.file.export.pdf.repeat.text=_Repeat Export
575
Action.file.export.pdf.repeat.icon=FILE_PDF_ALT
576
577
Action.file.export.html.dir.description=Export files in document directory as HTML
578
Action.file.export.html.dir.accelerator=Shortcut+Shift+H
579
Action.file.export.html.dir.text=Joined _HTML
580
Action.file.export.html.dir.icon=HTML5
581
582
Action.file.export.html_svg.description=Export the current document as HTML + SVG
583
Action.file.export.text=_Export As
584
Action.file.export.html_svg.text=HTML and S_VG
585
586
Action.file.export.html_tex.description=Export the current document as HTML + TeX
587
Action.file.export.html_tex.text=HTML and _TeX
588
589
Action.file.export.xhtml_tex.description=Export as XHTML + TeX
590
Action.file.export.xhtml_tex.text=_XHTML and TeX
591
592
Action.file.export.markdown.description=Export the current document as Markdown
593
Action.file.export.markdown.text=Markdown
594
595
Action.file.exit.description=Quit the application
596
Action.file.exit.text=E_xit
597
598
599
Action.edit.undo.description=Undo the previous edit
600
Action.edit.undo.accelerator=Shortcut+Z
601
Action.edit.undo.text=_Undo
602
Action.edit.undo.icon=UNDO
603
604
Action.edit.redo.description=Redo the previous edit
605
Action.edit.redo.accelerator=Shortcut+Y
606
Action.edit.redo.text=_Redo
607
Action.edit.redo.icon=REPEAT
608
609
Action.edit.cut.description=Delete the selected text or line
610
Action.edit.cut.accelerator=Shortcut+X
611
Action.edit.cut.text=Cu_t
612
Action.edit.cut.icon=CUT
613
614
Action.edit.copy.description=Copy the selected text
615
Action.edit.copy.accelerator=Shortcut+C
616
Action.edit.copy.text=_Copy
617
Action.edit.copy.icon=COPY
618
619
Action.edit.paste.description=Paste from the clipboard
620
Action.edit.paste.accelerator=Shortcut+V
621
Action.edit.paste.text=_Paste
622
Action.edit.paste.icon=PASTE
623
624
Action.edit.select_all.description=Highlight the current document text
625
Action.edit.select_all.accelerator=Shortcut+A
626
Action.edit.select_all.text=Select _All
627
628
Action.edit.find.description=Search for text in the document
629
Action.edit.find.accelerator=Shortcut+F
630
Action.edit.find.text=_Find
631
Action.edit.find.icon=SEARCH
632
633
Action.edit.find_next.description=Find next occurrence
634
Action.edit.find_next.accelerator=F3
635
Action.edit.find_next.text=Find _Next
636
637
Action.edit.find_prev.description=Find previous occurrence
638
Action.edit.find_prev.accelerator=Shift+F3
639
Action.edit.find_prev.text=Find _Prev
640
641
Action.edit.preferences.description=Edit user preferences
642
Action.edit.preferences.accelerator=Shortcut+Alt+S
643
Action.edit.preferences.text=_Preferences
644
645
646
Action.format.bold.description=Insert strong text
647
Action.format.bold.accelerator=Shortcut+B
648
Action.format.bold.text=_Bold
649
Action.format.bold.icon=BOLD
650
651
Action.format.italic.description=Insert text emphasis
652
Action.format.italic.accelerator=Shortcut+I
653
Action.format.italic.text=_Italic
654
Action.format.italic.icon=ITALIC
655
656
Action.format.monospace.description=Insert monospace text
657
Action.format.monospace.accelerator=Shortcut+`
658
Action.format.monospace.text=_Monospace
659
660
Action.format.superscript.description=Insert superscript text
661
Action.format.superscript.accelerator=Shortcut+[
662
Action.format.superscript.text=Su_perscript
663
Action.format.superscript.icon=SUPERSCRIPT
664
665
Action.format.subscript.description=Insert subscript text
666
Action.format.subscript.accelerator=Shortcut+]
667
Action.format.subscript.text=Su_bscript
668
Action.format.subscript.icon=SUBSCRIPT
669
670
Action.format.strikethrough.description=Insert struck text
671
Action.format.strikethrough.accelerator=Shortcut+T
672
Action.format.strikethrough.text=Stri_kethrough
673
Action.format.strikethrough.icon=STRIKETHROUGH
674
675
676
Action.insert.blockquote.description=Insert blockquote
677
Action.insert.blockquote.accelerator=Shortcut+Q
678
Action.insert.blockquote.text=_Blockquote
679
Action.insert.blockquote.icon=QUOTE_LEFT
680
681
Action.insert.code.description=Insert inline code
682
Action.insert.code.accelerator=Shortcut+K
683
Action.insert.code.text=Inline _Code
684
Action.insert.code.icon=CODE
685
686
Action.insert.fenced_code_block.description=Insert code block
687
Action.insert.fenced_code_block.accelerator=Shortcut+Shift+K
688
Action.insert.fenced_code_block.text=_Fenced Code Block
689
Action.insert.fenced_code_block.prompt.text=Enter code here
690
Action.insert.fenced_code_block.icon=FILE_CODE_ALT
691
692
Action.insert.link.description=Insert hyperlink
693
Action.insert.link.accelerator=Shortcut+L
694
Action.insert.link.text=_Link...
695
Action.insert.link.icon=LINK
696
697
Action.insert.image.description=Insert image
698
Action.insert.image.accelerator=Shortcut+G
699
Action.insert.image.text=_Image...
700
Action.insert.image.icon=PICTURE_ALT
701
702
Action.insert.heading.description=Insert heading level
703
Action.insert.heading.accelerator=Shortcut+
704
Action.insert.heading.icon=HEADER
705
706
Action.insert.heading_1.description=${Action.insert.heading.description} 1
707
Action.insert.heading_1.accelerator=${Action.insert.heading.accelerator}1
708
Action.insert.heading_1.text=Heading _1
709
Action.insert.heading_1.icon=${Action.insert.heading.icon}
710
711
Action.insert.heading_2.description=${Action.insert.heading.description} 2
712
Action.insert.heading_2.accelerator=${Action.insert.heading.accelerator}2
713
Action.insert.heading_2.text=Heading _2
714
Action.insert.heading_2.icon=${Action.insert.heading.icon}
715
716
Action.insert.heading_3.description=${Action.insert.heading.description} 3
717
Action.insert.heading_3.accelerator=${Action.insert.heading.accelerator}3
718
Action.insert.heading_3.text=Heading _3
719
Action.insert.heading_3.icon=${Action.insert.heading.icon}
720
721
Action.insert.unordered_list.description=Insert bulleted list
722
Action.insert.unordered_list.accelerator=Shortcut+U
723
Action.insert.unordered_list.text=_Unordered List
724
Action.insert.unordered_list.icon=LIST_UL
725
726
Action.insert.ordered_list.description=Insert enumerated list
727
Action.insert.ordered_list.accelerator=Shortcut+Shift+O
728
Action.insert.ordered_list.text=_Ordered List
729
Action.insert.ordered_list.icon=LIST_OL
730
731
Action.insert.horizontal_rule.description=Insert horizontal rule
732
Action.insert.horizontal_rule.accelerator=Shortcut+H
733
Action.insert.horizontal_rule.text=_Horizontal Rule
734
Action.insert.horizontal_rule.icon=LIST_OL
735
736
737
Action.definition.create.description=Create a new variable
738
Action.definition.create.text=_Create
739
Action.definition.create.icon=TREE
740
Action.definition.create.tooltip=Add new item (Insert)
741
742
Action.definition.rename.description=Rename the selected variable
743
Action.definition.rename.text=_Rename
744
Action.definition.rename.icon=EDIT
745
Action.definition.rename.tooltip=Rename selected item (F2)
746
747
Action.definition.delete.description=Delete the selected variables
748
Action.definition.delete.text=De_lete
749
Action.definition.delete.icon=TRASH
750
Action.definition.delete.tooltip=Delete selected items (Delete)
751
752
Action.definition.insert.description=Insert a variable
753
Action.definition.insert.accelerator=Shortcut+Space
754
Action.definition.insert.text=_Insert
755
Action.definition.insert.icon=STAR
756
757
758
Action.view.refresh.description=Clear all caches
759
Action.view.refresh.accelerator=F5
760
Action.view.refresh.text=Refresh
761
762
Action.view.preview.description=Open document preview
763
Action.view.preview.accelerator=F6
764
Action.view.preview.text=Preview
765
766
Action.view.outline.description=Open document outline
767
Action.view.outline.accelerator=F7
768
Action.view.outline.text=Outline
769
770
Action.view.statistics.description=Open document word counts
771
Action.view.statistics.accelerator=F8
772
Action.view.statistics.text=Statistics
773
774
Action.view.files.description=Open file manager
775
Action.view.files.accelerator=Shortcut+F8
776
Action.view.files.text=Files
777
778
Action.view.menubar.description=Toggle menu bar
779
Action.view.menubar.accelerator=Shortcut+F9
780
Action.view.menubar.text=Menu bar
781
782
Action.view.toolbar.description=Toggle toolbar
783
Action.view.toolbar.accelerator=Shortcut+Shift+F9
784
Action.view.toolbar.text=Toolbar
785
786
Action.view.statusbar.description=Toggle status bar
787
Action.view.statusbar.accelerator=Shortcut+Shift+Alt+F9
768788
Action.view.statusbar.text=Status bar
769789
M src/main/resources/com/keenwrite/skins/scene.css
1
/*
2
 * Copyright (c) 2015 Karl Tauber <karl at jformdesigner dot com>
3
 * All rights reserved.
4
 *
5
 * Redistribution and use in source and binary forms, with or without
6
 * modification, are permitted provided that the following conditions are met:
7
 *
8
 *  o Redistributions of source code must retain the above copyright
9
 *    notice, this list of conditions and the following disclaimer.
10
 *
11
 *  o Redistributions in binary form must reproduce the above copyright
12
 *    notice, this list of conditions and the following disclaimer in the
13
 *    documentation and/or other materials provided with the distribution.
14
 *
15
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
16
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
17
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
18
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
19
 * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
20
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
21
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
22
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
23
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
24
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
25
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
26
 */
27
281
.tool-bar {
292
  -fx-spacing: 0;