Dave Jarvis' Repositories

git clone https://repo.autonoma.ca/repo/keenwrite.git
M R/README.md
33
Import the files in this directory into the application, which include:
44
5
* bootstrap.R
56
* pluralize.R
67
* possessive.R
78
* conversion.R
89
* csv.R
10
11
# bootstrap.R
12
13
Copy the contents of this file into R script preferences, as shown in the
14
following figure, then restart the application for the changes to take
15
effect:
16
17
# ![Bootstrap](images/bootstrap.png)
18
19
Setting the **Working Directory** allows the startup script to load files
20
using a relative to said directory.
921
1022
# pluralize.R
1123
1224
This file defines a function that implements most of Damian Conway's [An Algorithmic Approach to English Pluralization](http://blob.perl.org/tpc/1998/User_Applications/Algorithmic%20Approach%20Plurals/Algorithmic_Plurals.html).
1325
1426
## Usage
1527
1628
Example usages of the pluralize function include:
1729
18
    `r#pluralize( 'mouse' )` - mice
19
    `r#pluralize( 'buzz' )` - buzzes
20
    `r#pluralize( 'bus' )` - busses
30
    `r#pluralize( "mouse" )` - mice
31
    `r#pluralize( "buzz" )` - buzzes
32
    `r#pluralize( "bus" )` - busses
2133
2234
# possessive.R
2335
2436
This file defines a function that applies possessives to English words.
2537
2638
## Usage
2739
2840
Example usages of the possessive function include:
2941
30
    `r#pos( 'Ross' )` - Ross'
31
    `r#pos( 'Ruby' )` - Ruby's
32
    `r#pos( 'Lois' )` - Lois'
33
    `r#pos( 'my' )` - mine
34
    `r#pos( 'Your' )` - Yours
42
    `r#pos( "Ross" )` - Ross'
43
    `r#pos( "Ruby" )` - Ruby's
44
    `r#pos( "Lois" )` - Lois'
45
    `r#pos( "my" )` - mine
46
    `r#pos( "Your" )` - Yours
3547
3648
M R/bootstrap.R
1
setwd( '{{application.r.working.directory}}' )
2
assign( "anchor", '{{date.anchor}}', envir = .GlobalEnv )
1
setwd( v$application$r$working$directory )
32
4
source( 'pluralize.R' )
5
source( 'possessive.R' )
6
source( 'conversion.R' )
7
source( 'csv.R' )
3
# To reference additional R variables in documents, define them such as:
4
# assign( "variable", v$key$name, envir = .GlobalEnv )
5
6
source( "pluralize.R" )
7
source( "possessive.R" )
8
source( "conversion.R" )
9
source( "csv.R" )
810
911
M R/csv.R
5353
    df[ (nrow( df ) + 1), number ] <- f.sum( df[, number], na.rm=TRUE )
5454
55
    # pluralise would be heavyweight here.
55
    # pluralize would be heavyweight here.
5656
    if( length( number ) > 1 ) {
57
      t <- "**Totals**"
57
      t <- "Totals"
5858
    }
5959
    else {
60
      t <- "**Total**"
60
      t <- "Total"
6161
    }
6262
...
7070
  if( align ) {
7171
    is.char <- vapply( df, is.character, logical( 1 ) )
72
    dashes <- paste( ifelse( is.char, ':---', '---:' ), collapse='|' )
72
    dashes <- paste( ifelse( is.char, ':---', '---:' ), collapse = '|' )
7373
  }
7474
  else {
7575
    dashes <- paste( rep( '---', length( df ) ), collapse = '|' )
7676
  }
7777
7878
  # Create a Markdown version of the data frame.
7979
  paste(
80
    paste( names( df ), collapse = '|'), '\n',
81
    dashes, '\n', 
80
    '|', paste( names( df ), collapse = '|'), '|', '\n',
81
    '|', dashes, '|', '\n', 
8282
    paste(
83
      '|',
8384
      Reduce( function( x, y ) {
84
          paste( x, format( y, digits = decimals ), sep = '|' )
85
          paste( x, format( y, nsmall = decimals ), sep = '|' )
8586
        }, df
8687
      ),
8788
      collapse = '|\n', sep=''
88
    )
89
    ),
90
    '|\n',
91
    sep=''
8992
  )
9093
}
A R/images/bootstrap.png
Binary file
M build.gradle
5050
5151
dependencies {
52
  def v_junit = '5.8.1'
52
  def v_junit = '5.8.2'
5353
  def v_flexmark = '0.62.2'
54
  def v_jackson = '2.13.0'
54
  def v_jackson = '2.13.1'
5555
  def v_batik = '1.14'
5656
  def v_wheatsheaf = '2.0.1'
...
119119
  implementation 'com.googlecode.juniversalchardet:juniversalchardet:1.0.3'
120120
  implementation 'javax.validation:validation-api:2.0.1.Final'
121
  implementation 'org.greenrobot:eventbus:3.2.0'
121
  implementation 'org.greenrobot:eventbus-java:3.3.1'
122122
123123
  implementation 'org.apache.commons:commons-configuration2:2.7'
M docs/README.md
99
* [skins.md](skins.md) -- User interface customization
1010
* [svg.md](svg.md) -- Resolve issues with some SVG files
11
* [metadata.md](metadata.md) -- Document metadata
1112
* [typesetting.md](typesetting.md) -- Document typesetting
1213
* [variables.md](variables.md) -- Variable definitions and interpolation
A docs/images/screenshots/09.png
Binary file
A docs/metadata.md
1
# Document metadata
2
3
Document metadata is information about a document. Metadata often includes
4
a title, author name, copyright date, and keywords.
5
6
# Custom metadata
7
8
The following screenshot shows example metadata preferences:
9
10
![Metadata screenshot](images/screenshots/09.png)
11
12
The **Key** column lists metadata names and the **Value** column lists
13
the metadata content for each corresponding **Key**. The content may
14
include references to variable definitions. When the document is typeset,
15
the values for the variables will be substituted upon export.
16
17
When the document is exported as XHTML, the header will include the
18
keys and values conforming to the XHTML specification. For example:
19
20
``` html
21
<head>
22
  <title>Document Title</title>
23
  <meta content="science, nature" name="keywords"/>
24
  <meta content="Penn Surnom" name="author"/>
25
  <meta content="4311" name="count"/>
26
</head>
27
```
28
29
# Special metadata
30
31
When exporting the document, note the following special metadata:
32
33
* **author** -- Included as PDF metadata
34
* **byline** -- Replaces author in PDF metadata (e.g., for pen names)
35
* **count** -- Total word count in document, automatically included
36
* **keywords** -- Included as PDF metadata
37
* **title** -- Included as a `<title>` tag, rather than a `<meta>` tag
38
139
M docs/r.md
9090
1. Set the **R Startup Script** contents to:
9191
    ``` r
92
    setwd( '{{application.r.working.directory}}' );
93
    source( 'library.R' );
92
    setwd( v$application$r$working$directory );
93
    source( "library.R" );
9494
    ```
9595
1. Change `sum.Rmd` to:
...
107107
```
108108
109
Calling `setwd` using `'{{application.r.working.directory}}'` changes the
109
Calling `setwd` using `v$application$r$working$directory` changes the
110110
working directory where the R engine searches for source files.
111111
M src/main/java/com/keenwrite/AbstractFileFactory.java
5353
      final var predicate = createFileTypePredicate( patterns );
5454
55
      if( found = predicate.test( path.toFile() ) ) {
55
      if( predicate.test( path.toFile() ) ) {
5656
        // Remove the EXTENSIONS_PREFIX to get the file name extension mapped
5757
        // to a standard name (as defined in the settings.properties file).
5858
        final String suffix = key.replace( prefix + '.', "" );
5959
        fileType = FileType.from( suffix );
60
        found = true;
6061
      }
6162
    }
M src/main/java/com/keenwrite/AppCommands.java
8888
   *               editor's directory; {@code false} means to export only the
8989
   *               actively edited file.
90
   *
9091
  private void file_export_pdf( final Path theme, final boolean concat ) {
9192
    if( Typesetter.canRun() ) {
...
124125
    file_export( MARKDOWN_PLAIN );
125126
  }
126
   */
127
127
*/
128128
  /**
129129
   * Concatenates all the files in the same directory as the given file into
M src/main/java/com/keenwrite/Bootstrap.java
2424
   * Order matters, this must be populated before deriving the app title.
2525
   */
26
  private static final Properties P = new Properties();
26
  private static final Properties sP = new Properties();
2727
2828
  static {
2929
    try( final var in = openResource( "/bootstrap.properties" ) ) {
30
      P.load( in );
30
      sP.load( in );
3131
    } catch( final Exception ignored ) {
3232
      // Bootstrap properties cannot be found, throw in the towel.
3333
    }
3434
  }
3535
36
  public static final String APP_TITLE = P.getProperty( "application.title" );
36
  public static final String APP_TITLE = sP.getProperty( "application.title" );
3737
  public static final String APP_TITLE_LOWERCASE = APP_TITLE.toLowerCase();
3838
  public static final String APP_VERSION = Launcher.getVersion();
M src/main/java/com/keenwrite/Caret.java
1919
public class Caret {
2020
21
  private final Mutator mMutator;
22
2123
  public static GenericBuilder<Caret.Mutator, Caret> builder() {
2224
    return GenericBuilder.of( Caret.Mutator::new, Caret::new );
...
6668
    }
6769
  }
68
69
  private final Mutator mMutator;
7070
7171
  /**
D src/main/java/com/keenwrite/DefinitionNameInjector.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite;
3
4
import com.keenwrite.editors.TextDefinition;
5
import com.keenwrite.editors.TextEditor;
6
import com.keenwrite.editors.definition.DefinitionTreeItem;
7
import com.keenwrite.sigils.SigilOperator;
8
9
import static com.keenwrite.constants.Constants.*;
10
import static com.keenwrite.events.StatusEvent.clue;
11
12
/**
13
 * Provides the logic for injecting variable names within the editor.
14
 */
15
public final class DefinitionNameInjector {
16
17
  /**
18
   * Prevent instantiation.
19
   */
20
  private DefinitionNameInjector() {
21
  }
22
23
  /**
24
   * Find a node that matches the current word and substitute the definition
25
   * reference.
26
   */
27
  public static void autoinsert(
28
    final TextEditor editor,
29
    final TextDefinition definitions,
30
    final SigilOperator operator ) {
31
    try {
32
      if( definitions.isEmpty() ) {
33
        clue( STATUS_DEFINITION_EMPTY );
34
      }
35
      else {
36
        final var indexes = editor.getCaretWord();
37
        final var word = editor.getText( indexes );
38
39
        if( word.isBlank() ) {
40
          clue( STATUS_DEFINITION_BLANK );
41
        }
42
        else {
43
          final var leaf = findLeaf( definitions, word );
44
45
          if( leaf == null ) {
46
            clue( STATUS_DEFINITION_MISSING, word );
47
          }
48
          else {
49
            final var entokened = operator.entoken( leaf.toPath() );
50
            editor.replaceText( indexes, operator.apply( entokened ) );
51
            definitions.expand( leaf );
52
          }
53
        }
54
      }
55
    } catch( final Exception ex ) {
56
      clue( STATUS_DEFINITION_BLANK, ex );
57
    }
58
  }
59
60
  /**
61
   * Looks for the given word, matching first by exact, next by a starts-with
62
   * condition with diacritics replaced, then by containment.
63
   *
64
   * @param word Match the word by: exact, beginning, containment, or other.
65
   */
66
  @SuppressWarnings( "ConstantConditions" )
67
  private static DefinitionTreeItem<String> findLeaf(
68
    final TextDefinition definition, final String word ) {
69
    assert word != null;
70
71
    DefinitionTreeItem<String> leaf = null;
72
73
    leaf = leaf == null ? definition.findLeafExact( word ) : leaf;
74
    leaf = leaf == null ? definition.findLeafStartsWith( word ) : leaf;
75
    leaf = leaf == null ? definition.findLeafContains( word ) : leaf;
76
    leaf = leaf == null ? definition.findLeafContainsNoCase( word ) : leaf;
77
78
    return leaf;
79
  }
80
}
811
M src/main/java/com/keenwrite/ExportFormat.java
5252
  private final String mExtension;
5353
54
  ExportFormat( final String extension ) {
55
    mExtension = extension;
56
  }
57
5854
  /**
5955
   * Looks up the {@link ExportFormat} based on the given format type and
...
8379
      ) );
8480
    };
81
  }
82
83
  ExportFormat( final String extension ) {
84
    mExtension = extension;
8585
  }
8686
M src/main/java/com/keenwrite/Launcher.java
3232
  private final String[] mArgs;
3333
34
  /**
35
   * Delegates running the application via the command-line argument parser.
36
   * This is the main entry point for the application, regardless of whether
37
   * run from the command-line or as a GUI.
38
   *
39
   * @param args Command-line arguments.
40
   */
41
  public static void main( final String[] args ) {
42
    installTrustManager();
43
    parse( args );
44
  }
45
46
  /**
47
   * @param args Command-line arguments (passed into the GUI).
48
   */
49
  public Launcher( final String[] args ) {
50
    mArgs = args;
51
  }
52
53
  /**
54
   * Called after the arguments have been parsed.
55
   *
56
   * @param args The parsed command-line arguments.
57
   */
58
  @Override
59
  public void accept( final Arguments args ) {
60
    assert args != null;
61
62
    try {
63
      int argCount = mArgs.length;
64
65
      if( args.quiet() ) {
66
        argCount--;
67
      }
68
      else {
69
        showAppInfo();
70
      }
71
72
      if( args.debug() ) {
73
        argCount--;
74
      }
75
      else {
76
        disableLogging();
77
      }
78
79
      if( argCount <= 0 ) {
80
        // When no command-line arguments are provided, launch the GUI.
81
        MainApp.main( mArgs );
82
      }
83
      else {
84
        // When command-line arguments are supplied, run in headless mode.
85
        HeadlessApp.main( args );
86
      }
87
    } catch( final Throwable t ) {
88
      log( t );
89
    }
90
  }
91
9234
  private static void parse( final String[] args ) {
9335
    assert args != null;
...
183125
  private static void out( final String message, final Object... args ) {
184126
    System.out.printf( format( "%s%n", message ), args );
127
  }
128
129
  /**
130
   * Delegates running the application via the command-line argument parser.
131
   * This is the main entry point for the application, regardless of whether
132
   * run from the command-line or as a GUI.
133
   *
134
   * @param args Command-line arguments.
135
   */
136
  public static void main( final String[] args ) {
137
    installTrustManager();
138
    parse( args );
139
  }
140
141
  /**
142
   * @param args Command-line arguments (passed into the GUI).
143
   */
144
  public Launcher( final String[] args ) {
145
    mArgs = args;
146
  }
147
148
  /**
149
   * Called after the arguments have been parsed.
150
   *
151
   * @param args The parsed command-line arguments.
152
   */
153
  @Override
154
  public void accept( final Arguments args ) {
155
    assert args != null;
156
157
    try {
158
      int argCount = mArgs.length;
159
160
      if( args.quiet() ) {
161
        argCount--;
162
      }
163
      else {
164
        showAppInfo();
165
      }
166
167
      if( args.debug() ) {
168
        argCount--;
169
      }
170
      else {
171
        disableLogging();
172
      }
173
174
      if( argCount <= 0 ) {
175
        // When no command-line arguments are provided, launch the GUI.
176
        MainApp.main( mArgs );
177
      }
178
      else {
179
        // When command-line arguments are supplied, run in headless mode.
180
        HeadlessApp.main( args );
181
      }
182
    } catch( final Throwable t ) {
183
      log( t );
184
    }
185185
  }
186
186187
}
187188
M src/main/java/com/keenwrite/MainApp.java
22
package com.keenwrite;
33
4
import com.keenwrite.cmdline.HeadlessApp;
45
import com.keenwrite.events.HyperlinkOpenEvent;
56
import com.keenwrite.preferences.Workspace;
...
1718
import static com.keenwrite.constants.GraphicsConstants.LOGOS;
1819
import static com.keenwrite.events.Bus.register;
19
import static com.keenwrite.preferences.WorkspaceKeys.*;
20
import static com.keenwrite.preferences.AppKeys.*;
2021
import static com.keenwrite.util.FontLoader.initFonts;
2122
import static javafx.scene.input.KeyCode.ALT;
...
3536
3637
  /**
37
   * Application entry point.
38
   * GUI application entry point. See {@link HeadlessApp} for the entry
39
   * point to the command-line application.
3840
   *
3941
   * @param args Command-line arguments.
4042
   */
4143
  public static void main( final String[] args ) {
4244
    launch( args );
45
  }
46
47
  /**
48
   * Creates an instance of {@link KeyEvent} that represents pressing a key.
49
   *
50
   * @param code  The key to simulate being pressed down.
51
   * @param shift Whether shift key modifier shall modify the key code.
52
   * @return An instance of {@link KeyEvent} that may be used to simulate
53
   * a key being pressed.
54
   */
55
  public static Event keyDown( final KeyCode code, final boolean shift ) {
56
    return keyEvent( KEY_PRESSED, code, shift );
57
  }
58
59
  /**
60
   * Creates an instance of {@link KeyEvent} that represents releasing a key.
61
   *
62
   * @param code  The key to simulate being released up.
63
   * @param shift Whether shift key modifier shall modify the key code.
64
   * @return An instance of {@link KeyEvent} that may be used to simulate
65
   * a key being released.
66
   */
67
  public static Event keyUp( final KeyCode code, final boolean shift ) {
68
    return keyEvent( KEY_RELEASED, code, shift );
69
  }
70
71
  /**
72
   * Creates an instance of {@link KeyEvent} that represents a key released
73
   * event without any modifier keys held.
74
   *
75
   * @param code The key code representing a key to simulate releasing.
76
   * @return An instance of {@link KeyEvent}.
77
   */
78
  public static Event keyUp( final KeyCode code ) {
79
    return keyUp( code, false );
80
  }
81
82
  private static Event keyEvent(
83
    final EventType<KeyEvent> type, final KeyCode code, final boolean shift ) {
84
    return new KeyEvent(
85
      type, "", "", code, shift, false, false, false
86
    );
4387
  }
4488
...
67111
    final var enable = createBoundsEnabledSupplier( stage );
68112
69
    stage.setX( mWorkspace.toDouble( KEY_UI_WINDOW_X ) );
70
    stage.setY( mWorkspace.toDouble( KEY_UI_WINDOW_Y ) );
71
    stage.setWidth( mWorkspace.toDouble( KEY_UI_WINDOW_W ) );
72
    stage.setHeight( mWorkspace.toDouble( KEY_UI_WINDOW_H ) );
73
    stage.setMaximized( mWorkspace.toBoolean( KEY_UI_WINDOW_MAX ) );
74
    stage.setFullScreen( mWorkspace.toBoolean( KEY_UI_WINDOW_FULL ) );
113
    stage.setX( mWorkspace.getDouble( KEY_UI_WINDOW_X ) );
114
    stage.setY( mWorkspace.getDouble( KEY_UI_WINDOW_Y ) );
115
    stage.setWidth( mWorkspace.getDouble( KEY_UI_WINDOW_W ) );
116
    stage.setHeight( mWorkspace.getDouble( KEY_UI_WINDOW_H ) );
117
    stage.setMaximized( mWorkspace.getBoolean( KEY_UI_WINDOW_MAX ) );
118
    stage.setFullScreen( mWorkspace.getBoolean( KEY_UI_WINDOW_FULL ) );
75119
76120
    mWorkspace.listen( KEY_UI_WINDOW_X, stage.xProperty(), enable );
...
140184
    return () ->
141185
      !(stage.isMaximized() || stage.isFullScreen() || stage.isIconified());
142
  }
143
144
  /**
145
   * Creates an instance of {@link KeyEvent} that represents pressing a key.
146
   *
147
   * @param code  The key to simulate being pressed down.
148
   * @param shift Whether shift key modifier shall modify the key code.
149
   * @return An instance of {@link KeyEvent} that may be used to simulate
150
   * a key being pressed.
151
   */
152
  public static Event keyDown( final KeyCode code, final boolean shift ) {
153
    return keyEvent( KEY_PRESSED, code, shift );
154
  }
155
156
  /**
157
   * Creates an instance of {@link KeyEvent} that represents releasing a key.
158
   *
159
   * @param code  The key to simulate being released up.
160
   * @param shift Whether shift key modifier shall modify the key code.
161
   * @return An instance of {@link KeyEvent} that may be used to simulate
162
   * a key being released.
163
   */
164
  public static Event keyUp( final KeyCode code, final boolean shift ) {
165
    return keyEvent( KEY_RELEASED, code, shift );
166
  }
167
168
  /**
169
   * Creates an instance of {@link KeyEvent} that represents a key released
170
   * event without any modifier keys held.
171
   *
172
   * @param code The key code representing a key to simulate releasing.
173
   * @return An instance of {@link KeyEvent}.
174
   */
175
  public static Event keyUp( final KeyCode code ) {
176
    return keyUp( code, false );
177
  }
178
179
  private static Event keyEvent(
180
    final EventType<KeyEvent> type, final KeyCode code, final boolean shift ) {
181
    return new KeyEvent(
182
      type, "", "", code, shift, false, false, false
183
    );
184186
  }
185187
}
M src/main/java/com/keenwrite/MainPane.java
1414
import com.keenwrite.preferences.Workspace;
1515
import com.keenwrite.preview.HtmlPreview;
16
import com.keenwrite.processors.Processor;
17
import com.keenwrite.processors.ProcessorContext;
18
import com.keenwrite.processors.ProcessorFactory;
19
import com.keenwrite.processors.markdown.extensions.CaretExtension;
20
import com.keenwrite.service.events.Notifier;
21
import com.keenwrite.sigils.RSigilOperator;
22
import com.keenwrite.sigils.SigilOperator;
23
import com.keenwrite.sigils.Sigils;
24
import com.keenwrite.sigils.YamlSigilOperator;
25
import com.keenwrite.ui.explorer.FilePickerFactory;
26
import com.keenwrite.ui.heuristics.DocumentStatistics;
27
import com.keenwrite.ui.outline.DocumentOutline;
28
import com.panemu.tiwulfx.control.dock.DetachableTab;
29
import com.panemu.tiwulfx.control.dock.DetachableTabPane;
30
import javafx.application.Platform;
31
import javafx.beans.property.*;
32
import javafx.collections.ListChangeListener;
33
import javafx.concurrent.Task;
34
import javafx.event.ActionEvent;
35
import javafx.event.Event;
36
import javafx.event.EventHandler;
37
import javafx.scene.Node;
38
import javafx.scene.Scene;
39
import javafx.scene.control.*;
40
import javafx.scene.control.TreeItem.TreeModificationEvent;
41
import javafx.scene.input.KeyEvent;
42
import javafx.scene.layout.FlowPane;
43
import javafx.stage.Stage;
44
import javafx.stage.Window;
45
import org.greenrobot.eventbus.Subscribe;
46
47
import java.io.File;
48
import java.io.FileNotFoundException;
49
import java.nio.file.Path;
50
import java.util.*;
51
import java.util.concurrent.ExecutorService;
52
import java.util.concurrent.ScheduledExecutorService;
53
import java.util.concurrent.ScheduledFuture;
54
import java.util.concurrent.atomic.AtomicBoolean;
55
import java.util.concurrent.atomic.AtomicReference;
56
import java.util.function.Function;
57
import java.util.stream.Collectors;
58
59
import static com.keenwrite.ExportFormat.NONE;
60
import static com.keenwrite.Messages.get;
61
import static com.keenwrite.constants.Constants.*;
62
import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG_NODE;
63
import static com.keenwrite.events.Bus.register;
64
import static com.keenwrite.events.StatusEvent.clue;
65
import static com.keenwrite.io.MediaType.*;
66
import static com.keenwrite.preferences.WorkspaceKeys.*;
67
import static com.keenwrite.processors.IdentityProcessor.IDENTITY;
68
import static com.keenwrite.processors.ProcessorFactory.createProcessors;
69
import static java.lang.String.format;
70
import static java.lang.System.getProperty;
71
import static java.util.concurrent.Executors.newFixedThreadPool;
72
import static java.util.concurrent.Executors.newScheduledThreadPool;
73
import static java.util.concurrent.TimeUnit.SECONDS;
74
import static java.util.stream.Collectors.groupingBy;
75
import static javafx.application.Platform.runLater;
76
import static javafx.scene.control.Alert.AlertType.ERROR;
77
import static javafx.scene.control.ButtonType.*;
78
import static javafx.scene.control.TabPane.TabClosingPolicy.ALL_TABS;
79
import static javafx.scene.input.KeyCode.SPACE;
80
import static javafx.scene.input.KeyCombination.CONTROL_DOWN;
81
import static javafx.util.Duration.millis;
82
import static javax.swing.SwingUtilities.invokeLater;
83
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
84
85
/**
86
 * Responsible for wiring together the main application components for a
87
 * particular {@link Workspace} (project). These include the definition views,
88
 * text editors, and preview pane along with any corresponding controllers.
89
 */
90
public final class MainPane extends SplitPane {
91
  private static final ExecutorService sExecutor = newFixedThreadPool( 1 );
92
93
  private final ScheduledExecutorService mSaver = newScheduledThreadPool( 1 );
94
  private final AtomicReference<ScheduledFuture<?>> mSaveTask =
95
    new AtomicReference<>();
96
97
  private static final Notifier sNotifier = Services.load( Notifier.class );
98
99
  /**
100
   * Used when opening files to determine how each file should be binned and
101
   * therefore what tab pane to be opened within.
102
   */
103
  private static final Set<MediaType> PLAIN_TEXT_FORMAT = Set.of(
104
    TEXT_MARKDOWN, TEXT_R_MARKDOWN, UNDEFINED
105
  );
106
107
  /**
108
   * Prevents re-instantiation of processing classes.
109
   */
110
  private final Map<TextResource, Processor<String>> mProcessors =
111
    new HashMap<>();
112
113
  private final Workspace mWorkspace;
114
115
  /**
116
   * Groups similar file type tabs together.
117
   */
118
  private final List<TabPane> mTabPanes = new ArrayList<>();
119
120
  /**
121
   * Renders the actively selected plain text editor tab.
122
   */
123
  private final HtmlPreview mPreview;
124
125
  /**
126
   * Provides an interactive document outline.
127
   */
128
  private final DocumentOutline mOutline = new DocumentOutline();
129
130
  /**
131
   * Changing the active editor fires the value changed event. This allows
132
   * refreshes to happen when external definitions are modified and need to
133
   * trigger the processing chain.
134
   */
135
  private final ObjectProperty<TextEditor> mActiveTextEditor =
136
    createActiveTextEditor();
137
138
  /**
139
   * Changing the active definition editor fires the value changed event. This
140
   * allows refreshes to happen when external definitions are modified and need
141
   * to trigger the processing chain.
142
   */
143
  private final ObjectProperty<TextDefinition> mActiveDefinitionEditor;
144
145
  /**
146
   * Called when the definition data is changed.
147
   */
148
  private final EventHandler<TreeModificationEvent<Event>> mTreeHandler =
149
    event -> {
150
      process( getActiveTextEditor() );
151
      save( getActiveTextDefinition() );
152
    };
153
154
  /**
155
   * Tracks the number of detached tab panels opened into their own windows,
156
   * which allows unique identification of subordinate windows by their title.
157
   * It is doubtful more than 128 windows, much less 256, will be created.
158
   */
159
  private byte mWindowCount;
160
161
  private final DocumentStatistics mStatistics;
162
163
  /**
164
   * Adds all content panels to the main user interface. This will load the
165
   * configuration settings from the workspace to reproduce the settings from
166
   * a previous session.
167
   */
168
  public MainPane( final Workspace workspace ) {
169
    mWorkspace = workspace;
170
    mPreview = new HtmlPreview( workspace );
171
    mStatistics = new DocumentStatistics( workspace );
172
    mActiveTextEditor.set( new MarkdownEditor( workspace ) );
173
    mActiveDefinitionEditor = createActiveDefinitionEditor( mActiveTextEditor );
174
175
    open( collect( getRecentFiles() ) );
176
    viewPreview();
177
    setDividerPositions( calculateDividerPositions() );
178
179
    // Once the main scene's window regains focus, update the active definition
180
    // editor to the currently selected tab.
181
    runLater( () -> getWindow().setOnCloseRequest( ( event ) -> {
182
      // Order matters here. We want to close all the tabs to ensure each
183
      // is saved, but after they are closed, the workspace should still
184
      // retain the list of files that were open. If this line came after
185
      // closing, then restarting the application would list no files.
186
      mWorkspace.save();
187
188
      if( closeAll() ) {
189
        Platform.exit();
190
        System.exit( 0 );
191
      }
192
      else {
193
        event.consume();
194
      }
195
    } ) );
196
197
    register( this );
198
    initAutosave( workspace );
199
  }
200
201
  @Subscribe
202
  public void handle( final TextEditorFocusEvent event ) {
203
    mActiveTextEditor.set( event.get() );
204
  }
205
206
  @Subscribe
207
  public void handle( final TextDefinitionFocusEvent event ) {
208
    mActiveDefinitionEditor.set( event.get() );
209
  }
210
211
  /**
212
   * Typically called when a file name is clicked in the preview panel.
213
   *
214
   * @param event The event to process, must contain a valid file reference.
215
   */
216
  @Subscribe
217
  public void handle( final FileOpenEvent event ) {
218
    final File eventFile;
219
    final var eventUri = event.getUri();
220
221
    if( eventUri.isAbsolute() ) {
222
      eventFile = new File( eventUri.getPath() );
223
    }
224
    else {
225
      final var activeFile = getActiveTextEditor().getFile();
226
      final var parent = activeFile.getParentFile();
227
228
      if( parent == null ) {
229
        clue( new FileNotFoundException( eventUri.getPath() ) );
230
        return;
231
      }
232
      else {
233
        final var parentPath = parent.getAbsolutePath();
234
        eventFile = Path.of( parentPath, eventUri.getPath() ).toFile();
235
      }
236
    }
237
238
    runLater( () -> open( eventFile ) );
239
  }
240
241
  @Subscribe
242
  public void handle( final CaretNavigationEvent event ) {
243
    runLater( () -> {
244
      final var textArea = getActiveTextEditor().getTextArea();
245
      textArea.moveTo( event.getOffset() );
246
      textArea.requestFollowCaret();
247
      textArea.requestFocus();
248
    } );
249
  }
250
251
  @Subscribe
252
  @SuppressWarnings( "unused" )
253
  public void handle( final ExportFailedEvent event ) {
254
    final var os = getProperty( "os.name" );
255
    final var arch = getProperty( "os.arch" ).toLowerCase();
256
    final var bits = getProperty( "sun.arch.data.model" );
257
258
    final var title = Messages.get( "Alert.typesetter.missing.title" );
259
    final var header = Messages.get( "Alert.typesetter.missing.header" );
260
    final var version = Messages.get(
261
      "Alert.typesetter.missing.version",
262
      os,
263
      arch
264
        .replaceAll( "amd.*|i.*|x86.*", "X86" )
265
        .replaceAll( "mips.*", "MIPS" )
266
        .replaceAll( "armv.*", "ARM" ),
267
      bits );
268
    final var text = Messages.get( "Alert.typesetter.missing.installer.text" );
269
270
    // Download and install ConTeXt for {0} {1} {2}-bit
271
    final var content = format( "%s %s", text, version );
272
    final var flowPane = new FlowPane();
273
    final var link = new Hyperlink( text );
274
    final var label = new Label( version );
275
    flowPane.getChildren().addAll( link, label );
276
277
    final var alert = new Alert( ERROR, content, OK );
278
    alert.setTitle( title );
279
    alert.setHeaderText( header );
280
    alert.getDialogPane().contentProperty().set( flowPane );
281
    alert.setGraphic( ICON_DIALOG_NODE );
282
283
    link.setOnAction( ( e ) -> {
284
      alert.close();
285
      final var url = Messages.get( "Alert.typesetter.missing.installer.url" );
286
      runLater( () -> HyperlinkOpenEvent.fire( url ) );
287
    } );
288
289
    alert.showAndWait();
290
  }
291
292
  private void initAutosave( final Workspace workspace ) {
293
    final var rate = workspace.integerProperty( KEY_EDITOR_AUTOSAVE );
294
295
    rate.addListener(
296
      ( c, o, n ) -> {
297
        final var taskRef = mSaveTask.get();
298
299
        // Prevent multiple autosaves from running.
300
        if( taskRef != null ) {
301
          taskRef.cancel( false );
302
        }
303
304
        initAutosave( rate );
305
      }
306
    );
307
308
    // Start the save listener (avoids duplicating some code).
309
    initAutosave( rate );
310
  }
311
312
  private void initAutosave( final IntegerProperty rate ) {
313
    mSaveTask.set(
314
      mSaver.scheduleAtFixedRate(
315
        () -> {
316
          if( getActiveTextEditor().isModified() ) {
317
            // Ensure the modified indicator is cleared by running on EDT.
318
            runLater( this::save );
319
          }
320
        }, 0, rate.intValue(), SECONDS
321
      )
322
    );
323
  }
324
325
  /**
326
   * TODO: Load divider positions from exported settings, see
327
   *   {@link #collect(SetProperty)} comment.
328
   */
329
  private double[] calculateDividerPositions() {
330
    final var ratio = 100f / getItems().size() / 100;
331
    final var positions = getDividerPositions();
332
333
    for( int i = 0; i < positions.length; i++ ) {
334
      positions[ i ] = ratio * i;
335
    }
336
337
    return positions;
338
  }
339
340
  /**
341
   * Opens all the files into the application, provided the paths are unique.
342
   * This may only be called for any type of files that a user can edit
343
   * (i.e., update and persist), such as definitions and text files.
344
   *
345
   * @param files The list of files to open.
346
   */
347
  public void open( final List<File> files ) {
348
    files.forEach( this::open );
349
  }
350
351
  /**
352
   * This opens the given file. Since the preview pane is not a file that
353
   * can be opened, it is safe to add a listener to the detachable pane.
354
   *
355
   * @param inputFile The file to open.
356
   */
357
  private void open( final File inputFile ) {
358
    final var tab = createTab( inputFile );
359
    final var node = tab.getContent();
360
    final var mediaType = MediaType.valueFrom( inputFile );
361
    final var tabPane = obtainTabPane( mediaType );
362
363
    tab.setTooltip( createTooltip( inputFile ) );
364
    tabPane.setFocusTraversable( false );
365
    tabPane.setTabClosingPolicy( ALL_TABS );
366
    tabPane.getTabs().add( tab );
367
368
    // Attach the tab scene factory for new tab panes.
369
    if( !getItems().contains( tabPane ) ) {
370
      addTabPane(
371
        node instanceof TextDefinition ? 0 : getItems().size(), tabPane
372
      );
373
    }
374
375
    getRecentFiles().add( inputFile.getAbsolutePath() );
376
  }
377
378
  /**
379
   * Opens a new text editor document using the default document file name.
380
   */
381
  public void newTextEditor() {
382
    open( DOCUMENT_DEFAULT );
383
  }
384
385
  /**
386
   * Opens a new definition editor document using the default definition
387
   * file name.
388
   */
389
  public void newDefinitionEditor() {
390
    open( DEFINITION_DEFAULT );
391
  }
392
393
  /**
394
   * Iterates over all tab panes to find all {@link TextEditor}s and request
395
   * that they save themselves.
396
   */
397
  public void saveAll() {
398
    mTabPanes.forEach(
399
      ( tp ) -> tp.getTabs().forEach( ( tab ) -> {
400
        final var node = tab.getContent();
401
        if( node instanceof final TextEditor editor ) {
402
          save( editor );
403
        }
404
      } )
405
    );
406
  }
407
408
  /**
409
   * Requests that the active {@link TextEditor} saves itself. Don't bother
410
   * checking if modified first because if the user swaps external media from
411
   * an external source (e.g., USB thumb drive), save should not second-guess
412
   * the user: save always re-saves. Also, it's less code.
413
   */
414
  public void save() {
415
    save( getActiveTextEditor() );
416
  }
417
418
  /**
419
   * Saves the active {@link TextEditor} under a new name.
420
   *
421
   * @param files The new active editor {@link File} reference, must contain
422
   *              at least one element.
423
   */
424
  public void saveAs( final List<File> files ) {
425
    assert files != null;
426
    assert !files.isEmpty();
427
    final var editor = getActiveTextEditor();
428
    final var tab = getTab( editor );
429
    final var file = files.get( 0 );
430
431
    editor.rename( file );
432
    tab.ifPresent( t -> {
433
      t.setText( editor.getFilename() );
434
      t.setTooltip( createTooltip( file ) );
435
    } );
436
437
    save();
438
  }
439
440
  /**
441
   * Saves the given {@link TextResource} to a file. This is typically used
442
   * to save either an instance of {@link TextEditor} or {@link TextDefinition}.
443
   *
444
   * @param resource The resource to export.
445
   */
446
  private void save( final TextResource resource ) {
447
    try {
448
      resource.save();
449
    } catch( final Exception ex ) {
450
      clue( ex );
451
      sNotifier.alert(
452
        getWindow(), resource.getPath(), "TextResource.saveFailed", ex
453
      );
454
    }
455
  }
456
457
  /**
458
   * Closes all open {@link TextEditor}s; all {@link TextDefinition}s stay open.
459
   *
460
   * @return {@code true} when all editors, modified or otherwise, were
461
   * permitted to close; {@code false} when one or more editors were modified
462
   * and the user requested no closing.
463
   */
464
  public boolean closeAll() {
465
    var closable = true;
466
467
    for( final var tabPane : mTabPanes ) {
468
      final var tabIterator = tabPane.getTabs().iterator();
469
470
      while( tabIterator.hasNext() ) {
471
        final var tab = tabIterator.next();
472
        final var resource = tab.getContent();
473
474
        // The definition panes auto-save, so being specific here prevents
475
        // closing the definitions in the situation where the user wants to
476
        // continue editing (i.e., possibly save unsaved work).
477
        if( !(resource instanceof TextEditor) ) {
478
          continue;
479
        }
480
481
        if( canClose( (TextEditor) resource ) ) {
482
          tabIterator.remove();
483
          close( tab );
484
        }
485
        else {
486
          closable = false;
487
        }
488
      }
489
    }
490
491
    return closable;
492
  }
493
494
  /**
495
   * Calls the tab's {@link Tab#getOnClosed()} handler to carry out a close
496
   * event.
497
   *
498
   * @param tab The {@link Tab} that was closed.
499
   */
500
  private void close( final Tab tab ) {
501
    assert tab != null;
502
503
    final var handler = tab.getOnClosed();
504
505
    if( handler != null ) {
506
      handler.handle( new ActionEvent() );
507
    }
508
  }
509
510
  /**
511
   * Closes the active tab; delegates to {@link #canClose(TextResource)}.
512
   */
513
  public void close() {
514
    final var editor = getActiveTextEditor();
515
516
    if( canClose( editor ) ) {
517
      close( editor );
518
    }
519
  }
520
521
  /**
522
   * Closes the given {@link TextResource}. This must not be called from within
523
   * a loop that iterates over the tab panes using {@code forEach}, lest a
524
   * concurrent modification exception be thrown.
525
   *
526
   * @param resource The {@link TextResource} to close, without confirming with
527
   *                 the user.
528
   */
529
  private void close( final TextResource resource ) {
530
    getTab( resource ).ifPresent(
531
      ( tab ) -> {
532
        close( tab );
533
        tab.getTabPane().getTabs().remove( tab );
534
      }
535
    );
536
  }
537
538
  /**
539
   * Answers whether the given {@link TextResource} may be closed.
540
   *
541
   * @param editor The {@link TextResource} to try closing.
542
   * @return {@code true} when the editor may be closed; {@code false} when
543
   * the user has requested to keep the editor open.
544
   */
545
  private boolean canClose( final TextResource editor ) {
546
    final var editorTab = getTab( editor );
547
    final var canClose = new AtomicBoolean( true );
548
549
    if( editor.isModified() ) {
550
      final var filename = new StringBuilder();
551
      editorTab.ifPresent( ( tab ) -> filename.append( tab.getText() ) );
552
553
      final var message = sNotifier.createNotification(
554
        Messages.get( "Alert.file.close.title" ),
555
        Messages.get( "Alert.file.close.text" ),
556
        filename.toString()
557
      );
558
559
      final var dialog = sNotifier.createConfirmation( getWindow(), message );
560
561
      dialog.showAndWait().ifPresent(
562
        save -> canClose.set( save == YES ? editor.save() : save == NO )
563
      );
564
    }
565
566
    return canClose.get();
567
  }
568
569
  private ObjectProperty<TextEditor> createActiveTextEditor() {
570
    final var editor = new SimpleObjectProperty<TextEditor>();
571
572
    editor.addListener( ( c, o, n ) -> {
573
      if( n != null ) {
574
        mPreview.setBaseUri( n.getPath() );
575
        process( n );
576
      }
577
    } );
578
579
    return editor;
580
  }
581
582
  /**
583
   * Adds the HTML preview tab to its own, singular tab pane.
584
   */
585
  public void viewPreview() {
586
    viewTab( mPreview, TEXT_HTML, "Pane.preview.title" );
587
  }
588
589
  /**
590
   * Adds the document outline tab to its own, singular tab pane.
591
   */
592
  public void viewOutline() {
593
    viewTab( mOutline, APP_DOCUMENT_OUTLINE, "Pane.outline.title" );
594
  }
595
596
  public void viewStatistics() {
597
    viewTab( mStatistics, APP_DOCUMENT_STATISTICS, "Pane.statistics.title" );
598
  }
599
600
  public void viewFiles() {
601
    try {
602
      final var factory = new FilePickerFactory( getWorkspace() );
603
      final var fileManager = factory.createModeless();
604
      viewTab( fileManager, APP_FILE_MANAGER, "Pane.files.title" );
605
    } catch( final Exception ex ) {
606
      clue( ex );
607
    }
608
  }
609
610
  private void viewTab(
611
    final Node node, final MediaType mediaType, final String key ) {
612
    final var tabPane = obtainTabPane( mediaType );
613
614
    for( final var tab : tabPane.getTabs() ) {
615
      if( tab.getContent() == node ) {
616
        return;
617
      }
618
    }
619
620
    tabPane.getTabs().add( createTab( get( key ), node ) );
621
    addTabPane( tabPane );
622
  }
623
624
  public void viewRefresh() {
625
    mPreview.refresh();
626
  }
627
628
  /**
629
   * Returns the tab that contains the given {@link TextEditor}.
630
   *
631
   * @param editor The {@link TextEditor} instance to find amongst the tabs.
632
   * @return The first tab having content that matches the given tab.
633
   */
634
  private Optional<Tab> getTab( final TextResource editor ) {
635
    return mTabPanes.stream()
636
                    .flatMap( pane -> pane.getTabs().stream() )
637
                    .filter( tab -> editor.equals( tab.getContent() ) )
638
                    .findFirst();
639
  }
640
641
  /**
642
   * Creates a new {@link DefinitionEditor} wrapped in a listener that
643
   * is used to detect when the active {@link DefinitionEditor} has changed.
644
   * Upon changing, the variables are interpolated and the active text editor
645
   * is refreshed.
646
   *
647
   * @param textEditor Text editor to update with the revised resolved map.
648
   * @return A newly configured property that represents the active
649
   * {@link DefinitionEditor}, never null.
650
   */
651
  private ObjectProperty<TextDefinition> createActiveDefinitionEditor(
652
    final ObjectProperty<TextEditor> textEditor ) {
653
    final var defEditor = new SimpleObjectProperty<>(
654
      createDefinitionEditor()
655
    );
656
657
    defEditor.addListener( ( c, o, n ) -> process( textEditor.get() ) );
658
659
    return defEditor;
660
  }
661
662
  private Tab createTab( final String filename, final Node node ) {
663
    return new DetachableTab( filename, node );
664
  }
665
666
  private Tab createTab( final File file ) {
667
    final var r = createTextResource( file );
668
    final var tab = createTab( r.getFilename(), r.getNode() );
669
670
    r.modifiedProperty().addListener(
671
      ( c, o, n ) -> tab.setText( r.getFilename() + (n ? "*" : "") )
672
    );
673
674
    // This is called when either the tab is closed by the user clicking on
675
    // the tab's close icon or when closing (all) from the file menu.
676
    tab.setOnClosed(
677
      ( __ ) -> getRecentFiles().remove( file.getAbsolutePath() )
678
    );
679
680
    // When closing a tab, give focus to the newly revealed tab.
681
    tab.selectedProperty().addListener( ( c, o, n ) -> {
682
      if( n != null && n ) {
683
        final var pane = tab.getTabPane();
684
685
        if( pane != null ) {
686
          pane.requestFocus();
687
        }
688
      }
689
    } );
690
691
    tab.tabPaneProperty().addListener( ( cPane, oPane, nPane ) -> {
692
      if( nPane != null ) {
693
        nPane.focusedProperty().addListener( ( c, o, n ) -> {
694
          if( n != null && n ) {
695
            final var selected = nPane.getSelectionModel().getSelectedItem();
696
            final var node = selected.getContent();
697
            node.requestFocus();
698
          }
699
        } );
700
      }
701
    } );
702
703
    return tab;
704
  }
705
706
  /**
707
   * Creates bins for the different {@link MediaType}s, which eventually are
708
   * added to the UI as separate tab panes. If ever a general-purpose scene
709
   * exporter is developed to serialize a scene to an FXML file, this could
710
   * be replaced by such a class.
711
   * <p>
712
   * When binning the files, this makes sure that at least one file exists
713
   * for every type. If the user has opted to close a particular type (such
714
   * as the definition pane), the view will suppressed elsewhere.
715
   * </p>
716
   * <p>
717
   * The order that the binned files are returned will be reflected in the
718
   * order that the corresponding panes are rendered in the UI.
719
   * </p>
720
   *
721
   * @param paths The file paths to bin according to their type.
722
   * @return An in-order list of files, first by structured definition files,
723
   * then by plain text documents.
724
   */
725
  private List<File> collect( final SetProperty<String> paths ) {
726
    // Treat all files destined for the text editor as plain text documents
727
    // so that they are added to the same pane. Grouping by TEXT_PLAIN is a
728
    // bit arbitrary, but means explicitly capturing TEXT_PLAIN isn't needed.
729
    final Function<MediaType, MediaType> bin =
730
      m -> PLAIN_TEXT_FORMAT.contains( m ) ? TEXT_PLAIN : m;
731
732
    // Create two groups: YAML files and plain text files.
733
    final var bins = paths
734
      .stream()
735
      .collect(
736
        groupingBy( path -> bin.apply( MediaType.fromFilename( path ) ) )
737
      );
738
739
    bins.putIfAbsent( TEXT_YAML, List.of( DEFINITION_DEFAULT.toString() ) );
740
    bins.putIfAbsent( TEXT_PLAIN, List.of( DOCUMENT_DEFAULT.toString() ) );
741
742
    final var result = new ArrayList<File>( paths.size() );
743
744
    // Ensure that the same types are listed together (keep insertion order).
745
    bins.forEach( ( mediaType, files ) -> result.addAll(
746
      files.stream().map( File::new ).collect( Collectors.toList() ) )
747
    );
748
749
    return result;
750
  }
751
752
  /**
753
   * Force the active editor to update, which will cause the processor
754
   * to re-evaluate the interpolated definition map thereby updating the
755
   * preview pane.
756
   *
757
   * @param editor Contains the source document to update in the preview pane.
758
   */
759
  private void process( final TextEditor editor ) {
760
    // Ensure processing does not run on the JavaFX thread, which frees the
761
    // text editor immediately for caret movement. The preview will have a
762
    // slight delay when catching up to the caret position.
763
    final var task = new Task<Void>() {
764
      @Override
765
      public Void call() {
766
        try {
767
          final var p = mProcessors.getOrDefault( editor, IDENTITY );
768
          p.apply( editor == null ? "" : editor.getText() );
769
        } catch( final Exception ex ) {
770
          clue( ex );
771
        }
772
773
        return null;
774
      }
775
    };
776
777
    task.setOnSucceeded(
778
      e -> invokeLater( () -> mPreview.scrollTo( CARET_ID ) )
779
    );
780
781
    // Prevents multiple process requests from executing simultaneously (due
782
    // to having a restricted queue size).
783
    sExecutor.execute( task );
784
  }
785
786
  /**
787
   * Lazily creates a {@link TabPane} configured to listen for tab select
788
   * events. The tab pane is associated with a given media type so that
789
   * similar files can be grouped together.
790
   *
791
   * @param mediaType The media type to associate with the tab pane.
792
   * @return An instance of {@link TabPane} that will handle tab docking.
793
   */
794
  private TabPane obtainTabPane( final MediaType mediaType ) {
795
    for( final var pane : mTabPanes ) {
796
      for( final var tab : pane.getTabs() ) {
797
        final var node = tab.getContent();
798
799
        if( node instanceof TextResource r && r.supports( mediaType ) ) {
800
          return pane;
801
        }
802
      }
803
    }
804
805
    final var pane = createTabPane();
806
    mTabPanes.add( pane );
807
    return pane;
808
  }
809
810
  /**
811
   * Creates an initialized {@link TabPane} instance.
812
   *
813
   * @return A new {@link TabPane} with all listeners configured.
814
   */
815
  private TabPane createTabPane() {
816
    final var tabPane = new DetachableTabPane();
817
818
    initStageOwnerFactory( tabPane );
819
    initTabListener( tabPane );
820
821
    return tabPane;
822
  }
823
824
  /**
825
   * When any {@link DetachableTabPane} is detached from the main window,
826
   * the stage owner factory must be given its parent window, which will
827
   * own the child window. The parent window is the {@link MainPane}'s
828
   * {@link Scene}'s {@link Window} instance.
829
   *
830
   * <p>
831
   * This will derives the new title from the main window title, incrementing
832
   * the window count to help uniquely identify the child windows.
833
   * </p>
834
   *
835
   * @param tabPane A new {@link DetachableTabPane} to configure.
836
   */
837
  private void initStageOwnerFactory( final DetachableTabPane tabPane ) {
838
    tabPane.setStageOwnerFactory( ( stage ) -> {
839
      final var title = get(
840
        "Detach.tab.title",
841
        ((Stage) getWindow()).getTitle(), ++mWindowCount
842
      );
843
      stage.setTitle( title );
844
845
      return getScene().getWindow();
846
    } );
847
  }
848
849
  /**
850
   * Responsible for configuring the content of each {@link DetachableTab} when
851
   * it is added to the given {@link DetachableTabPane} instance.
852
   * <p>
853
   * For {@link TextEditor} contents, an instance of {@link ScrollEventHandler}
854
   * is initialized to perform synchronized scrolling between the editor and
855
   * its preview window. Additionally, the last tab in the tab pane's list of
856
   * tabs is given focus.
857
   * </p>
858
   * <p>
859
   * Note that multiple tabs can be added simultaneously.
860
   * </p>
861
   *
862
   * @param tabPane A new {@link TabPane} to configure.
863
   */
864
  private void initTabListener( final TabPane tabPane ) {
865
    tabPane.getTabs().addListener(
866
      ( final ListChangeListener.Change<? extends Tab> listener ) -> {
867
        while( listener.next() ) {
868
          if( listener.wasAdded() ) {
869
            final var tabs = listener.getAddedSubList();
870
871
            tabs.forEach( ( tab ) -> {
872
              final var node = tab.getContent();
873
874
              if( node instanceof TextEditor ) {
875
                initScrollEventListener( tab );
876
              }
877
            } );
878
879
            // Select and give focus to the last tab opened.
880
            final var index = tabs.size() - 1;
881
            if( index >= 0 ) {
882
              final var tab = tabs.get( index );
883
              tabPane.getSelectionModel().select( tab );
884
              tab.getContent().requestFocus();
885
            }
886
          }
887
        }
888
      }
889
    );
890
  }
891
892
  /**
893
   * Synchronizes scrollbar positions between the given {@link Tab} that
894
   * contains an instance of {@link TextEditor} and {@link HtmlPreview} pane.
895
   *
896
   * @param tab The container for an instance of {@link TextEditor}.
897
   */
898
  private void initScrollEventListener( final Tab tab ) {
899
    final var editor = (TextEditor) tab.getContent();
900
    final var scrollPane = editor.getScrollPane();
901
    final var scrollBar = mPreview.getVerticalScrollBar();
902
    final var handler = new ScrollEventHandler( scrollPane, scrollBar );
903
    handler.enabledProperty().bind( tab.selectedProperty() );
904
  }
905
906
  private void addTabPane( final int index, final TabPane tabPane ) {
907
    final var items = getItems();
908
    if( !items.contains( tabPane ) ) {
909
      items.add( index, tabPane );
910
    }
911
  }
912
913
  private void addTabPane( final TabPane tabPane ) {
914
    addTabPane( getItems().size(), tabPane );
915
  }
916
917
  public ProcessorContext createProcessorContext() {
918
    return createProcessorContext( null, NONE );
919
  }
920
921
  public ProcessorContext createProcessorContext(
922
    final Path exportPath, final ExportFormat format ) {
923
    final var textEditor = getActiveTextEditor();
924
    return createProcessorContext(
925
      textEditor.getPath(), exportPath, format, textEditor.getCaret() );
926
  }
927
928
  private ProcessorContext createProcessorContext(
929
    final Path inputPath, final Caret caret ) {
930
    return createProcessorContext( inputPath, null, NONE, caret );
931
  }
932
933
  /**
934
   * @param inputPath  Used by {@link ProcessorFactory} to determine
935
   *                   {@link Processor} type to create based on file type.
936
   * @param outputPath Used when exporting to a PDF file (binary).
937
   * @param format     Used when processors export to a new text format.
938
   * @param caret      Used by {@link CaretExtension} to add ID attribute into
939
   *                   preview document for scrollbar synchronization.
940
   * @return A new {@link ProcessorContext} to use when creating an instance of
941
   * {@link Processor}.
942
   */
943
  private ProcessorContext createProcessorContext(
944
    final Path inputPath,
945
    final Path outputPath,
946
    final ExportFormat format,
947
    final Caret caret ) {
948
    return ProcessorContext.builder()
949
      .with( ProcessorContext.Mutator::setInputPath, inputPath )
950
      .with( ProcessorContext.Mutator::setOutputPath, outputPath )
951
      .with( ProcessorContext.Mutator::setExportFormat, format )
952
      .with( ProcessorContext.Mutator::setHtmlPreview, mPreview )
953
      .with( ProcessorContext.Mutator::setTextDefinition, mActiveDefinitionEditor )
954
      .with( ProcessorContext.Mutator::setWorkspace, mWorkspace )
955
      .with( ProcessorContext.Mutator::setCaret, caret )
956
      .build();
957
  }
958
959
  private TextResource createTextResource( final File file ) {
960
    // TODO: Create PlainTextEditor that's returned by default.
961
    return MediaType.valueFrom( file ) == TEXT_YAML
962
      ? createDefinitionEditor( file )
963
      : createMarkdownEditor( file );
964
  }
965
966
  /**
967
   * Creates an instance of {@link MarkdownEditor} that listens for both
968
   * caret change events and text change events. Text change events must
969
   * take priority over caret change events because it's possible to change
970
   * the text without moving the caret (e.g., delete selected text).
971
   *
972
   * @param inputFile The file containing contents for the text editor.
973
   * @return A non-null text editor.
974
   */
975
  private TextResource createMarkdownEditor( final File inputFile ) {
976
    final var inputPath = inputFile.toPath();
977
    final var editor = new MarkdownEditor( inputFile, getWorkspace() );
978
    final var caret = editor.getCaret();
979
    final var context = createProcessorContext( inputPath, caret );
980
981
    mProcessors.computeIfAbsent( editor, p -> createProcessors( context ) );
982
983
    editor.addDirtyListener( ( c, o, n ) -> {
984
      if( n ) {
985
        // Reset the status to OK after changing the text.
986
        clue();
987
988
        // Processing the text may update the status bar.
989
        process( getActiveTextEditor() );
990
      }
991
    } );
992
993
    editor.addEventListener(
994
      keyPressed( SPACE, CONTROL_DOWN ), this::autoinsert
995
    );
996
997
    // Set the active editor, which refreshes the preview panel.
998
    mActiveTextEditor.set( editor );
999
1000
    return editor;
1001
  }
1002
1003
  /**
1004
   * Delegates to {@link #autoinsert()}.
1005
   *
1006
   * @param event Ignored.
1007
   */
1008
  private void autoinsert( final KeyEvent event ) {
1009
    autoinsert();
1010
  }
1011
1012
  /**
1013
   * Finds a node that matches the word at the caret, then inserts the
1014
   * corresponding definition. The definition token delimiters depend on
1015
   * the type of file being edited.
1016
   */
1017
  public void autoinsert() {
1018
    final var definitions = getActiveTextDefinition();
1019
    final var editor = getActiveTextEditor();
1020
    final var mediaType = editor.getMediaType();
1021
    final var operator = getSigilOperator( mediaType );
1022
1023
    DefinitionNameInjector.autoinsert( editor, definitions, operator );
1024
  }
1025
1026
  private TextDefinition createDefinitionEditor() {
1027
    return createDefinitionEditor( DEFINITION_DEFAULT );
1028
  }
1029
1030
  private TextDefinition createDefinitionEditor( final File file ) {
1031
    final var editor = new DefinitionEditor(
1032
      file, createTreeTransformer(), createYamlSigilOperator() );
1033
    editor.addTreeChangeHandler( mTreeHandler );
1034
    return editor;
1035
  }
1036
1037
  private TreeTransformer createTreeTransformer() {
1038
    return new YamlTreeTransformer();
1039
  }
1040
1041
  private Tooltip createTooltip( final File file ) {
1042
    final var path = file.toPath();
1043
    final var tooltip = new Tooltip( path.toString() );
1044
1045
    tooltip.setShowDelay( millis( 200 ) );
1046
    return tooltip;
1047
  }
1048
1049
  public TextEditor getActiveTextEditor() {
1050
    return mActiveTextEditor.get();
1051
  }
1052
1053
  public ReadOnlyObjectProperty<TextEditor> activeTextEditorProperty() {
1054
    return mActiveTextEditor;
1055
  }
1056
1057
  public TextDefinition getActiveTextDefinition() {
1058
    return mActiveDefinitionEditor.get();
1059
  }
1060
1061
  public Window getWindow() {
1062
    return getScene().getWindow();
1063
  }
1064
1065
  public Workspace getWorkspace() {
1066
    return mWorkspace;
1067
  }
1068
1069
  /**
1070
   * Returns the sigil operator for the given {@link MediaType}.
1071
   *
1072
   * @param mediaType The type of file being edited.
1073
   */
1074
  private SigilOperator getSigilOperator( final MediaType mediaType ) {
1075
    final var operator = new YamlSigilOperator( createDefinitionSigils() );
1076
1077
    return mediaType == TEXT_R_MARKDOWN
1078
      ? new RSigilOperator( createRSigils(), operator )
1079
      : operator;
1080
  }
1081
1082
  /**
1083
   * Returns the set of file names opened in the application. The names must
1084
   * be converted to {@link File} objects.
1085
   *
1086
   * @return A {@link Set} of file names.
1087
   */
1088
  private SetProperty<String> getRecentFiles() {
1089
    return getWorkspace().setsProperty( KEY_UI_FILES_PATH );
1090
  }
1091
1092
  private StringProperty stringProperty( final Key key ) {
1093
    return getWorkspace().stringProperty( key );
1094
  }
1095
1096
  private SigilOperator createYamlSigilOperator() {
1097
    return new YamlSigilOperator( createDefinitionSigils() );
1098
  }
1099
1100
  private Sigils createRSigils() {
1101
    return createSigils( KEY_R_DELIM_BEGAN, KEY_R_DELIM_ENDED );
1102
  }
1103
1104
  private Sigils createDefinitionSigils() {
1105
    return createSigils( KEY_DEF_DELIM_BEGAN, KEY_DEF_DELIM_ENDED );
1106
  }
1107
1108
  private Sigils createSigils( final Key began, final Key ended ) {
1109
    return new Sigils( stringProperty( began ), stringProperty( ended ) );
16
import com.keenwrite.processors.HtmlPreviewProcessor;
17
import com.keenwrite.processors.Processor;
18
import com.keenwrite.processors.ProcessorContext;
19
import com.keenwrite.processors.ProcessorFactory;
20
import com.keenwrite.processors.r.InlineRProcessor;
21
import com.keenwrite.service.events.Notifier;
22
import com.keenwrite.sigils.PropertyKeyOperator;
23
import com.keenwrite.sigils.RKeyOperator;
24
import com.keenwrite.ui.explorer.FilePickerFactory;
25
import com.keenwrite.ui.heuristics.DocumentStatistics;
26
import com.keenwrite.ui.outline.DocumentOutline;
27
import com.keenwrite.util.GenericBuilder;
28
import com.panemu.tiwulfx.control.dock.DetachableTab;
29
import com.panemu.tiwulfx.control.dock.DetachableTabPane;
30
import javafx.application.Platform;
31
import javafx.beans.property.*;
32
import javafx.collections.ListChangeListener;
33
import javafx.concurrent.Task;
34
import javafx.event.ActionEvent;
35
import javafx.event.Event;
36
import javafx.event.EventHandler;
37
import javafx.scene.Node;
38
import javafx.scene.Scene;
39
import javafx.scene.control.*;
40
import javafx.scene.control.TreeItem.TreeModificationEvent;
41
import javafx.scene.input.KeyEvent;
42
import javafx.scene.layout.FlowPane;
43
import javafx.stage.Stage;
44
import javafx.stage.Window;
45
import org.greenrobot.eventbus.Subscribe;
46
47
import java.io.File;
48
import java.io.FileNotFoundException;
49
import java.nio.file.Path;
50
import java.util.*;
51
import java.util.concurrent.ExecutorService;
52
import java.util.concurrent.ScheduledExecutorService;
53
import java.util.concurrent.ScheduledFuture;
54
import java.util.concurrent.atomic.AtomicBoolean;
55
import java.util.concurrent.atomic.AtomicReference;
56
import java.util.function.Function;
57
import java.util.function.UnaryOperator;
58
import java.util.stream.Collectors;
59
60
import static com.keenwrite.ExportFormat.NONE;
61
import static com.keenwrite.Messages.get;
62
import static com.keenwrite.constants.Constants.*;
63
import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG_NODE;
64
import static com.keenwrite.events.Bus.register;
65
import static com.keenwrite.events.StatusEvent.clue;
66
import static com.keenwrite.io.MediaType.*;
67
import static com.keenwrite.preferences.AppKeys.*;
68
import static com.keenwrite.processors.IdentityProcessor.IDENTITY;
69
import static com.keenwrite.processors.ProcessorContext.Mutator;
70
import static com.keenwrite.processors.ProcessorContext.builder;
71
import static com.keenwrite.processors.ProcessorFactory.createProcessors;
72
import static java.lang.String.format;
73
import static java.lang.System.getProperty;
74
import static java.util.concurrent.Executors.newFixedThreadPool;
75
import static java.util.concurrent.Executors.newScheduledThreadPool;
76
import static java.util.concurrent.TimeUnit.SECONDS;
77
import static java.util.stream.Collectors.groupingBy;
78
import static javafx.application.Platform.runLater;
79
import static javafx.scene.control.Alert.AlertType.ERROR;
80
import static javafx.scene.control.ButtonType.*;
81
import static javafx.scene.control.TabPane.TabClosingPolicy.ALL_TABS;
82
import static javafx.scene.input.KeyCode.SPACE;
83
import static javafx.scene.input.KeyCombination.CONTROL_DOWN;
84
import static javafx.util.Duration.millis;
85
import static javax.swing.SwingUtilities.invokeLater;
86
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
87
88
/**
89
 * Responsible for wiring together the main application components for a
90
 * particular {@link Workspace} (project). These include the definition views,
91
 * text editors, and preview pane along with any corresponding controllers.
92
 */
93
public final class MainPane extends SplitPane {
94
95
  private static final ExecutorService sExecutor = newFixedThreadPool( 1 );
96
  private static final Notifier sNotifier = Services.load( Notifier.class );
97
98
  /**
99
   * Used when opening files to determine how each file should be binned and
100
   * therefore what tab pane to be opened within.
101
   */
102
  private static final Set<MediaType> PLAIN_TEXT_FORMAT = Set.of(
103
    TEXT_MARKDOWN, TEXT_R_MARKDOWN, UNDEFINED
104
  );
105
106
  private final ScheduledExecutorService mSaver = newScheduledThreadPool( 1 );
107
  private final AtomicReference<ScheduledFuture<?>> mSaveTask =
108
    new AtomicReference<>();
109
110
  /**
111
   * Prevents re-instantiation of processing classes.
112
   */
113
  private final Map<TextResource, Processor<String>> mProcessors =
114
    new HashMap<>();
115
116
  private final Workspace mWorkspace;
117
118
  /**
119
   * Groups similar file type tabs together.
120
   */
121
  private final List<TabPane> mTabPanes = new ArrayList<>();
122
123
  /**
124
   * Renders the actively selected plain text editor tab.
125
   */
126
  private final HtmlPreview mPreview;
127
128
  /**
129
   * Provides an interactive document outline.
130
   */
131
  private final DocumentOutline mOutline = new DocumentOutline();
132
133
  /**
134
   * Changing the active editor fires the value changed event. This allows
135
   * refreshes to happen when external definitions are modified and need to
136
   * trigger the processing chain.
137
   */
138
  private final ObjectProperty<TextEditor> mTextEditor =
139
    createActiveTextEditor();
140
141
  /**
142
   * Changing the active definition editor fires the value changed event. This
143
   * allows refreshes to happen when external definitions are modified and need
144
   * to trigger the processing chain.
145
   */
146
  private final ObjectProperty<TextDefinition> mDefinitionEditor;
147
148
  /**
149
   * Called when the definition data is changed.
150
   */
151
  private final EventHandler<TreeModificationEvent<Event>> mTreeHandler =
152
    event -> {
153
      process( getTextEditor() );
154
      save( getTextDefinition() );
155
    };
156
157
  /**
158
   * Tracks the number of detached tab panels opened into their own windows,
159
   * which allows unique identification of subordinate windows by their title.
160
   * It is doubtful more than 128 windows, much less 256, will be created.
161
   */
162
  private byte mWindowCount;
163
164
  private final DocumentStatistics mStatistics;
165
166
  /**
167
   * Adds all content panels to the main user interface. This will load the
168
   * configuration settings from the workspace to reproduce the settings from
169
   * a previous session.
170
   */
171
  public MainPane( final Workspace workspace ) {
172
    mWorkspace = workspace;
173
    mPreview = new HtmlPreview( workspace );
174
    mStatistics = new DocumentStatistics( workspace );
175
    mTextEditor.set( new MarkdownEditor( workspace ) );
176
    mDefinitionEditor = createActiveDefinitionEditor( mTextEditor );
177
178
    open( collect( getRecentFiles() ) );
179
    viewPreview();
180
    setDividerPositions( calculateDividerPositions() );
181
182
    // Once the main scene's window regains focus, update the active definition
183
    // editor to the currently selected tab.
184
    runLater( () -> getWindow().setOnCloseRequest( event -> {
185
      // Order matters: Open file names must be persisted before closing all.
186
      mWorkspace.save();
187
188
      if( closeAll() ) {
189
        Platform.exit();
190
        System.exit( 0 );
191
      }
192
193
      event.consume();
194
    } ) );
195
196
    register( this );
197
    initAutosave( workspace );
198
  }
199
200
  @Subscribe
201
  public void handle( final TextEditorFocusEvent event ) {
202
    mTextEditor.set( event.get() );
203
  }
204
205
  @Subscribe
206
  public void handle( final TextDefinitionFocusEvent event ) {
207
    mDefinitionEditor.set( event.get() );
208
  }
209
210
  /**
211
   * Typically called when a file name is clicked in the preview panel.
212
   *
213
   * @param event The event to process, must contain a valid file reference.
214
   */
215
  @Subscribe
216
  public void handle( final FileOpenEvent event ) {
217
    final File eventFile;
218
    final var eventUri = event.getUri();
219
220
    if( eventUri.isAbsolute() ) {
221
      eventFile = new File( eventUri.getPath() );
222
    }
223
    else {
224
      final var activeFile = getTextEditor().getFile();
225
      final var parent = activeFile.getParentFile();
226
227
      if( parent == null ) {
228
        clue( new FileNotFoundException( eventUri.getPath() ) );
229
        return;
230
      }
231
      else {
232
        final var parentPath = parent.getAbsolutePath();
233
        eventFile = Path.of( parentPath, eventUri.getPath() ).toFile();
234
      }
235
    }
236
237
    runLater( () -> open( eventFile ) );
238
  }
239
240
  @Subscribe
241
  public void handle( final CaretNavigationEvent event ) {
242
    runLater( () -> {
243
      final var textArea = getTextEditor().getTextArea();
244
      textArea.moveTo( event.getOffset() );
245
      textArea.requestFollowCaret();
246
      textArea.requestFocus();
247
    } );
248
  }
249
250
  @Subscribe
251
  @SuppressWarnings( "unused" )
252
  public void handle( final ExportFailedEvent event ) {
253
    final var os = getProperty( "os.name" );
254
    final var arch = getProperty( "os.arch" ).toLowerCase();
255
    final var bits = getProperty( "sun.arch.data.model" );
256
257
    final var title = Messages.get( "Alert.typesetter.missing.title" );
258
    final var header = Messages.get( "Alert.typesetter.missing.header" );
259
    final var version = Messages.get(
260
      "Alert.typesetter.missing.version",
261
      os,
262
      arch
263
        .replaceAll( "amd.*|i.*|x86.*", "X86" )
264
        .replaceAll( "mips.*", "MIPS" )
265
        .replaceAll( "armv.*", "ARM" ),
266
      bits );
267
    final var text = Messages.get( "Alert.typesetter.missing.installer.text" );
268
269
    // Download and install ConTeXt for {0} {1} {2}-bit
270
    final var content = format( "%s %s", text, version );
271
    final var flowPane = new FlowPane();
272
    final var link = new Hyperlink( text );
273
    final var label = new Label( version );
274
    flowPane.getChildren().addAll( link, label );
275
276
    final var alert = new Alert( ERROR, content, OK );
277
    alert.setTitle( title );
278
    alert.setHeaderText( header );
279
    alert.getDialogPane().contentProperty().set( flowPane );
280
    alert.setGraphic( ICON_DIALOG_NODE );
281
282
    link.setOnAction( ( e ) -> {
283
      alert.close();
284
      final var url = Messages.get( "Alert.typesetter.missing.installer.url" );
285
      runLater( () -> HyperlinkOpenEvent.fire( url ) );
286
    } );
287
288
    alert.showAndWait();
289
  }
290
291
  private void initAutosave( final Workspace workspace ) {
292
    final var rate = workspace.integerProperty( KEY_EDITOR_AUTOSAVE );
293
294
    rate.addListener(
295
      ( c, o, n ) -> {
296
        final var taskRef = mSaveTask.get();
297
298
        // Prevent multiple autosaves from running.
299
        if( taskRef != null ) {
300
          taskRef.cancel( false );
301
        }
302
303
        initAutosave( rate );
304
      }
305
    );
306
307
    // Start the save listener (avoids duplicating some code).
308
    initAutosave( rate );
309
  }
310
311
  private void initAutosave( final IntegerProperty rate ) {
312
    mSaveTask.set(
313
      mSaver.scheduleAtFixedRate(
314
        () -> {
315
          if( getTextEditor().isModified() ) {
316
            // Ensure the modified indicator is cleared by running on EDT.
317
            runLater( this::save );
318
          }
319
        }, 0, rate.intValue(), SECONDS
320
      )
321
    );
322
  }
323
324
  /**
325
   * TODO: Load divider positions from exported settings, see
326
   *   {@link #collect(SetProperty)} comment.
327
   */
328
  private double[] calculateDividerPositions() {
329
    final var ratio = 100f / getItems().size() / 100;
330
    final var positions = getDividerPositions();
331
332
    for( int i = 0; i < positions.length; i++ ) {
333
      positions[ i ] = ratio * i;
334
    }
335
336
    return positions;
337
  }
338
339
  /**
340
   * Opens all the files into the application, provided the paths are unique.
341
   * This may only be called for any type of files that a user can edit
342
   * (i.e., update and persist), such as definitions and text files.
343
   *
344
   * @param files The list of files to open.
345
   */
346
  public void open( final List<File> files ) {
347
    files.forEach( this::open );
348
  }
349
350
  /**
351
   * This opens the given file. Since the preview pane is not a file that
352
   * can be opened, it is safe to add a listener to the detachable pane.
353
   * This will exit early if the given file is not a regular file (i.e., a
354
   * directory).
355
   *
356
   * @param inputFile The file to open.
357
   */
358
  private void open( final File inputFile ) {
359
    // Prevent opening directories (a non-existent "untitled.md" is fine).
360
    if( !inputFile.isFile() && inputFile.exists() ) {
361
      return;
362
    }
363
364
    final var tab = createTab( inputFile );
365
    final var node = tab.getContent();
366
    final var mediaType = MediaType.valueFrom( inputFile );
367
    final var tabPane = obtainTabPane( mediaType );
368
369
    tab.setTooltip( createTooltip( inputFile ) );
370
    tabPane.setFocusTraversable( false );
371
    tabPane.setTabClosingPolicy( ALL_TABS );
372
    tabPane.getTabs().add( tab );
373
374
    // Attach the tab scene factory for new tab panes.
375
    if( !getItems().contains( tabPane ) ) {
376
      addTabPane(
377
        node instanceof TextDefinition ? 0 : getItems().size(), tabPane
378
      );
379
    }
380
381
    if( inputFile.isFile() ) {
382
      getRecentFiles().add( inputFile.getAbsolutePath() );
383
    }
384
  }
385
386
  /**
387
   * Opens a new text editor document using the default document file name.
388
   */
389
  public void newTextEditor() {
390
    open( DOCUMENT_DEFAULT );
391
  }
392
393
  /**
394
   * Opens a new definition editor document using the default definition
395
   * file name.
396
   */
397
  public void newDefinitionEditor() {
398
    open( DEFINITION_DEFAULT );
399
  }
400
401
  /**
402
   * Iterates over all tab panes to find all {@link TextEditor}s and request
403
   * that they save themselves.
404
   */
405
  public void saveAll() {
406
    mTabPanes.forEach(
407
      tp -> tp.getTabs().forEach( tab -> {
408
        final var node = tab.getContent();
409
410
        if( node instanceof final TextEditor editor ) {
411
          save( editor );
412
        }
413
      } )
414
    );
415
  }
416
417
  /**
418
   * Requests that the active {@link TextEditor} saves itself. Don't bother
419
   * checking if modified first because if the user swaps external media from
420
   * an external source (e.g., USB thumb drive), save should not second-guess
421
   * the user: save always re-saves. Also, it's less code.
422
   */
423
  public void save() {
424
    save( getTextEditor() );
425
  }
426
427
  /**
428
   * Saves the active {@link TextEditor} under a new name.
429
   *
430
   * @param files The new active editor {@link File} reference, must contain
431
   *              at least one element.
432
   */
433
  public void saveAs( final List<File> files ) {
434
    assert files != null;
435
    assert !files.isEmpty();
436
    final var editor = getTextEditor();
437
    final var tab = getTab( editor );
438
    final var file = files.get( 0 );
439
440
    editor.rename( file );
441
    tab.ifPresent( t -> {
442
      t.setText( editor.getFilename() );
443
      t.setTooltip( createTooltip( file ) );
444
    } );
445
446
    save();
447
  }
448
449
  /**
450
   * Saves the given {@link TextResource} to a file. This is typically used
451
   * to save either an instance of {@link TextEditor} or {@link TextDefinition}.
452
   *
453
   * @param resource The resource to export.
454
   */
455
  private void save( final TextResource resource ) {
456
    try {
457
      resource.save();
458
    } catch( final Exception ex ) {
459
      clue( ex );
460
      sNotifier.alert(
461
        getWindow(), resource.getPath(), "TextResource.saveFailed", ex
462
      );
463
    }
464
  }
465
466
  /**
467
   * Closes all open {@link TextEditor}s; all {@link TextDefinition}s stay open.
468
   *
469
   * @return {@code true} when all editors, modified or otherwise, were
470
   * permitted to close; {@code false} when one or more editors were modified
471
   * and the user requested no closing.
472
   */
473
  public boolean closeAll() {
474
    var closable = true;
475
476
    for( final var tabPane : mTabPanes ) {
477
      final var tabIterator = tabPane.getTabs().iterator();
478
479
      while( tabIterator.hasNext() ) {
480
        final var tab = tabIterator.next();
481
        final var resource = tab.getContent();
482
483
        // The definition panes auto-save, so being specific here prevents
484
        // closing the definitions in the situation where the user wants to
485
        // continue editing (i.e., possibly save unsaved work).
486
        if( !(resource instanceof TextEditor) ) {
487
          continue;
488
        }
489
490
        if( canClose( (TextEditor) resource ) ) {
491
          tabIterator.remove();
492
          close( tab );
493
        }
494
        else {
495
          closable = false;
496
        }
497
      }
498
    }
499
500
    return closable;
501
  }
502
503
  /**
504
   * Calls the tab's {@link Tab#getOnClosed()} handler to carry out a close
505
   * event.
506
   *
507
   * @param tab The {@link Tab} that was closed.
508
   */
509
  private void close( final Tab tab ) {
510
    assert tab != null;
511
512
    final var handler = tab.getOnClosed();
513
514
    if( handler != null ) {
515
      handler.handle( new ActionEvent() );
516
    }
517
  }
518
519
  /**
520
   * Closes the active tab; delegates to {@link #canClose(TextResource)}.
521
   */
522
  public void close() {
523
    final var editor = getTextEditor();
524
525
    if( canClose( editor ) ) {
526
      close( editor );
527
    }
528
  }
529
530
  /**
531
   * Closes the given {@link TextResource}. This must not be called from within
532
   * a loop that iterates over the tab panes using {@code forEach}, lest a
533
   * concurrent modification exception be thrown.
534
   *
535
   * @param resource The {@link TextResource} to close, without confirming with
536
   *                 the user.
537
   */
538
  private void close( final TextResource resource ) {
539
    getTab( resource ).ifPresent(
540
      ( tab ) -> {
541
        close( tab );
542
        tab.getTabPane().getTabs().remove( tab );
543
      }
544
    );
545
  }
546
547
  /**
548
   * Answers whether the given {@link TextResource} may be closed.
549
   *
550
   * @param editor The {@link TextResource} to try closing.
551
   * @return {@code true} when the editor may be closed; {@code false} when
552
   * the user has requested to keep the editor open.
553
   */
554
  private boolean canClose( final TextResource editor ) {
555
    final var editorTab = getTab( editor );
556
    final var canClose = new AtomicBoolean( true );
557
558
    if( editor.isModified() ) {
559
      final var filename = new StringBuilder();
560
      editorTab.ifPresent( ( tab ) -> filename.append( tab.getText() ) );
561
562
      final var message = sNotifier.createNotification(
563
        Messages.get( "Alert.file.close.title" ),
564
        Messages.get( "Alert.file.close.text" ),
565
        filename.toString()
566
      );
567
568
      final var dialog = sNotifier.createConfirmation( getWindow(), message );
569
570
      dialog.showAndWait().ifPresent(
571
        save -> canClose.set( save == YES ? editor.save() : save == NO )
572
      );
573
    }
574
575
    return canClose.get();
576
  }
577
578
  private ObjectProperty<TextEditor> createActiveTextEditor() {
579
    final var editor = new SimpleObjectProperty<TextEditor>();
580
581
    editor.addListener( ( c, o, n ) -> {
582
      if( n != null ) {
583
        mPreview.setBaseUri( n.getPath() );
584
        process( n );
585
      }
586
    } );
587
588
    return editor;
589
  }
590
591
  /**
592
   * Adds the HTML preview tab to its own, singular tab pane.
593
   */
594
  public void viewPreview() {
595
    viewTab( mPreview, TEXT_HTML, "Pane.preview.title" );
596
  }
597
598
  /**
599
   * Adds the document outline tab to its own, singular tab pane.
600
   */
601
  public void viewOutline() {
602
    viewTab( mOutline, APP_DOCUMENT_OUTLINE, "Pane.outline.title" );
603
  }
604
605
  public void viewStatistics() {
606
    viewTab( mStatistics, APP_DOCUMENT_STATISTICS, "Pane.statistics.title" );
607
  }
608
609
  public void viewFiles() {
610
    try {
611
      final var factory = new FilePickerFactory( getWorkspace() );
612
      final var fileManager = factory.createModeless();
613
      viewTab( fileManager, APP_FILE_MANAGER, "Pane.files.title" );
614
    } catch( final Exception ex ) {
615
      clue( ex );
616
    }
617
  }
618
619
  private void viewTab(
620
    final Node node, final MediaType mediaType, final String key ) {
621
    final var tabPane = obtainTabPane( mediaType );
622
623
    for( final var tab : tabPane.getTabs() ) {
624
      if( tab.getContent() == node ) {
625
        return;
626
      }
627
    }
628
629
    tabPane.getTabs().add( createTab( get( key ), node ) );
630
    addTabPane( tabPane );
631
  }
632
633
  public void viewRefresh() {
634
    mPreview.refresh();
635
  }
636
637
  /**
638
   * Returns the tab that contains the given {@link TextEditor}.
639
   *
640
   * @param editor The {@link TextEditor} instance to find amongst the tabs.
641
   * @return The first tab having content that matches the given tab.
642
   */
643
  private Optional<Tab> getTab( final TextResource editor ) {
644
    return mTabPanes.stream()
645
                    .flatMap( pane -> pane.getTabs().stream() )
646
                    .filter( tab -> editor.equals( tab.getContent() ) )
647
                    .findFirst();
648
  }
649
650
  /**
651
   * Creates a new {@link DefinitionEditor} wrapped in a listener that
652
   * is used to detect when the active {@link DefinitionEditor} has changed.
653
   * Upon changing, the variables are interpolated and the active text editor
654
   * is refreshed.
655
   *
656
   * @param textEditor Text editor to update with the revised resolved map.
657
   * @return A newly configured property that represents the active
658
   * {@link DefinitionEditor}, never null.
659
   */
660
  private ObjectProperty<TextDefinition> createActiveDefinitionEditor(
661
    final ObjectProperty<TextEditor> textEditor ) {
662
    final var defEditor = new SimpleObjectProperty<>(
663
      createDefinitionEditor()
664
    );
665
666
    defEditor.addListener( ( c, o, n ) -> process( textEditor.get() ) );
667
668
    return defEditor;
669
  }
670
671
  private Tab createTab( final String filename, final Node node ) {
672
    return new DetachableTab( filename, node );
673
  }
674
675
  private Tab createTab( final File file ) {
676
    final var r = createTextResource( file );
677
    final var tab = createTab( r.getFilename(), r.getNode() );
678
679
    r.modifiedProperty().addListener(
680
      ( c, o, n ) -> tab.setText( r.getFilename() + (n ? "*" : "") )
681
    );
682
683
    // This is called when either the tab is closed by the user clicking on
684
    // the tab's close icon or when closing (all) from the file menu.
685
    tab.setOnClosed(
686
      ( __ ) -> getRecentFiles().remove( file.getAbsolutePath() )
687
    );
688
689
    // When closing a tab, give focus to the newly revealed tab.
690
    tab.selectedProperty().addListener( ( c, o, n ) -> {
691
      if( n != null && n ) {
692
        final var pane = tab.getTabPane();
693
694
        if( pane != null ) {
695
          pane.requestFocus();
696
        }
697
      }
698
    } );
699
700
    tab.tabPaneProperty().addListener( ( cPane, oPane, nPane ) -> {
701
      if( nPane != null ) {
702
        nPane.focusedProperty().addListener( ( c, o, n ) -> {
703
          if( n != null && n ) {
704
            final var selected = nPane.getSelectionModel().getSelectedItem();
705
            final var node = selected.getContent();
706
            node.requestFocus();
707
          }
708
        } );
709
      }
710
    } );
711
712
    return tab;
713
  }
714
715
  /**
716
   * Creates bins for the different {@link MediaType}s, which eventually are
717
   * added to the UI as separate tab panes. If ever a general-purpose scene
718
   * exporter is developed to serialize a scene to an FXML file, this could
719
   * be replaced by such a class.
720
   * <p>
721
   * When binning the files, this makes sure that at least one file exists
722
   * for every type. If the user has opted to close a particular type (such
723
   * as the definition pane), the view will suppressed elsewhere.
724
   * </p>
725
   * <p>
726
   * The order that the binned files are returned will be reflected in the
727
   * order that the corresponding panes are rendered in the UI.
728
   * </p>
729
   *
730
   * @param paths The file paths to bin according to their type.
731
   * @return An in-order list of files, first by structured definition files,
732
   * then by plain text documents.
733
   */
734
  private List<File> collect( final SetProperty<String> paths ) {
735
    // Treat all files destined for the text editor as plain text documents
736
    // so that they are added to the same pane. Grouping by TEXT_PLAIN is a
737
    // bit arbitrary, but means explicitly capturing TEXT_PLAIN isn't needed.
738
    final Function<MediaType, MediaType> bin =
739
      m -> PLAIN_TEXT_FORMAT.contains( m ) ? TEXT_PLAIN : m;
740
741
    // Create two groups: YAML files and plain text files. The order that
742
    // the elements are listed in the enumeration for media types determines
743
    // what files are loaded first. Variable definitions come before all other
744
    // plain text documents.
745
    final var bins = paths
746
      .stream()
747
      .collect(
748
        groupingBy(
749
          path -> bin.apply( MediaType.fromFilename( path ) ),
750
          () -> new TreeMap<>( Enum::compareTo ),
751
          Collectors.toList()
752
        )
753
      );
754
755
    bins.putIfAbsent( TEXT_YAML, List.of( DEFINITION_DEFAULT.toString() ) );
756
    bins.putIfAbsent( TEXT_PLAIN, List.of( DOCUMENT_DEFAULT.toString() ) );
757
758
    final var result = new LinkedList<File>();
759
760
    // Ensure that the same types are listed together (keep insertion order).
761
    bins.forEach( ( mediaType, files ) -> result.addAll(
762
      files.stream().map( File::new ).toList() )
763
    );
764
765
    return result;
766
  }
767
768
  /**
769
   * Force the active editor to update, which will cause the processor
770
   * to re-evaluate the interpolated definition map thereby updating the
771
   * preview pane.
772
   *
773
   * @param editor Contains the source document to update in the preview pane.
774
   */
775
  private void process( final TextEditor editor ) {
776
    // Ensure processing does not run on the JavaFX thread, which frees the
777
    // text editor immediately for caret movement. The preview will have a
778
    // slight delay when catching up to the caret position.
779
    final var task = new Task<Void>() {
780
      @Override
781
      public Void call() {
782
        try {
783
          final var p = mProcessors.getOrDefault( editor, IDENTITY );
784
          p.apply( editor == null ? "" : editor.getText() );
785
        } catch( final Exception ex ) {
786
          clue( ex );
787
        }
788
789
        return null;
790
      }
791
    };
792
793
    // TODO: Each time the editor successfully runs the processor the task is
794
    //   considered successful. Due to the rapid-fire nature of processing
795
    //   (e.g., keyboard navigation, fast typing), it isn't necessary to
796
    //   scroll each time.
797
    //   The algorithm:
798
    //   1. Peek at the oldest time.
799
    //   2. If the difference between the oldest time and current time exceeds
800
    //      250 milliseconds, then invoke the scrolling.
801
    //   3. Insert the current time into the circular queue.
802
    task.setOnSucceeded(
803
      e -> invokeLater( () -> mPreview.scrollTo( CARET_ID ) )
804
    );
805
806
    // Prevents multiple process requests from executing simultaneously (due
807
    // to having a restricted queue size).
808
    sExecutor.execute( task );
809
  }
810
811
  /**
812
   * Lazily creates a {@link TabPane} configured to listen for tab select
813
   * events. The tab pane is associated with a given media type so that
814
   * similar files can be grouped together.
815
   *
816
   * @param mediaType The media type to associate with the tab pane.
817
   * @return An instance of {@link TabPane} that will handle tab docking.
818
   */
819
  private TabPane obtainTabPane( final MediaType mediaType ) {
820
    for( final var pane : mTabPanes ) {
821
      for( final var tab : pane.getTabs() ) {
822
        final var node = tab.getContent();
823
824
        if( node instanceof TextResource r && r.supports( mediaType ) ) {
825
          return pane;
826
        }
827
      }
828
    }
829
830
    final var pane = createTabPane();
831
    mTabPanes.add( pane );
832
    return pane;
833
  }
834
835
  /**
836
   * Creates an initialized {@link TabPane} instance.
837
   *
838
   * @return A new {@link TabPane} with all listeners configured.
839
   */
840
  private TabPane createTabPane() {
841
    final var tabPane = new DetachableTabPane();
842
843
    initStageOwnerFactory( tabPane );
844
    initTabListener( tabPane );
845
846
    return tabPane;
847
  }
848
849
  /**
850
   * When any {@link DetachableTabPane} is detached from the main window,
851
   * the stage owner factory must be given its parent window, which will
852
   * own the child window. The parent window is the {@link MainPane}'s
853
   * {@link Scene}'s {@link Window} instance.
854
   *
855
   * <p>
856
   * This will derives the new title from the main window title, incrementing
857
   * the window count to help uniquely identify the child windows.
858
   * </p>
859
   *
860
   * @param tabPane A new {@link DetachableTabPane} to configure.
861
   */
862
  private void initStageOwnerFactory( final DetachableTabPane tabPane ) {
863
    tabPane.setStageOwnerFactory( ( stage ) -> {
864
      final var title = get(
865
        "Detach.tab.title",
866
        ((Stage) getWindow()).getTitle(), ++mWindowCount
867
      );
868
      stage.setTitle( title );
869
870
      return getScene().getWindow();
871
    } );
872
  }
873
874
  /**
875
   * Responsible for configuring the content of each {@link DetachableTab} when
876
   * it is added to the given {@link DetachableTabPane} instance.
877
   * <p>
878
   * For {@link TextEditor} contents, an instance of {@link ScrollEventHandler}
879
   * is initialized to perform synchronized scrolling between the editor and
880
   * its preview window. Additionally, the last tab in the tab pane's list of
881
   * tabs is given focus.
882
   * </p>
883
   * <p>
884
   * Note that multiple tabs can be added simultaneously.
885
   * </p>
886
   *
887
   * @param tabPane A new {@link TabPane} to configure.
888
   */
889
  private void initTabListener( final TabPane tabPane ) {
890
    tabPane.getTabs().addListener(
891
      ( final ListChangeListener.Change<? extends Tab> listener ) -> {
892
        while( listener.next() ) {
893
          if( listener.wasAdded() ) {
894
            final var tabs = listener.getAddedSubList();
895
896
            tabs.forEach( tab -> {
897
              final var node = tab.getContent();
898
899
              if( node instanceof TextEditor ) {
900
                initScrollEventListener( tab );
901
              }
902
            } );
903
904
            // Select and give focus to the last tab opened.
905
            final var index = tabs.size() - 1;
906
            if( index >= 0 ) {
907
              final var tab = tabs.get( index );
908
              tabPane.getSelectionModel().select( tab );
909
              tab.getContent().requestFocus();
910
            }
911
          }
912
        }
913
      }
914
    );
915
  }
916
917
  /**
918
   * Synchronizes scrollbar positions between the given {@link Tab} that
919
   * contains an instance of {@link TextEditor} and {@link HtmlPreview} pane.
920
   *
921
   * @param tab The container for an instance of {@link TextEditor}.
922
   */
923
  private void initScrollEventListener( final Tab tab ) {
924
    final var editor = (TextEditor) tab.getContent();
925
    final var scrollPane = editor.getScrollPane();
926
    final var scrollBar = mPreview.getVerticalScrollBar();
927
    final var handler = new ScrollEventHandler( scrollPane, scrollBar );
928
929
    handler.enabledProperty().bind( tab.selectedProperty() );
930
  }
931
932
  private void addTabPane( final int index, final TabPane tabPane ) {
933
    final var items = getItems();
934
935
    if( !items.contains( tabPane ) ) {
936
      items.add( index, tabPane );
937
    }
938
  }
939
940
  private void addTabPane( final TabPane tabPane ) {
941
    addTabPane( getItems().size(), tabPane );
942
  }
943
944
  private GenericBuilder<Mutator, ProcessorContext> createProcessorContextBuilder() {
945
    return builder()
946
      .with( Mutator::setDefinitions, this::getDefinitions )
947
      .with( Mutator::setWorkspace, mWorkspace )
948
      .with( Mutator::setCaret, () -> getTextEditor().getCaret() );
949
  }
950
951
  public ProcessorContext createProcessorContext() {
952
    return createProcessorContext( null, NONE );
953
  }
954
955
  /**
956
   * @param outputPath Used when exporting to a PDF file (binary).
957
   * @param format     Used when processors export to a new text format.
958
   * @return A new {@link ProcessorContext} to use when creating an instance of
959
   * {@link Processor}.
960
   */
961
  public ProcessorContext createProcessorContext(
962
    final Path outputPath, final ExportFormat format ) {
963
    final var textEditor = getTextEditor();
964
    final var inputPath = textEditor.getPath();
965
966
    return createProcessorContextBuilder()
967
      .with( Mutator::setInputPath, inputPath )
968
      .with( Mutator::setOutputPath, outputPath )
969
      .with( Mutator::setExportFormat, format )
970
      .build();
971
  }
972
973
  /**
974
   * @param inputPath Used by {@link ProcessorFactory} to determine
975
   *                  {@link Processor} type to create based on file type.
976
   * @return A new {@link ProcessorContext} to use when creating an instance of
977
   * {@link Processor}.
978
   */
979
  private ProcessorContext createProcessorContext( final Path inputPath ) {
980
    return createProcessorContextBuilder()
981
      .with( Mutator::setInputPath, inputPath )
982
      .with( Mutator::setExportFormat, NONE )
983
      .build();
984
  }
985
986
  private TextResource createTextResource( final File file ) {
987
    // TODO: Create PlainTextEditor that's returned by default.
988
    return MediaType.valueFrom( file ) == TEXT_YAML
989
      ? createDefinitionEditor( file )
990
      : createMarkdownEditor( file );
991
  }
992
993
  /**
994
   * Creates an instance of {@link MarkdownEditor} that listens for both
995
   * caret change events and text change events. Text change events must
996
   * take priority over caret change events because it's possible to change
997
   * the text without moving the caret (e.g., delete selected text).
998
   *
999
   * @param inputFile The file containing contents for the text editor.
1000
   * @return A non-null text editor.
1001
   */
1002
  private TextResource createMarkdownEditor( final File inputFile ) {
1003
    final var editor = new MarkdownEditor( inputFile, getWorkspace() );
1004
1005
    mProcessors.computeIfAbsent(
1006
      editor, p -> createProcessors(
1007
        createProcessorContext( inputFile.toPath() ),
1008
        createHtmlPreviewProcessor()
1009
      )
1010
    );
1011
1012
    // Listener for editor modifications or caret position changes.
1013
    editor.addDirtyListener( ( c, o, n ) -> {
1014
      if( n ) {
1015
        // Reset the status bar after changing the text.
1016
        clue();
1017
1018
        // Processing the text may update the status bar.
1019
        process( getTextEditor() );
1020
      }
1021
    } );
1022
1023
    editor.addEventListener(
1024
      keyPressed( SPACE, CONTROL_DOWN ), this::autoinsert
1025
    );
1026
1027
    // Set the active editor, which refreshes the preview panel.
1028
    mTextEditor.set( editor );
1029
1030
    return editor;
1031
  }
1032
1033
  /**
1034
   * Creates a {@link Processor} capable of rendering an HTML document onto
1035
   * a GUI widget.
1036
   *
1037
   * @return The {@link Processor} for rendering an HTML document.
1038
   */
1039
  private Processor<String> createHtmlPreviewProcessor() {
1040
    return new HtmlPreviewProcessor( getPreview() );
1041
  }
1042
1043
  /**
1044
   * See {@link #autoinsert()}.
1045
   */
1046
  private void autoinsert( final KeyEvent ignored ) {
1047
    autoinsert();
1048
  }
1049
1050
  /**
1051
   * Finds a node that matches the word at the caret, then inserts the
1052
   * corresponding definition. The definition token delimiters depend on
1053
   * the type of file being edited.
1054
   */
1055
  public void autoinsert() {
1056
    final var editor = getTextEditor();
1057
    final var mediaType = editor.getMediaType();
1058
    final var injector = createInjector( mediaType );
1059
    final var definitions = getTextDefinition();
1060
1061
    VariableNameInjector.autoinsert( editor, definitions, injector );
1062
  }
1063
1064
  private UnaryOperator<String> createInjector( final MediaType mediaType ) {
1065
    final String began;
1066
    final String ended;
1067
    final UnaryOperator<String> operator;
1068
1069
    switch( mediaType ) {
1070
      case TEXT_MARKDOWN -> {
1071
        began = getString( KEY_DEF_DELIM_BEGAN );
1072
        ended = getString( KEY_DEF_DELIM_ENDED );
1073
        operator = s -> s;
1074
      }
1075
      case TEXT_R_MARKDOWN -> {
1076
        began = InlineRProcessor.PREFIX + getString( KEY_R_DELIM_BEGAN );
1077
        ended = getString( KEY_R_DELIM_ENDED ) + InlineRProcessor.SUFFIX;
1078
        operator = new RKeyOperator();
1079
      }
1080
      case TEXT_PROPERTIES -> {
1081
        began = PropertyKeyOperator.BEGAN;
1082
        ended = PropertyKeyOperator.ENDED;
1083
        operator = s -> s;
1084
      }
1085
      default -> {
1086
        began = "";
1087
        ended = "";
1088
        operator = s -> s;
1089
      }
1090
    }
1091
1092
    return s -> began + operator.apply( s ) + ended;
1093
  }
1094
1095
  private String getString( final Key key ) {
1096
    assert key != null;
1097
    return getWorkspace().getString( key );
1098
  }
1099
1100
  private TextDefinition createDefinitionEditor() {
1101
    return createDefinitionEditor( DEFINITION_DEFAULT );
1102
  }
1103
1104
  private TextDefinition createDefinitionEditor( final File file ) {
1105
    final var editor = new DefinitionEditor( file, createTreeTransformer() );
1106
1107
    editor.addTreeChangeHandler( mTreeHandler );
1108
1109
    return editor;
1110
  }
1111
1112
  private TreeTransformer createTreeTransformer() {
1113
    return new YamlTreeTransformer();
1114
  }
1115
1116
  private Tooltip createTooltip( final File file ) {
1117
    final var path = file.toPath();
1118
    final var tooltip = new Tooltip( path.toString() );
1119
1120
    tooltip.setShowDelay( millis( 200 ) );
1121
1122
    return tooltip;
1123
  }
1124
1125
  public HtmlPreview getPreview() {
1126
    return mPreview;
1127
  }
1128
1129
  /**
1130
   * Returns the active text editor.
1131
   *
1132
   * @return The text editor that currently has focus.
1133
   */
1134
  public TextEditor getTextEditor() {
1135
    return mTextEditor.get();
1136
  }
1137
1138
  /**
1139
   * Returns the active text editor property.
1140
   *
1141
   * @return The property container for the active text editor.
1142
   */
1143
  public ReadOnlyObjectProperty<TextEditor> textEditorProperty() {
1144
    return mTextEditor;
1145
  }
1146
1147
  /**
1148
   * Returns the active text definition editor.
1149
   *
1150
   * @return The property container for the active definition editor.
1151
   */
1152
  public TextDefinition getTextDefinition() {
1153
    return mDefinitionEditor.get();
1154
  }
1155
1156
  /**
1157
   * Returns the active variable definitions, without any interpolation.
1158
   * Interpolation is a responsibility of {@link Processor} instances.
1159
   *
1160
   * @return The key-value pairs, not interpolated.
1161
   */
1162
  private Map<String, String> getDefinitions() {
1163
    return getTextDefinition().getDefinitions();
1164
  }
1165
1166
  public Window getWindow() {
1167
    return getScene().getWindow();
1168
  }
1169
1170
  public Workspace getWorkspace() {
1171
    return mWorkspace;
1172
  }
1173
1174
  /**
1175
   * Returns the set of file names opened in the application. The names must
1176
   * be converted to {@link File} objects.
1177
   *
1178
   * @return A {@link Set} of file names.
1179
   */
1180
  private <E> SetProperty<E> getRecentFiles() {
1181
    return getWorkspace().setsProperty( KEY_UI_RECENT_OPEN_PATH );
11101182
  }
11111183
}
M src/main/java/com/keenwrite/MainScene.java
2222
import static com.keenwrite.events.StatusEvent.clue;
2323
import static com.keenwrite.preferences.SkinProperty.toFilename;
24
import static com.keenwrite.preferences.WorkspaceKeys.KEY_UI_SKIN_CUSTOM;
25
import static com.keenwrite.preferences.WorkspaceKeys.KEY_UI_SKIN_SELECTION;
24
import static com.keenwrite.preferences.AppKeys.KEY_UI_SKIN_CUSTOM;
25
import static com.keenwrite.preferences.AppKeys.KEY_UI_SKIN_SELECTION;
2626
import static com.keenwrite.ui.actions.ApplicationBars.*;
2727
import static javafx.application.Platform.runLater;
...
3434
 */
3535
public final class MainScene {
36
3637
  private final Scene mScene;
3738
  private final MenuBar mMenuBar;
...
179180
   */
180181
  private CaretListener createCaretListener( final MainPane mainPane ) {
181
    return new CaretListener( mainPane.activeTextEditorProperty() );
182
    return new CaretListener( mainPane.textEditorProperty() );
182183
  }
183184
M src/main/java/com/keenwrite/Messages.java
33
44
import com.keenwrite.preferences.Key;
5
import com.keenwrite.sigils.SigilOperator;
6
import com.keenwrite.util.InterpolatingMap;
5
import com.keenwrite.sigils.PropertyKeyOperator;
6
import com.keenwrite.sigils.SigilKeyOperator;
7
import com.keenwrite.collections.InterpolatingMap;
78
89
import java.text.MessageFormat;
...
1819
public final class Messages {
1920
20
  private static final SigilOperator OPERATOR = createBundleSigilOperator();
21
  private static final InterpolatingMap MAP = new InterpolatingMap();
21
  private static final SigilKeyOperator OPERATOR = new PropertyKeyOperator();
22
  private static final InterpolatingMap MAP = new InterpolatingMap( OPERATOR );
2223
2324
  static {
2425
    // Obtains the application resource bundle using the default locale. The
2526
    // locale cannot be changed using the application, making interpolation of
2627
    // values viable as a one-time operation.
27
    final var BUNDLE = getBundle( APP_BUNDLE_NAME );
28
    BUNDLE.keySet().forEach( key -> MAP.put( key, BUNDLE.getString( key ) ) );
29
    MAP.interpolate( OPERATOR );
30
  }
28
    final var bundle = getBundle( APP_BUNDLE_NAME );
3129
32
  private Messages() {
30
    bundle.keySet().forEach( key -> MAP.put( key, bundle.getString( key ) ) );
31
    MAP.interpolate();
3332
  }
3433
...
4140
   */
4241
  public static String get( final String key ) {
43
    final var v = MAP.get( OPERATOR.entoken( key ) );
42
    final var v = MAP.get( key );
43
4444
    return v == null ? key : v;
4545
  }
...
7575
   */
7676
  public static boolean containsKey( final String key ) {
77
    return MAP.containsKey( OPERATOR.entoken( key ) );
77
    return MAP.containsKey( key );
7878
  }
7979
80
  private static SigilOperator createBundleSigilOperator() {
81
    return new SigilOperator( "${", "}" );
82
  }
80
  private Messages() {}
8381
}
8482
M src/main/java/com/keenwrite/PermissiveCertificate.java
4949
5050
  /**
51
   * Use {@link #installTrustManager()}.
52
   */
53
  private PermissiveCertificate() {
54
  }
55
56
  /**
5751
   * Install the all-trusting trust manager. If this fails it means that in
5852
   * certain situations the HTML preview may fail to render diagrams. A way
59
   * to work-around the issue is to install a local server for generating
53
   * to work around the issue is to install a local server for generating
6054
   * diagrams.
6155
   */
...
7064
      return false;
7165
    }
66
  }
67
68
  /**
69
   * Use {@link #installTrustManager()}.
70
   */
71
  private PermissiveCertificate() {
7272
  }
7373
}
A src/main/java/com/keenwrite/VariableNameInjector.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite;
3
4
import com.keenwrite.editors.TextDefinition;
5
import com.keenwrite.editors.TextEditor;
6
import com.keenwrite.editors.definition.DefinitionTreeItem;
7
8
import java.util.function.UnaryOperator;
9
10
import static com.keenwrite.constants.Constants.*;
11
import static com.keenwrite.events.StatusEvent.clue;
12
13
/**
14
 * Provides the logic for injecting variable names within the editor.
15
 */
16
public final class VariableNameInjector {
17
18
  /**
19
   * Find a node that matches the current word and substitute the definition
20
   * reference.
21
   */
22
  public static void autoinsert(
23
    final TextEditor editor,
24
    final TextDefinition definitions,
25
    final UnaryOperator<String> operator ) {
26
    assert editor != null;
27
    assert definitions != null;
28
    assert operator != null;
29
30
    try {
31
      if( definitions.isEmpty() ) {
32
        clue( STATUS_DEFINITION_EMPTY );
33
      }
34
      else {
35
        final var indexes = editor.getCaretWord();
36
        final var word = editor.getText( indexes );
37
38
        if( word.isBlank() ) {
39
          clue( STATUS_DEFINITION_BLANK );
40
        }
41
        else {
42
          final var leaf = findLeaf( definitions, word );
43
44
          if( leaf == null ) {
45
            clue( STATUS_DEFINITION_MISSING, word );
46
          }
47
          else {
48
            editor.replaceText( indexes, operator.apply( leaf.toPath() ) );
49
            definitions.expand( leaf );
50
          }
51
        }
52
      }
53
    } catch( final Exception ex ) {
54
      clue( STATUS_DEFINITION_BLANK, ex );
55
    }
56
  }
57
58
  /**
59
   * Looks for the given word, matching first by exact, next by a starts-with
60
   * condition with diacritics replaced, then by containment.
61
   *
62
   * @param word Match the word by: exact, beginning, containment, or other.
63
   */
64
  @SuppressWarnings( "ConstantConditions" )
65
  private static DefinitionTreeItem<String> findLeaf(
66
    final TextDefinition definition, final String word ) {
67
    assert definition != null;
68
    assert word != null;
69
70
    DefinitionTreeItem<String> leaf = null;
71
72
    leaf = leaf == null ? definition.findLeafExact( word ) : leaf;
73
    leaf = leaf == null ? definition.findLeafStartsWith( word ) : leaf;
74
    leaf = leaf == null ? definition.findLeafContains( word ) : leaf;
75
    leaf = leaf == null ? definition.findLeafContainsNoCase( word ) : leaf;
76
77
    return leaf;
78
  }
79
80
  /**
81
   * Prevent instantiation.
82
   */
83
  private VariableNameInjector() {}
84
}
185
M src/main/java/com/keenwrite/cmdline/Arguments.java
22
33
import com.keenwrite.ExportFormat;
4
import com.keenwrite.preferences.Key;
5
import com.keenwrite.preferences.KeyConfiguration;
46
import com.keenwrite.processors.ProcessorContext;
57
import com.keenwrite.processors.ProcessorContext.Mutator;
68
import picocli.CommandLine;
79
810
import java.io.File;
911
import java.nio.file.Path;
12
import java.util.HashMap;
1013
import java.util.Map;
1114
import java.util.Set;
1215
import java.util.concurrent.Callable;
1316
import java.util.function.Consumer;
17
18
import static com.keenwrite.preferences.AppKeys.*;
1419
20
/**
21
 * Responsible for mapping command-line arguments to keys that are used by
22
 * the application. This class implements the {@link KeyConfiguration} as
23
 * an abstraction so that the CLI and GUI can reuse the same code, but without
24
 * the CLI needing to instantiate or initialize JavaFX.
25
 */
1526
@CommandLine.Command(
1627
  name = "KeenWrite",
1728
  mixinStandardHelpOptions = true,
1829
  description = "Plain text editor for editing with variables."
1930
)
2031
@SuppressWarnings( "unused" )
21
public final class Arguments implements Callable<Integer> {
32
public final class Arguments implements Callable<Integer>, KeyConfiguration {
2233
  @CommandLine.Option(
2334
    names = {"-a", "--all"},
2435
    description =
2536
      "Concatenate files in directory before processing (${DEFAULT-VALUE}).",
2637
    defaultValue = "false"
2738
  )
2839
  private boolean mAll;
40
41
  @CommandLine.Option(
42
    names = {"-k", "--keep-files"},
43
    description =
44
      "Keep temporary build files (${DEFAULT-VALUE}).",
45
    defaultValue = "false"
46
  )
47
  private boolean mKeepFiles;
2948
3049
  @CommandLine.Option(
...
4059
    description =
4160
      "Set the file name to read.",
42
    paramLabel = "FILE",
61
    paramLabel = "PATH",
4362
    defaultValue = "stdin",
4463
    required = true
4564
  )
46
  private File mFileInput;
65
  private Path mPathInput;
4766
4867
  @CommandLine.Option(
...
6887
    description =
6988
      "Set the file name to write.",
70
    paramLabel = "FILE",
89
    paramLabel = "PATH",
7190
    defaultValue = "stdout",
7291
    required = true
7392
  )
74
  private File mFileOutput;
93
  private File mPathOutput;
7594
7695
  @CommandLine.Option(
7796
    names = {"-p", "--images-path"},
7897
    description =
7998
      "Absolute path to images directory",
8099
    paramLabel = "PATH"
81100
  )
82
  private Path mImages;
101
  private Path mPathImages;
83102
84103
  @CommandLine.Option(
...
104123
    paramLabel = "PATH"
105124
  )
106
  private String mThemeName;
125
  private Path mThemeName;
107126
108127
  @CommandLine.Option(
109128
    names = {"-x", "--image-extensions"},
110129
    description =
111130
      "Space-separated image file name extensions (${DEFAULT-VALUE}).",
112131
    paramLabel = "String",
113132
    defaultValue = "svg pdf png jpg tiff"
114133
  )
115
  private Set<String> mExtensions;
134
  private Set<String> mImageExtensions;
116135
117136
  @CommandLine.Option(
118137
    names = {"-v", "--variables"},
119138
    description =
120139
      "Set the file name containing variable definitions (${DEFAULT-VALUE}).",
121140
    paramLabel = "FILE",
122141
    defaultValue = "variables.yaml"
123142
  )
124
  private String mFileVariables;
143
  private Path mPathVariables;
125144
126145
  private final Consumer<Arguments> mLauncher;
146
147
  private final Map<Key, Object> mValues = new HashMap<>();
127148
128149
  public Arguments( final Consumer<Arguments> launcher ) {
129150
    mLauncher = launcher;
130151
  }
131152
132153
  public ProcessorContext createProcessorContext() {
154
    mValues.put( KEY_UI_RECENT_DOCUMENT, mPathInput );
155
    mValues.put( KEY_UI_RECENT_DEFINITION, mPathVariables );
156
    mValues.put( KEY_UI_RECENT_EXPORT, mPathOutput );
157
    mValues.put( KEY_IMAGES_DIR, mPathImages );
158
    mValues.put( KEY_TYPESET_CONTEXT_THEMES_PATH, mThemeName.getParent() );
159
    mValues.put( KEY_TYPESET_CONTEXT_THEME_SELECTION, mThemeName.getFileName() );
160
    mValues.put( KEY_TYPESET_CONTEXT_CLEAN, !mKeepFiles );
161
133162
    final var format = ExportFormat.valueFrom( mFormatType, mFormatSubtype );
163
134164
    return ProcessorContext
135165
      .builder()
136
      .with( Mutator::setInputPath, mFileInput )
137
      .with( Mutator::setOutputPath, mFileOutput )
166
      .with( Mutator::setInputPath, mPathInput )
167
      .with( Mutator::setOutputPath, mPathOutput )
138168
      .with( Mutator::setExportFormat, format )
139169
      .build();
...
158188
  public Integer call() throws Exception {
159189
    mLauncher.accept( this );
190
    return 0;
191
  }
192
193
  @Override
194
  public String getString( final Key key ) {
195
    return null;
196
  }
197
198
  @Override
199
  public boolean getBoolean( final Key key ) {
200
    return false;
201
  }
202
203
  @Override
204
  public int getInteger( final Key key ) {
205
    return 0;
206
  }
207
208
  @Override
209
  public double getDouble( final Key key ) {
160210
    return 0;
211
  }
212
213
  @Override
214
  public File getFile( final Key key ) {
215
    return null;
161216
  }
162217
}
M src/main/java/com/keenwrite/cmdline/ColourScheme.java
99
 */
1010
public class ColourScheme {
11
12
  /**
13
   * Creates a new color scheme for use with command-line parsing.
14
   *
15
   * @return The new color scheme to apply to the parsesr.
16
   */
1117
  public static ColorScheme create() {
1218
    return new Builder()
M src/main/java/com/keenwrite/cmdline/HeadlessApp.java
22
33
import com.keenwrite.AppCommands;
4
import com.keenwrite.events.StatusEvent;
5
import org.greenrobot.eventbus.Subscribe;
6
7
import static com.keenwrite.events.Bus.register;
48
59
/**
610
 * Responsible for running the application in headless mode.
711
 */
812
public class HeadlessApp {
13
14
  /**
15
   * Contains directives that control text file processing.
16
   */
17
  private final Arguments mArgs;
18
19
  /**
20
   * Creates a new command-line version of the application.
21
   *
22
   * @param args The post-processed command-line arguments.
23
   */
24
  public HeadlessApp( final Arguments args ) {
25
    assert args != null;
26
27
    mArgs = args;
28
29
    register( this );
30
    AppCommands.run( mArgs );
31
  }
32
33
  /**
34
   * When a status message is shown, write it to the console, if not in
35
   * quiet mode.
36
   *
37
   * @param event The event published when the status changes.
38
   */
39
  @Subscribe
40
  public void handle( final StatusEvent event ) {
41
    if( !mArgs.quiet() ) {
42
      System.out.println( event );
43
    }
44
  }
945
1046
  /**
1147
   * Entry point for running the application in headless mode.
1248
   *
1349
   * @param args The parsed command-line arguments.
1450
   */
1551
  public static void main( final Arguments args ) {
16
    AppCommands.run( args );
52
    new HeadlessApp( args );
1753
  }
1854
}
A src/main/java/com/keenwrite/collections/BoundedCache.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.collections;
3
4
import java.util.LinkedHashMap;
5
import java.util.Map;
6
7
/**
8
 * A map that removes the oldest entry once its capacity (cache size) has
9
 * been reached.
10
 *
11
 * @param <K> The type of key mapped to a value.
12
 * @param <V> The type of value mapped to a key.
13
 */
14
public final class BoundedCache<K, V> extends LinkedHashMap<K, V> {
15
  private final int mCacheSize;
16
17
  /**
18
   * Constructs a new instance having a finite size.
19
   *
20
   * @param cacheSize The maximum number of entries.
21
   */
22
  public BoundedCache( final int cacheSize ) {
23
    mCacheSize = cacheSize;
24
  }
25
26
  @Override
27
  protected boolean removeEldestEntry( final Map.Entry<K, V> eldest ) {
28
    return size() > mCacheSize;
29
  }
30
}
131
A src/main/java/com/keenwrite/collections/CircularQueue.java
1
/* Copyright 2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.collections;
3
4
import java.util.*;
5
6
import static java.lang.Math.min;
7
8
/**
9
 * Responsible for maintaining a circular queue where newly added items will
10
 * overwrite existing items.
11
 *
12
 * <strong>Warning:</strong> This class is not thread-safe.
13
 *
14
 * @param <E> The type of elements to store in this collection.
15
 */
16
@SuppressWarnings( "unchecked" )
17
public class CircularQueue<E>
18
  extends AbstractCollection<E> implements Queue<E> {
19
20
  /**
21
   * Simplifies the code by reusing an existing list implementation.
22
   * Initialized with {@code null} values at construction time.
23
   */
24
  private final Object[] mElements;
25
26
  /**
27
   * Maximum number of elements allowed in the collection before old elements
28
   * are overwritten. Set at construction time.
29
   */
30
  private final int mCapacity;
31
32
  /**
33
   * Insertion position when a new element is added. Starts at zero.
34
   */
35
  private int mProducer;
36
37
  /**
38
   * Retrieval position when the oldest element is removed. Starts at zero.
39
   */
40
  private int mConsumer;
41
42
  /**
43
   * The number of elements in the collection. This cannot delegate to the
44
   * {@link #mElements} list. Starts at zero.
45
   */
46
  private int mSize;
47
48
  /**
49
   * Creates a new circular queue that has a limited number of elements that
50
   * may be added before newly added elements will overwrite the oldest
51
   * elements that were added previously.
52
   * <p>
53
   * <strong>Warning:</strong> Client classes must take care not to exceed
54
   * memory limits imposed by the Java Virtual Machine.
55
   *
56
   * @param capacity Maximum number elements allowed in the list, must be
57
   *                 greater than one.
58
   */
59
  public CircularQueue( final int capacity ) {
60
    assert capacity > 1;
61
62
    mCapacity = capacity;
63
    mElements = new Object[ capacity ];
64
  }
65
66
  /**
67
   * Adds an element to the end of the collection. This overwrites the oldest
68
   * element in the collection when the queue is full. The number of elements,
69
   * reflected by the return value of {@link #size()} will not exceed the
70
   * capacity.
71
   *
72
   * @param element The item to insert into the collection, must not be
73
   *                {@code null}.
74
   * @return {@code true} Non-{@code null} items are always added.
75
   * @throws NullPointerException if the given element is {@code null}.
76
   *                              The iterator requires a consecutive
77
   *                              non-{@code null} range (no gaps).
78
   */
79
  @Override
80
  public boolean add( final E element ) {
81
    if( element == null ) {
82
      throw new NullPointerException();
83
    }
84
85
    mElements[ mProducer++ ] = element;
86
    mProducer %= mCapacity;
87
    mSize = min( mSize + 1, mCapacity );
88
89
    return true;
90
  }
91
92
  /**
93
   * Delegates to {@link #add(E)}.
94
   */
95
  @Override
96
  public boolean offer( final E element ) {
97
    return add( element );
98
  }
99
100
  /**
101
   * Removes the oldest element that was added to the collection.  The number
102
   * of elements reflected by the return value of {@link #size()} will not
103
   * drop below zero.
104
   *
105
   * @return The oldest element.
106
   * @throws NoSuchElementException The collection is empty.
107
   */
108
  @Override
109
  public E remove() {
110
    if( isEmpty() ) {
111
      throw new NoSuchElementException();
112
    }
113
114
    final E element = (E) mElements[ mConsumer ];
115
116
    mElements[ mConsumer++ ] = null;
117
    mConsumer %= mCapacity;
118
    mSize--;
119
120
    return element;
121
  }
122
123
  /**
124
   * Delegates to {@link #remove()}, but does not throw an exception.
125
   *
126
   * @return The oldest element.
127
   */
128
  @Override
129
  public E poll() {
130
    return isEmpty() ? null : remove();
131
  }
132
133
  /**
134
   * Returns the oldest element that was added to the collection.
135
   *
136
   * @return The oldest element.
137
   * @throws NoSuchElementException The collection is empty.
138
   */
139
  @Override
140
  public E element() {
141
    if( isEmpty() ) {
142
      throw new NoSuchElementException();
143
    }
144
145
    return (E) mElements[ mConsumer ];
146
  }
147
148
  /**
149
   * Delegates to {@link #element()}, but does not throw an exception.
150
   *
151
   * @return The oldest element.
152
   */
153
  @Override
154
  public E peek() {
155
    return isEmpty() ? null : element();
156
  }
157
158
  /**
159
   * Answers how many elements are currently in the collection.
160
   *
161
   * @return The number of elements that have been added to but not removed
162
   * from the collection.
163
   */
164
  @Override
165
  public int size() {
166
    return mSize;
167
  }
168
169
  /**
170
   * Returns a facility to visit each of the elements in the
171
   * {@link CircularQueue}. This will start iterating at the oldest element
172
   * and stop when there are no more elements.
173
   * <p>
174
   * The iterator is not thread-safe; concurrent modifications to the number
175
   * of elements in the {@link CircularQueue} will result in undefined
176
   * behaviour.
177
   *
178
   * @return A new {@link Iterator} instance capable of visiting each element.
179
   */
180
  @Override
181
  public Iterator<E> iterator() {
182
    return new Iterator<>() {
183
      private int mIndex = mConsumer;
184
      private boolean mFirst = true;
185
186
      @Override
187
      public boolean hasNext() {
188
        return (mFirst || mIndex != mConsumer) && mElements[ mIndex ] != null;
189
      }
190
191
      @Override
192
      public E next() {
193
        final var element = mElements[ mIndex++ ];
194
        mIndex %= mCapacity;
195
        mFirst = false;
196
197
        return (E) element;
198
      }
199
    };
200
  }
201
202
  @Override
203
  public String toString() {
204
    return Arrays.toString( mElements );
205
  }
206
}
1207
A src/main/java/com/keenwrite/collections/InterpolatingMap.java
1
/* Copyright 2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.collections;
3
4
import com.keenwrite.sigils.SigilKeyOperator;
5
6
import java.util.HashMap;
7
import java.util.Map;
8
import java.util.concurrent.ConcurrentHashMap;
9
10
/**
11
 * Responsible for interpolating key-value pairs in a map. That is, this will
12
 * iterate over all key-value pairs and replace keys wrapped in sigils
13
 * with corresponding definition value from the same map.
14
 */
15
public class InterpolatingMap extends ConcurrentHashMap<String, String> {
16
  private static final int GROUP_DELIMITED = 1;
17
18
  /**
19
   * Used to override the default initial capacity in {@link HashMap}.
20
   */
21
  private static final int INITIAL_CAPACITY = 1 << 8;
22
23
  private final SigilKeyOperator mOperator;
24
25
  /**
26
   * @param operator Contains the opening and closing sigils that mark
27
   *                 where variable names begin and end.
28
   */
29
  public InterpolatingMap( final SigilKeyOperator operator ) {
30
    super( INITIAL_CAPACITY );
31
32
    assert operator != null;
33
    mOperator = operator;
34
  }
35
36
  /**
37
   * @param operator Contains the opening and closing sigils that mark
38
   *                 where variable names begin and end.
39
   * @param m        The initial {@link Map} to copy into this instance.
40
   */
41
  public InterpolatingMap(
42
    final SigilKeyOperator operator, final Map<String, String> m ) {
43
    this( operator );
44
    putAll( m );
45
  }
46
47
  /**
48
   * Interpolates all values in the map that reference other values by way
49
   * of key names. Performs a non-greedy match of key names delimited by
50
   * definition tokens. This operation modifies the map directly.
51
   *
52
   * @return {@code this}
53
   */
54
  public Map<String, String> interpolate() {
55
    for( final var k : keySet() ) {
56
      replace( k, interpolate( get( k ) ) );
57
    }
58
59
    return this;
60
  }
61
62
  /**
63
   * Given a value with zero or more key references, this will resolve all
64
   * the values, recursively. If a key cannot be de-referenced, the value will
65
   * contain the key name, including the original sigils.
66
   *
67
   * @param value    Value containing zero or more key references.
68
   * @return The given value with all embedded key references interpolated.
69
   */
70
  public String interpolate( String value ) {
71
    assert value != null;
72
73
    final var matcher = mOperator.match( value );
74
75
    while( matcher.find() ) {
76
      final var keyName = matcher.group( GROUP_DELIMITED );
77
      final var mapValue = get( keyName );
78
79
      if( mapValue != null ) {
80
        final var keyValue = interpolate( mapValue );
81
        value = value.replace( mOperator.apply( keyName ), keyValue );
82
      }
83
    }
84
85
    return value;
86
  }
87
}
188
M src/main/java/com/keenwrite/constants/Constants.java
6262
    "file.stylesheet.preview.locale";
6363
64
  public static final String FILE_PREFERENCES = getPreferencesFilename();
64
  public static final File FILE_PREFERENCES = getPreferencesFile();
6565
6666
  /**
...
258258
   * Returns the equivalent of {@code $HOME/.filename.xml}.
259259
   */
260
  private static String getPreferencesFilename() {
261
    return format(
260
  private static File getPreferencesFile() {
261
    return new File( format(
262262
      "%s%s.%s.xml",
263263
      getProperty( "user.home" ),
264264
      separator,
265265
      APP_TITLE_LOWERCASE
266
    );
266
    ) );
267267
  }
268268
}
M src/main/java/com/keenwrite/dom/DocumentParser.java
1616
import javax.xml.xpath.XPathExpressionException;
1717
import javax.xml.xpath.XPathFactory;
18
import java.io.IOException;
19
import java.io.InputStream;
20
import java.io.StringReader;
21
import java.io.StringWriter;
18
import java.io.*;
2219
import java.nio.file.Path;
2320
import java.util.HashMap;
...
3633
  private static final String LOAD_EXTERNAL_DTD =
3734
    "http://apache.org/xml/features/nonvalidating/load-external-dtd";
35
  private static final String INDENT_AMOUNT =
36
    "{http://xml.apache.org/xslt}indent-amount";
3837
3938
  /**
...
6463
      sTransformer.setOutputProperty( OMIT_XML_DECLARATION, "yes" );
6564
      sTransformer.setOutputProperty( METHOD, "xml" );
66
      sTransformer.setOutputProperty( INDENT, "no" );
6765
      sTransformer.setOutputProperty( ENCODING, UTF_8.toString() );
66
      sTransformer.setOutputProperty( INDENT, "yes" );
67
      sTransformer.setOutputProperty( INDENT_AMOUNT, "2" );
6868
    } catch( final Exception ex ) {
6969
      clue( ex );
7070
    }
7171
  }
72
73
  /**
74
   * Use the {@code static} constants and methods, not an instance, at least
75
   * until an iterable sub-interface is written.
76
   */
77
  private DocumentParser() {}
7872
7973
  public static Document newDocument() {
...
9084
   */
9185
  public static Document parse( final String xml ) {
86
    assert xml != null;
87
9288
    final var input = new InputSource();
9389
...
10197
10298
      return sDocumentBuilder.newDocument();
99
    }
100
  }
101
102
  /**
103
   * Parses the given file contents into a document object model.
104
   *
105
   * @param doc The source XML document to parse.
106
   * @return The file as a document object model.
107
   * @throws IOException  Could not open the document.
108
   * @throws SAXException Could not read the XML file content.
109
   */
110
  public static Document parse( final File doc )
111
    throws IOException, SAXException {
112
    assert doc != null;
113
114
    try( final var in = new FileInputStream( doc ) ) {
115
      return parse( in );
103116
    }
104117
  }
105118
119
  /**
120
   * Parses the given file contents into a document object model. Callers
121
   * must close the stream.
122
   *
123
   * @param doc The source XML document to parse.
124
   * @return The {@link InputStream} converted to a document object model.
125
   * @throws IOException  Could not open the document.
126
   * @throws SAXException Could not read the XML file content.
127
   */
106128
  public static Document parse( final InputStream doc )
107129
    throws IOException, SAXException {
130
    assert doc != null;
131
108132
    return sDocumentBuilder.parse( doc );
109133
  }
...
117141
   * @param consumer The consumer to call for each matching document node.
118142
   */
119
  public static void walk(
143
  public static void visit(
120144
    final Document document,
121
    final String xpath,
145
    final CharSequence xpath,
122146
    final Consumer<Node> consumer ) {
123147
    assert document != null;
124148
    assert consumer != null;
125149
126150
    try {
127
      final var expr = lookupXPathExpression( xpath );
128
      final var nodes = (NodeList) expr.evaluate( document, NODESET );
151
      final var expr = compile( xpath );
152
      final var nodeSet = expr.evaluate( document, NODESET );
129153
130
      if( nodes != null ) {
154
      if( nodeSet instanceof NodeList nodes ) {
131155
        for( int i = 0, len = nodes.getLength(); i < len; i++ ) {
132156
          consumer.accept( nodes.item( i ) );
...
140164
  public static Node createMeta(
141165
    final Document document, final Map.Entry<String, String> entry ) {
166
    assert document != null;
167
    assert entry != null;
168
142169
    final var node = document.createElement( "meta" );
143170
144171
    node.setAttribute( "name", entry.getKey() );
145172
    node.setAttribute( "content", entry.getValue() );
146173
147174
    return node;
148175
  }
149176
150177
  public static String toString( final Document xhtml ) {
178
    assert xhtml != null;
179
151180
    try( final var writer = new StringWriter() ) {
152181
      final var domSource = new DOMSource( xhtml );
...
164193
  public static String transform( final Element root )
165194
    throws IOException, TransformerException {
195
    assert root != null;
196
166197
    try( final var writer = new StringWriter() ) {
167198
      sTransformer.transform(
...
180211
   * @throws Exception The file could not be processed.
181212
   */
182
  public static void sanitize( final Path path )
183
    throws Exception {
213
  public static void sanitize( final Path path ) throws Exception {
214
    assert path != null;
215
184216
    final var file = path.toFile();
185217
186218
    sTransformer.transform(
187219
      new DOMSource( sDocumentBuilder.parse( file ) ), new StreamResult( file )
188220
    );
189221
  }
190222
191223
  /**
192
   * Adorns the given document with {@code html}, {@code head}, and
193
   * {@code body} elements.
224
   * Converts a string into an {@link XPathExpression}, which may be used to
225
   * extract elements from a {@link Document} object model.
194226
   *
195
   * @param html The document to decorate.
196
   * @return A document with a typical HTML structure.
227
   * @param cs The string to convert to an {@link XPathExpression}.
228
   * @return {@code null} if there was an error compiling the xpath.
197229
   */
198
  public static String decorate( final String html ) {
199
    return
200
      "<html><head><title> </title><meta charset='utf8'/></head><body>"
201
        + html
202
        + "</body></html>";
203
  }
230
  public static XPathExpression compile( final CharSequence cs ) {
231
    assert cs != null;
204232
205
  private static XPathExpression lookupXPathExpression( final String xpath ) {
233
    final var xpath = cs.toString();
234
206235
    return sXpaths.computeIfAbsent( xpath, k -> {
207236
      try {
208237
        return sXpath.compile( xpath );
209238
      } catch( final XPathExpressionException ex ) {
210239
        clue( ex );
211240
        return null;
212241
      }
213242
    } );
214243
  }
244
245
  /**
246
   * Use the {@code static} constants and methods, not an instance, at least
247
   * until an iterable sub-interface is written.
248
   */
249
  private DocumentParser() {}
215250
}
216251
M src/main/java/com/keenwrite/editors/TextDefinition.java
1616
1717
  /**
18
   * Requests the interpolated version of the variable definitions.
18
   * Requests all variable definitions.
1919
   *
20
   * @return The definition map with all variables interpolated.
20
   * @return The definition map without interpolation.
2121
   */
2222
  Map<String, String> getDefinitions();
2323
2424
  /**
25
   * Requests that the visual representation be expanded to the given
26
   * node.
25
   * Requests that the visual representation be expanded to the given node.
2726
   *
2827
   * @param node Request expansion to this node.
M src/main/java/com/keenwrite/editors/TextEditor.java
2727
2828
  /**
29
   * Requests that styling be added to the document between the given
30
   * integer values.
31
   *
32
   * @param indexes Document offset where style is to start and end.
33
   * @param style   The style class to apply between the given offset indexes.
34
   */
35
  default void stylize( final IndexRange indexes, final String style ) {
36
  }
37
38
  /**
39
   * Requests that the most recent styling for the given style class be
40
   * removed from the document between the given integer values.
41
   */
42
  default void unstylize( final String style ) {
43
  }
44
45
  /**
4629
   * Returns the complete text for the specified paragraph index.
4730
   *
...
149132
   * Requests making the selected text, or word at caret, bold.
150133
   */
151
  default void bold() { }
134
  default void bold() {}
152135
153136
  /**
154137
   * Requests making the selected text, or word at caret, italic.
155138
   */
156
  default void italic() { }
139
  default void italic() {}
157140
158141
  /**
159142
   * Requests making the selected text, or word at caret, monospace.
160143
   */
161
  default void monospace() { }
144
  default void monospace() {}
162145
163146
  /**
164147
   * Requests making the selected text, or word at caret, a superscript.
165148
   */
166
  default void superscript() { }
149
  default void superscript() {}
167150
168151
  /**
169152
   * Requests making the selected text, or word at caret, a subscript.
170153
   */
171
  default void subscript() { }
154
  default void subscript() {}
172155
173156
  /**
174157
   * Requests making the selected text, or word at caret, struck.
175158
   */
176
  default void strikethrough() { }
159
  default void strikethrough() {}
177160
178161
  /**
179162
   * Requests making the selected text, or word at caret, a blockquote block.
180163
   */
181
  default void blockquote() { }
164
  default void blockquote() {}
182165
183166
  /**
184167
   * Requests making the selected text, or word at caret, inline code.
185168
   */
186
  default void code() { }
169
  default void code() {}
187170
188171
  /**
189172
   * Requests making the selected text, or word at caret, a fenced code block.
190173
   */
191
  default void fencedCodeBlock() { }
174
  default void fencedCodeBlock() {}
192175
193176
  /**
194177
   * Requests making the selected text, or word at caret, a heading.
195178
   *
196179
   * @param level The heading level to apply (typically 1 through 3).
197180
   */
198
  default void heading( final int level ) { }
181
  default void heading( final int level ) {}
199182
200183
  /**
201184
   * Requests making the selected text, or word at caret, an unordered list
202185
   * block.
203186
   */
204
  default void unorderedList() { }
187
  default void unorderedList() {}
205188
206189
  /**
207190
   * Requests making the selected text, or word at caret, an ordered list block.
208191
   */
209
  default void orderedList() { }
192
  default void orderedList() {}
210193
211194
  /**
212195
   * Requests making the selected text, or inserting at the caret, a
213196
   * horizontal rule.
214197
   */
215
  default void horizontalRule() { }
198
  default void horizontalRule() {}
199
200
  /**
201
   * Requests that styling be added to the document between the given
202
   * integer values.
203
   *
204
   * @param indexes Document offset where style is to start and end.
205
   * @param style   The style class to apply between the given offset indexes.
206
   */
207
  default void stylize( final IndexRange indexes, final String style ) {}
208
209
  /**
210
   * Requests that the most recent styling for the given style class be
211
   * removed from the document between the given integer values.
212
   */
213
  default void unstylize( final String style ) {}
216214
}
217215
M src/main/java/com/keenwrite/editors/definition/DefinitionEditor.java
55
import com.keenwrite.editors.TextDefinition;
66
import com.keenwrite.events.TextDefinitionFocusEvent;
7
import com.keenwrite.sigils.SigilOperator;
7
import com.keenwrite.processors.r.Engine;
88
import com.keenwrite.ui.tree.AltTreeView;
99
import com.keenwrite.ui.tree.TreeItemConverter;
...
6666
  private final Set<EventHandler<? super KeyEvent>> mKeyEventHandlers
6767
    = new HashSet<>();
68
69
  /**
70
   * File being edited by this editor instance.
71
   */
72
  private File mFile;
7368
7469
  private final Map<String, String> mDefinitions = new HashMap<>();
...
8580
   */
8681
  private final BooleanProperty mModified = new SimpleBooleanProperty();
82
83
  /**
84
   * File being edited by this editor instance, which may be renamed.
85
   */
86
  private File mFile;
8787
8888
  /**
8989
   * This is provided for unit tests that are not backed by files.
9090
   *
9191
   * @param treeTransformer Responsible for transforming the definitions into
9292
   *                        {@link TreeItem} instances.
93
   * @param operator        Defines how detect variables within values so
94
   *                        that they are interpolated when returning the
95
   *                        definitions.
9693
   */
9794
  public DefinitionEditor(
98
    final TreeTransformer treeTransformer,
99
    final SigilOperator operator ) {
100
    this( DEFINITION_DEFAULT, treeTransformer, operator );
95
    final TreeTransformer treeTransformer ) {
96
    this( DEFINITION_DEFAULT, treeTransformer );
10197
  }
10298
10399
  /**
104100
   * Constructs a definition pane with a given tree view root.
105101
   *
106102
   * @param file The file of definitions to maintain through the UI.
107103
   */
108104
  public DefinitionEditor(
109105
    final File file,
110
    final TreeTransformer treeTransformer,
111
    final SigilOperator operator ) {
106
    final TreeTransformer treeTransformer ) {
112107
    assert file != null;
113108
    assert treeTransformer != null;
...
129124
    buttonBar.setAlignment( CENTER );
130125
    buttonBar.setSpacing( UI_CONTROL_SPACING );
131
132126
    setTop( buttonBar );
133127
    setCenter( mTreeView );
134128
    setAlignment( buttonBar, TOP_CENTER );
129
135130
    mEncoding = open( mFile );
131
    updateDefinitions( getDefinitions(), getTreeView().getRoot() );
136132
137133
    // After the file is opened, watch for changes, not before. Otherwise,
138134
    // upon saving, users will be prompted to save a file that hasn't had
139135
    // any modifications (from their perspective).
140136
    addTreeChangeHandler( event -> {
141
      interpolate( operator );
142137
      mModified.set( true );
138
      updateDefinitions( getDefinitions(), getTreeView().getRoot() );
143139
    } );
140
  }
144141
145
    interpolate( operator );
142
  /**
143
   * Replaces the given list of variable definitions with a flat hierarchy
144
   * of the converted {@link TreeView} root.
145
   *
146
   * @param definitions The definition map to update.
147
   * @param root        The values to flatten then insert into the map.
148
   */
149
  private void updateDefinitions(
150
    final Map<String, String> definitions,
151
    final TreeItem<String> root ) {
152
    definitions.clear();
153
    definitions.putAll( TreeItemMapper.convert( root ) );
154
    Engine.clear();
146155
  }
147156
148157
  /**
149
   * Returns the variable definitions. This is called in critical parts of the
150
   * application, necessitating a cache. The cache is updated by calling
151
   * {@link #interpolate(SigilOperator)}, which happens upon tree modifications
152
   * via the editor or immediately after the definition file is loaded.
158
   * Returns the variable definitions.
153159
   *
154
   * @return The definition map with all variable references fully interpolated
155
   * and replaced.
160
   * @return The definition map.
156161
   */
157162
  @Override
...
181186
182187
      problem.ifPresentOrElse(
183
        ( node ) -> clue( "yaml.error.tree.form", node ),
188
        node -> clue( "yaml.error.tree.form", node ),
184189
        () -> result.append( mTreeTransformer.transform( root ) )
185190
      );
...
221226
  public void clearModifiedProperty() {
222227
    mModified.setValue( false );
223
  }
224
225
  private void interpolate( final SigilOperator operator ) {
226
    final var map = TreeItemMapper.convert( getTreeView().getRoot() );
227
228
    mDefinitions.clear();
229
    mDefinitions.putAll( map.interpolate( operator ) );
230228
  }
231229
M src/main/java/com/keenwrite/editors/definition/TreeItemMapper.java
33
44
import com.fasterxml.jackson.databind.JsonNode;
5
import com.keenwrite.util.InterpolatingMap;
65
import javafx.scene.control.TreeItem;
76
7
import java.util.HashMap;
88
import java.util.Iterator;
9
import java.util.Map;
910
import java.util.Stack;
1011
...
6667
   * @param root The topmost item in the tree.
6768
   */
68
  public static InterpolatingMap convert( final TreeItem<String> root ) {
69
    final var map = new InterpolatingMap();
69
  public static Map<String, String> convert( final TreeItem<String> root ) {
70
    final var map = new HashMap<String, String>();
7071
7172
    new TreeIterator( root ).forEachRemaining( item -> {
72
      if( item.isLeaf() ) {
73
      if( item.isLeaf() && item.getParent() != null ) {
7374
        map.put( toPath( item.getParent() ), item.getValue() );
7475
      }
...
8788
   */
8889
  public static <T> String toPath( TreeItem<T> node ) {
89
    assert node != null;
90
9190
    final var key = new StringBuilder( DEFAULT_KEY_LENGTH );
9291
    final var stack = new Stack<TreeItem<T>>();
M src/main/java/com/keenwrite/editors/markdown/MarkdownEditor.java
55
import com.keenwrite.constants.Constants;
66
import com.keenwrite.editors.TextEditor;
7
import com.keenwrite.events.TextEditorFocusEvent;
78
import com.keenwrite.io.MediaType;
89
import com.keenwrite.preferences.LocaleProperty;
...
3839
import static com.keenwrite.constants.Constants.*;
3940
import static com.keenwrite.events.StatusEvent.clue;
40
import static com.keenwrite.events.TextEditorFocusEvent.fireTextEditorFocus;
4141
import static com.keenwrite.io.MediaType.TEXT_MARKDOWN;
4242
import static com.keenwrite.io.MediaType.TEXT_R_MARKDOWN;
43
import static com.keenwrite.preferences.WorkspaceKeys.*;
43
import static com.keenwrite.preferences.AppKeys.*;
4444
import static java.lang.Character.isWhitespace;
4545
import static java.lang.String.format;
...
6565
  private static final Pattern PATTERN_AUTO_INDENT = Pattern.compile(
6666
    "(\\s*[*+-]\\s+|\\s*[0-9]+\\.\\s+|\\s+)(.*)" );
67
68
  private final Workspace mWorkspace;
6769
6870
  /**
...
7779
  private final VirtualizedScrollPane<StyleClassedTextArea> mScrollPane =
7880
    new VirtualizedScrollPane<>( mTextArea );
79
80
  /**
81
   *
82
   */
83
  private final TextEditorSpeller mSpeller = new TextEditorSpeller();
84
85
  private final Workspace mWorkspace;
8681
8782
  /**
8883
   * Tracks where the caret is located in this document. This offers observable
8984
   * properties for caret position changes.
9085
   */
9186
  private final Caret mCaret = createCaret( mTextArea );
87
88
  /**
89
   * For spell checking the document upon load and whenever it changes.
90
   */
91
  private final TextEditorSpeller mSpeller = new TextEditorSpeller();
9292
9393
  /**
...
139139
      mDirty.set( false );
140140
141
      // Prevent a caret position change from raising the dirty bits.
141
      // Prevent the subsequent caret position change from raising dirty bits.
142142
      mDirty.set( true );
143143
    } );
...
151151
    textArea.focusedProperty().addListener( ( c, o, n ) -> {
152152
      if( n != null && n ) {
153
        fireTextEditorFocus( this );
153
        TextEditorFocusEvent.fire( this );
154154
      }
155155
    } );
M src/main/java/com/keenwrite/events/StatusEvent.java
22
package com.keenwrite.events;
33
4
import com.keenwrite.MainApp;
4
import com.keenwrite.AppCommands;
55
66
import java.util.List;
7
import java.util.stream.Collectors;
87
98
import static com.keenwrite.Messages.get;
...
1918
 */
2019
public final class StatusEvent implements AppEvent {
21
  private static final String PACKAGE_NAME = MainApp.class.getPackageName();
20
  /**
21
   * Reference a class in the top-level package that doesn't depend on any
22
   * JavaFX APIs.
23
   */
24
  private static final String PACKAGE_NAME = AppCommands.class.getPackageName();
2225
2326
  private static final String ENGLISHIFY =
...
7376
        .takeWhile( StatusEvent::filter )
7477
        .limit( 10 )
75
        .collect( Collectors.toList() )
78
        .toList()
7679
        .forEach( e -> sb.append( e.toString() ).append( NEWLINE ) );
7780
    }
M src/main/java/com/keenwrite/events/TextEditorFocusEvent.java
1515
   * @param editor The instance of editor that has gained input focus.
1616
   */
17
  public static void fireTextEditorFocus( final TextEditor editor ) {
17
  public static void fire( final TextEditor editor ) {
1818
    new TextEditorFocusEvent( editor ).publish();
1919
  }
M src/main/java/com/keenwrite/io/MediaType.java
9898
  /*
9999
   * Document types for editing or displaying documents, mix of standard and
100
   * application-specific.
100
   * application-specific. The order that these are declared reflect in the
101
   * ordinal value used during comparisons.
101102
   */
102
  TEXT_HTML( TEXT, "html" ),
103
  TEXT_MARKDOWN( TEXT, "markdown" ),
103
  TEXT_YAML( TEXT, "yaml" ),
104104
  TEXT_PLAIN( TEXT, "plain" ),
105
  TEXT_MARKDOWN( TEXT, "markdown" ),
105106
  TEXT_R_MARKDOWN( TEXT, "R+markdown" ),
107
  TEXT_PROPERTIES( TEXT, "x-java-properties" ),
108
  TEXT_HTML( TEXT, "html" ),
106109
  TEXT_XHTML( TEXT, "xhtml+xml" ),
107110
  TEXT_XML( TEXT, "xml" ),
108
  TEXT_YAML( TEXT, "yaml" ),
109111
110112
  /*
M src/main/java/com/keenwrite/io/MediaTypeExtension.java
4646
  MEDIA_TEXT_PLAIN( TEXT_PLAIN, of( "txt", "asc", "ascii", "text", "utxt" ) ),
4747
  MEDIA_TEXT_R_MARKDOWN( TEXT_R_MARKDOWN, of( "Rmd" ) ),
48
  MEDIA_TEXT_PROPERTIES( TEXT_PROPERTIES, of( "properties" ) ),
4849
  MEDIA_TEXT_XHTML( TEXT_XHTML, of( "xhtml" ) ),
4950
  MEDIA_TEXT_XML( TEXT_XML ),
5051
  MEDIA_TEXT_YAML( TEXT_YAML, of( "yaml", "yml" ) ),
5152
5253
  MEDIA_UNDEFINED( UNDEFINED, of( "undefined" ) );
54
55
  /**
56
   * Returns the {@link MediaTypeExtension} that matches the given media type.
57
   *
58
   * @param mediaType The media type to find.
59
   * @return The correlated value or {@link #MEDIA_UNDEFINED} if not found.
60
   */
61
  public static MediaTypeExtension valueFrom( final MediaType mediaType ) {
62
    for( final var type : values() ) {
63
      if( type.isMediaType( mediaType ) ) {
64
        return type;
65
      }
66
    }
67
68
    return MEDIA_UNDEFINED;
69
  }
70
71
  /**
72
   * Returns the {@link MediaType} associated with the given file name
73
   * extension. The extension must not contain a period.
74
   *
75
   * @param extension File name extension, case insensitive, {@code null}-safe.
76
   * @return The associated {@link MediaType} as defined by IANA.
77
   */
78
  static MediaType getMediaType( final String extension ) {
79
    final var sanitized = sanitize( extension );
80
81
    for( final var mediaType : MediaTypeExtension.values() ) {
82
      if( mediaType.isType( sanitized ) ) {
83
        return mediaType.getMediaType();
84
      }
85
    }
86
87
    return UNDEFINED;
88
  }
89
90
  private static String sanitize( final String extension ) {
91
    return extension == null ? "" : extension.toLowerCase();
92
  }
5393
5494
  private final MediaType mMediaType;
...
95135
  public String getExtension() {
96136
    return mExtensions.get( 0 );
97
  }
98
99
  /**
100
   * Returns the {@link MediaTypeExtension} that matches the given media type.
101
   *
102
   * @param mediaType The media type to find.
103
   * @return The correlated value or {@link #MEDIA_UNDEFINED} if not found.
104
   */
105
  public static MediaTypeExtension valueFrom( final MediaType mediaType ) {
106
    for( final var type : values() ) {
107
      if( type.isMediaType( mediaType ) ) {
108
        return type;
109
      }
110
    }
111
112
    return MEDIA_UNDEFINED;
113137
  }
114138
115139
  boolean isMediaType( final MediaType mediaType ) {
116140
    return mMediaType == mediaType;
117
  }
118
119
  /**
120
   * Returns the {@link MediaType} associated with the given file name
121
   * extension. The extension must not contain a period.
122
   *
123
   * @param extension File name extension, case insensitive, {@code null}-safe.
124
   * @return The associated {@link MediaType} as defined by IANA.
125
   */
126
  static MediaType getMediaType( final String extension ) {
127
    final var sanitized = sanitize( extension );
128
129
    for( final var mediaType : MediaTypeExtension.values() ) {
130
      if( mediaType.isType( sanitized ) ) {
131
        return mediaType.getMediaType();
132
      }
133
    }
134
135
    return UNDEFINED;
136141
  }
137142
...
144149
145150
    return false;
146
  }
147
148
  private static String sanitize( final String extension ) {
149
    return extension == null ? "" : extension.toLowerCase();
150151
  }
151152
M src/main/java/com/keenwrite/io/SysFile.java
2020
2121
  /**
22
   * Creates a new instance for a given file name.
23
   *
24
   * @param pathname File name to represent for subsequent operations.
25
   */
26
  public SysFile( final String pathname ) {
27
    super( pathname );
28
  }
29
30
  /**
3122
   * For a file name that represents an executable (without an extension)
3223
   * file, this determines whether the executable is found in the PATH
...
5546
      }
5647
    );
48
  }
49
50
  /**
51
   * Creates a new instance for a given file name.
52
   *
53
   * @param pathname File name to represent for subsequent operations.
54
   */
55
  public SysFile( final String pathname ) {
56
    super( pathname );
5757
  }
5858
}
A src/main/java/com/keenwrite/preferences/AppKeys.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.preferences;
3
4
import static com.keenwrite.preferences.Key.key;
5
6
/**
7
 * Responsible for defining constants used throughout the application that
8
 * represent persisted preferences.
9
 */
10
public final class AppKeys {
11
  //@formatter:off
12
  private static final Key KEY_ROOT = key( "workspace" );
13
14
  public static final Key KEY_META = key( KEY_ROOT, "meta" );
15
  public static final Key KEY_META_NAME = key( KEY_META, "name" );
16
  public static final Key KEY_META_VERSION = key( KEY_META, "version" );
17
18
  public static final Key KEY_DOC = key( KEY_ROOT, "document" );
19
  public static final Key KEY_DOC_META = key( KEY_DOC, "meta" );
20
21
  public static final Key KEY_EDITOR = key( KEY_ROOT, "editor" );
22
  public static final Key KEY_EDITOR_AUTOSAVE = key( KEY_EDITOR, "autosave" );
23
24
  public static final Key KEY_R = key( KEY_ROOT, "r" );
25
  public static final Key KEY_R_SCRIPT = key( KEY_R, "script" );
26
  public static final Key KEY_R_DIR = key( KEY_R, "dir" );
27
  public static final Key KEY_R_DELIM = key( KEY_R, "delimiter" );
28
  public static final Key KEY_R_DELIM_BEGAN = key( KEY_R_DELIM, "began" );
29
  public static final Key KEY_R_DELIM_ENDED = key( KEY_R_DELIM, "ended" );
30
31
  public static final Key KEY_IMAGES = key( KEY_ROOT, "images" );
32
  public static final Key KEY_IMAGES_DIR = key( KEY_IMAGES, "dir" );
33
  public static final Key KEY_IMAGES_ORDER = key( KEY_IMAGES, "order" );
34
  public static final Key KEY_IMAGES_RESIZE = key( KEY_IMAGES, "resize" );
35
  public static final Key KEY_IMAGES_SERVER = key( KEY_IMAGES, "server" );
36
37
  public static final Key KEY_DEF = key( KEY_ROOT, "definition" );
38
  public static final Key KEY_DEF_PATH = key( KEY_DEF, "path" );
39
  public static final Key KEY_DEF_DELIM = key( KEY_DEF, "delimiter" );
40
  public static final Key KEY_DEF_DELIM_BEGAN = key( KEY_DEF_DELIM, "began" );
41
  public static final Key KEY_DEF_DELIM_ENDED = key( KEY_DEF_DELIM, "ended" );
42
43
  public static final Key KEY_UI = key( KEY_ROOT, "ui" );
44
45
  public static final Key KEY_UI_RECENT = key( KEY_UI, "recent" );
46
  public static final Key KEY_UI_RECENT_DIR = key( KEY_UI_RECENT, "dir" );
47
  public static final Key KEY_UI_RECENT_DOCUMENT = key( KEY_UI_RECENT, "document" );
48
  public static final Key KEY_UI_RECENT_DEFINITION = key( KEY_UI_RECENT, "definition" );
49
  public static final Key KEY_UI_RECENT_EXPORT = key( KEY_UI_RECENT, "export" );
50
  public static final Key KEY_UI_RECENT_OPEN = key( KEY_UI_RECENT, "files" );
51
  public static final Key KEY_UI_RECENT_OPEN_PATH = key( KEY_UI_RECENT_OPEN, "path" );
52
53
  public static final Key KEY_UI_FONT = key( KEY_UI, "font" );
54
  public static final Key KEY_UI_FONT_EDITOR = key( KEY_UI_FONT, "editor" );
55
  public static final Key KEY_UI_FONT_EDITOR_NAME = key( KEY_UI_FONT_EDITOR, "name" );
56
  public static final Key KEY_UI_FONT_EDITOR_SIZE = key( KEY_UI_FONT_EDITOR, "size" );
57
  public static final Key KEY_UI_FONT_PREVIEW = key( KEY_UI_FONT, "preview" );
58
  public static final Key KEY_UI_FONT_PREVIEW_NAME = key( KEY_UI_FONT_PREVIEW, "name" );
59
  public static final Key KEY_UI_FONT_PREVIEW_SIZE = key( KEY_UI_FONT_PREVIEW, "size" );
60
  public static final Key KEY_UI_FONT_PREVIEW_MONO = key( KEY_UI_FONT_PREVIEW, "mono" );
61
  public static final Key KEY_UI_FONT_PREVIEW_MONO_NAME = key( KEY_UI_FONT_PREVIEW_MONO, "name" );
62
  public static final Key KEY_UI_FONT_PREVIEW_MONO_SIZE = key( KEY_UI_FONT_PREVIEW_MONO, "size" );
63
64
  public static final Key KEY_UI_WINDOW = key( KEY_UI, "window" );
65
  public static final Key KEY_UI_WINDOW_X = key( KEY_UI_WINDOW, "x" );
66
  public static final Key KEY_UI_WINDOW_Y = key( KEY_UI_WINDOW, "y" );
67
  public static final Key KEY_UI_WINDOW_W = key( KEY_UI_WINDOW, "width" );
68
  public static final Key KEY_UI_WINDOW_H = key( KEY_UI_WINDOW, "height" );
69
  public static final Key KEY_UI_WINDOW_MAX = key( KEY_UI_WINDOW, "maximized" );
70
  public static final Key KEY_UI_WINDOW_FULL = key( KEY_UI_WINDOW, "full" );
71
72
  public static final Key KEY_UI_SKIN = key( KEY_UI, "skin" );
73
  public static final Key KEY_UI_SKIN_SELECTION = key( KEY_UI_SKIN, "selection" );
74
  public static final Key KEY_UI_SKIN_CUSTOM = key( KEY_UI_SKIN, "custom" );
75
76
  public static final Key KEY_UI_PREVIEW = key( KEY_UI, "preview" );
77
  public static final Key KEY_UI_PREVIEW_STYLESHEET = key( KEY_UI_PREVIEW, "stylesheet" );
78
79
  public static final Key KEY_LANGUAGE = key( KEY_ROOT, "language" );
80
  public static final Key KEY_LANGUAGE_LOCALE = key( KEY_LANGUAGE, "locale" );
81
82
  public static final Key KEY_TYPESET = key( KEY_ROOT, "typeset" );
83
  public static final Key KEY_TYPESET_CONTEXT = key( KEY_TYPESET, "context" );
84
  public static final Key KEY_TYPESET_CONTEXT_THEMES = key( KEY_TYPESET_CONTEXT, "themes" );
85
  public static final Key KEY_TYPESET_CONTEXT_THEMES_PATH = key( KEY_TYPESET_CONTEXT_THEMES, "path" );
86
  public static final Key KEY_TYPESET_CONTEXT_THEME_SELECTION = key( KEY_TYPESET_CONTEXT_THEMES, "selection" );
87
  public static final Key KEY_TYPESET_CONTEXT_CLEAN = key( KEY_TYPESET_CONTEXT, "clean" );
88
  public static final Key KEY_TYPESET_TYPOGRAPHY = key( KEY_TYPESET, "typography" );
89
  public static final Key KEY_TYPESET_TYPOGRAPHY_QUOTES = key( KEY_TYPESET_TYPOGRAPHY, "quotes" );
90
  //@formatter:on
91
92
  /**
93
   * Only for constants, do not instantiate.
94
   */
95
  private AppKeys() { }
96
}
197
M src/main/java/com/keenwrite/preferences/Key.java
22
package com.keenwrite.preferences;
33
4
import java.util.Stack;
5
import java.util.function.Consumer;
6
47
/**
58
 * Responsible for creating a type hierarchy of preference storage keys.
69
 */
710
public class Key {
811
  private final Key mParent;
912
  private final String mName;
10
11
  private Key( final Key parent, final String name ) {
12
    mParent = parent;
13
    mName = name;
14
  }
1513
1614
  /**
1715
   * Returns a new key with no parent.
1816
   *
1917
   * @param name The key name, never {@code null}.
2018
   * @return The new {@link Key} instance with a name but no parent.
2119
   */
2220
  public static Key key( final String name ) {
23
    assert name != null && !name.isEmpty();
2421
    return key( null, name );
2522
  }
...
3431
   */
3532
  public static Key key( final Key parent, final String name ) {
36
    assert name != null && !name.isEmpty();
3733
    return new Key( parent, name );
3834
  }
3935
40
  private Key parent() {
36
  private Key( final Key parent, final String name ) {
37
    assert name != null;
38
    assert !name.isBlank();
39
40
    mParent = parent;
41
    mName = name;
42
  }
43
44
  /**
45
   * Answers whether more {@link Key}s exist above this one in the hierarchy.
46
   *
47
   * @return {@code true} means this {@link Key} has a parent {@link Key}.
48
   */
49
  public boolean hasParent() {
50
    return mParent != null;
51
  }
52
53
  /**
54
   * Visits every key in the hierarchy, starting at the topmost {@link Key} and
55
   * ending with the current {@link Key}.
56
   *
57
   * @param consumer  Receives the name of each visited node.
58
   * @param separator Characters to insert between each node.
59
   */
60
  public void walk( final Consumer<String> consumer, final String separator ) {
61
    var key = this;
62
63
    final var stack = new Stack<String>();
64
65
    while( key != null ) {
66
      stack.push( key.name() );
67
      key = key.parent();
68
    }
69
70
    var sep = "";
71
72
    while( !stack.empty() ) {
73
      consumer.accept( sep + stack.pop() );
74
      sep = separator;
75
    }
76
  }
77
78
  public void walk( final Consumer<String> consumer ) {
79
    walk( consumer, "" );
80
  }
81
82
  public Key parent() {
4183
    return mParent;
4284
  }
4385
44
  private String name() {
86
  public String name() {
4587
    return mName;
4688
  }
4789
4890
  /**
4991
   * Returns a dot-separated path representing the key's name.
5092
   *
51
   * @return The recursively derived dot-separated key name.
93
   * @return The dot-separated key name.
5294
   */
5395
  @Override
5496
  public String toString() {
55
    return parent() == null ? name() : parent().toString() + '.' + name();
97
    final var sb = new StringBuilder( 128 );
98
99
    walk( sb::append, "." );
100
101
    return sb.toString();
56102
  }
57103
}
A src/main/java/com/keenwrite/preferences/KeyConfiguration.java
1
package com.keenwrite.preferences;
2
3
import com.keenwrite.cmdline.Arguments;
4
5
import java.io.File;
6
7
/**
8
 * Responsible for maintaining key-value pairs for user-defined setting
9
 * values. When processing a document, various settings are used to configure
10
 * the processing behaviour. This interface represents an abstraction that
11
 * can be used by the processors without having to depend on a specific
12
 * implementation, such as {@link Arguments} or a {@link Workspace}.
13
 */
14
public interface KeyConfiguration {
15
16
  /**
17
   * Returns a {@link String} value associated with the given {@link Key}.
18
   *
19
   * @param key The {@link Key} associated with a value.
20
   * @return The value associated with the given {@link Key}.
21
   */
22
  String getString( final Key key );
23
24
  /**
25
   * Returns a {@link Boolean} value associated with the given {@link Key}.
26
   *
27
   * @param key The {@link Key} associated with a value.
28
   * @return The value associated with the given {@link Key}.
29
   */
30
  boolean getBoolean( final Key key );
31
32
  /**
33
   * Returns an {@link Integer} value associated with the given {@link Key}.
34
   *
35
   * @param key The {@link Key} associated with a value.
36
   * @return The value associated with the given {@link Key}.
37
   */
38
  int getInteger( final Key key );
39
40
  /**
41
   * Returns a {@link Double} value associated with the given {@link Key}.
42
   *
43
   * @param key The {@link Key} associated with a value.
44
   * @return The value associated with the given {@link Key}.
45
   */
46
  double getDouble( final Key key );
47
48
  /**
49
   * Returns a {@link File} value associated with the given {@link Key}.
50
   *
51
   * @param key The {@link Key} associated with a value.
52
   * @return The value associated with the given {@link Key}.
53
   */
54
  File getFile( final Key key );
55
}
156
M src/main/java/com/keenwrite/preferences/LocaleScripts.java
8888
  private static Map<String, String> m( final String v, final String... k ) {
8989
    final var map = new HashMap<String, String>();
90
    asList( k ).forEach( ( key ) -> map.put( key, v ) );
90
    asList( k ).forEach( key -> map.put( key, v ) );
9191
    return Collections.unmodifiableMap( map );
9292
  }
M src/main/java/com/keenwrite/preferences/PreferencesController.java
44
import com.dlsc.formsfx.model.structure.StringField;
55
import com.dlsc.preferencesfx.PreferencesFx;
6
import com.dlsc.preferencesfx.PreferencesFxEvent;
7
import com.dlsc.preferencesfx.model.Category;
8
import com.dlsc.preferencesfx.model.Group;
9
import com.dlsc.preferencesfx.model.Setting;
10
import com.dlsc.preferencesfx.util.StorageHandler;
11
import com.dlsc.preferencesfx.view.NavigationView;
12
import javafx.beans.property.*;
13
import javafx.event.EventHandler;
14
import javafx.scene.Node;
15
import javafx.scene.control.Button;
16
import javafx.scene.control.DialogPane;
17
import javafx.scene.control.Label;
18
import org.controlsfx.control.MasterDetailPane;
19
20
import java.io.File;
21
22
import static com.dlsc.formsfx.model.structure.Field.ofStringType;
23
import static com.dlsc.preferencesfx.PreferencesFxEvent.EVENT_PREFERENCES_SAVED;
24
import static com.keenwrite.Messages.get;
25
import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG;
26
import static com.keenwrite.preferences.LocaleProperty.localeListProperty;
27
import static com.keenwrite.preferences.SkinProperty.skinListProperty;
28
import static com.keenwrite.preferences.WorkspaceKeys.*;
29
import static javafx.scene.control.ButtonType.CANCEL;
30
import static javafx.scene.control.ButtonType.OK;
31
32
/**
33
 * Provides the ability for users to configure their preferences. This links
34
 * the {@link Workspace} model with the {@link PreferencesFx} view, in MVC.
35
 */
36
@SuppressWarnings( "SameParameterValue" )
37
public final class PreferencesController {
38
39
  private final Workspace mWorkspace;
40
  private final PreferencesFx mPreferencesFx;
41
42
  public PreferencesController( final Workspace workspace ) {
43
    mWorkspace = workspace;
44
45
    // All properties must be initialized before creating the dialog.
46
    mPreferencesFx = createPreferencesFx();
47
48
    initKeyEventHandler( mPreferencesFx );
49
  }
50
51
  /**
52
   * Display the user preferences settings dialog (non-modal).
53
   */
54
  public void show() {
55
    getPreferencesFx().show( false );
56
  }
57
58
  /**
59
   * Call to persist the settings. Strictly speaking, this could watch on
60
   * all values for external changes then save automatically.
61
   */
62
  public void save() {
63
    getPreferencesFx().saveSettings();
64
  }
65
66
  /**
67
   * Delegates to the {@link PreferencesFx} event handler for monitoring
68
   * save events.
69
   *
70
   * @param eventHandler The handler to call when the preferences are saved.
71
   */
72
  public void addSaveEventHandler(
73
    final EventHandler<? super PreferencesFxEvent> eventHandler ) {
74
    getPreferencesFx().addEventHandler( EVENT_PREFERENCES_SAVED, eventHandler );
75
  }
76
77
  private StringField createFontNameField(
78
    final StringProperty fontName, final DoubleProperty fontSize ) {
79
    final var control = new SimpleFontControl( "Change" );
80
    control.fontSizeProperty().addListener( ( c, o, n ) -> {
81
      if( n != null ) {
82
        fontSize.set( n.doubleValue() );
83
      }
84
    } );
85
    return ofStringType( fontName ).render( control );
86
  }
87
88
  /**
89
   * Creates the preferences dialog based using {@link XmlStorageHandler} and
90
   * numerous {@link Category} objects.
91
   *
92
   * @return A component for editing preferences.
93
   * @throws RuntimeException Could not construct the {@link PreferencesFx}
94
   *                          object (e.g., illegal access permissions,
95
   *                          unmapped XML resource).
96
   */
97
  private PreferencesFx createPreferencesFx() {
98
    return PreferencesFx.of( createStorageHandler(), createCategories() )
99
                        .instantPersistent( false )
100
                        .dialogIcon( ICON_DIALOG );
101
  }
102
103
  private StorageHandler createStorageHandler() {
104
    return new XmlStorageHandler();
105
  }
106
107
  private Category[] createCategories() {
108
    return new Category[]{
109
      Category.of(
110
        get( KEY_DOC ),
111
        Group.of(
112
          get( KEY_DOC_TITLE ),
113
          Setting.of( label( KEY_DOC_TITLE ) ),
114
          Setting.of( title( KEY_DOC_TITLE ),
115
                      stringProperty( KEY_DOC_TITLE ) )
116
        ),
117
        Group.of(
118
          get( KEY_DOC_AUTHOR ),
119
          Setting.of( label( KEY_DOC_AUTHOR ) ),
120
          Setting.of( title( KEY_DOC_AUTHOR ),
121
                      stringProperty( KEY_DOC_AUTHOR ) )
122
        ),
123
        Group.of(
124
          get( KEY_DOC_BYLINE ),
125
          Setting.of( label( KEY_DOC_BYLINE ) ),
126
          Setting.of( title( KEY_DOC_BYLINE ),
127
                      stringProperty( KEY_DOC_BYLINE ) )
128
        ),
129
        Group.of(
130
          get( KEY_DOC_ADDRESS ),
131
          Setting.of( label( KEY_DOC_ADDRESS ) ),
132
          createMultilineSetting( "Address", KEY_DOC_ADDRESS )
133
        ),
134
        Group.of(
135
          get( KEY_DOC_PHONE ),
136
          Setting.of( label( KEY_DOC_PHONE ) ),
137
          Setting.of( title( KEY_DOC_PHONE ),
138
                      stringProperty( KEY_DOC_PHONE ) )
139
        ),
140
        Group.of(
141
          get( KEY_DOC_EMAIL ),
142
          Setting.of( label( KEY_DOC_EMAIL ) ),
143
          Setting.of( title( KEY_DOC_EMAIL ),
144
                      stringProperty( KEY_DOC_EMAIL ) )
145
        ),
146
        Group.of(
147
          get( KEY_DOC_KEYWORDS ),
148
          Setting.of( label( KEY_DOC_KEYWORDS ) ),
149
          Setting.of( title( KEY_DOC_KEYWORDS ),
150
                      stringProperty( KEY_DOC_KEYWORDS ) )
151
        ),
152
        Group.of(
153
          get( KEY_DOC_COPYRIGHT ),
154
          Setting.of( label( KEY_DOC_COPYRIGHT ) ),
155
          Setting.of( title( KEY_DOC_COPYRIGHT ),
156
                      stringProperty( KEY_DOC_COPYRIGHT ) )
157
        ),
158
        Group.of(
159
          get( KEY_DOC_DATE ),
160
          Setting.of( label( KEY_DOC_DATE ) ),
161
          Setting.of( title( KEY_DOC_DATE ),
162
                      stringProperty( KEY_DOC_DATE ) )
163
        )
164
      ),
165
      Category.of(
166
        get( KEY_TYPESET ),
167
        Group.of(
168
          get( KEY_TYPESET_CONTEXT ),
169
          Setting.of( label( KEY_TYPESET_CONTEXT_THEMES_PATH ) ),
170
          Setting.of( title( KEY_TYPESET_CONTEXT_THEMES_PATH ),
171
                      fileProperty( KEY_TYPESET_CONTEXT_THEMES_PATH ), true ),
172
          Setting.of( label( KEY_TYPESET_CONTEXT_CLEAN ) ),
173
          Setting.of( title( KEY_TYPESET_CONTEXT_CLEAN ),
174
                      booleanProperty( KEY_TYPESET_CONTEXT_CLEAN ) )
175
        ),
176
        Group.of(
177
          get( KEY_TYPESET_TYPOGRAPHY ),
178
          Setting.of( label( KEY_TYPESET_TYPOGRAPHY_QUOTES ) ),
179
          Setting.of( title( KEY_TYPESET_TYPOGRAPHY_QUOTES ),
180
                      booleanProperty( KEY_TYPESET_TYPOGRAPHY_QUOTES ) )
181
        )
182
      ),
183
      Category.of(
184
        get( KEY_EDITOR ),
185
        Group.of(
186
          get( KEY_EDITOR_AUTOSAVE ),
187
          Setting.of( label( KEY_EDITOR_AUTOSAVE ) ),
188
          Setting.of( title( KEY_EDITOR_AUTOSAVE ),
189
                      integerProperty( KEY_EDITOR_AUTOSAVE ) )
190
        )
191
      ),
192
      Category.of(
193
        get( KEY_R ),
194
        Group.of(
195
          get( KEY_R_DIR ),
196
          Setting.of( label( KEY_R_DIR,
197
                             stringProperty( KEY_DEF_DELIM_BEGAN ).get(),
198
                             stringProperty( KEY_DEF_DELIM_ENDED ).get() ) ),
199
          Setting.of( title( KEY_R_DIR ),
200
                      fileProperty( KEY_R_DIR ), true )
201
        ),
202
        Group.of(
203
          get( KEY_R_SCRIPT ),
204
          Setting.of( label( KEY_R_SCRIPT ) ),
205
          createMultilineSetting( "Script", KEY_R_SCRIPT )
206
        ),
207
        Group.of(
208
          get( KEY_R_DELIM_BEGAN ),
209
          Setting.of( label( KEY_R_DELIM_BEGAN ) ),
210
          Setting.of( title( KEY_R_DELIM_BEGAN ),
211
                      stringProperty( KEY_R_DELIM_BEGAN ) )
212
        ),
213
        Group.of(
214
          get( KEY_R_DELIM_ENDED ),
215
          Setting.of( label( KEY_R_DELIM_ENDED ) ),
216
          Setting.of( title( KEY_R_DELIM_ENDED ),
217
                      stringProperty( KEY_R_DELIM_ENDED ) )
218
        )
219
      ),
220
      Category.of(
221
        get( KEY_IMAGES ),
222
        Group.of(
223
          get( KEY_IMAGES_DIR ),
224
          Setting.of( label( KEY_IMAGES_DIR ) ),
225
          Setting.of( title( KEY_IMAGES_DIR ),
226
                      fileProperty( KEY_IMAGES_DIR ), true )
227
        ),
228
        Group.of(
229
          get( KEY_IMAGES_ORDER ),
230
          Setting.of( label( KEY_IMAGES_ORDER ) ),
231
          Setting.of( title( KEY_IMAGES_ORDER ),
232
                      stringProperty( KEY_IMAGES_ORDER ) )
233
        ),
234
        Group.of(
235
          get( KEY_IMAGES_RESIZE ),
236
          Setting.of( label( KEY_IMAGES_RESIZE ) ),
237
          Setting.of( title( KEY_IMAGES_RESIZE ),
238
                      booleanProperty( KEY_IMAGES_RESIZE ) )
239
        ),
240
        Group.of(
241
          get( KEY_IMAGES_SERVER ),
242
          Setting.of( label( KEY_IMAGES_SERVER ) ),
243
          Setting.of( title( KEY_IMAGES_SERVER ),
244
                      stringProperty( KEY_IMAGES_SERVER ) )
245
        )
246
      ),
247
      Category.of(
248
        get( KEY_DEF ),
249
        Group.of(
250
          get( KEY_DEF_PATH ),
251
          Setting.of( label( KEY_DEF_PATH ) ),
252
          Setting.of( title( KEY_DEF_PATH ),
253
                      fileProperty( KEY_DEF_PATH ), false )
254
        ),
255
        Group.of(
256
          get( KEY_DEF_DELIM_BEGAN ),
257
          Setting.of( label( KEY_DEF_DELIM_BEGAN ) ),
258
          Setting.of( title( KEY_DEF_DELIM_BEGAN ),
259
                      stringProperty( KEY_DEF_DELIM_BEGAN ) )
260
        ),
261
        Group.of(
262
          get( KEY_DEF_DELIM_ENDED ),
263
          Setting.of( label( KEY_DEF_DELIM_ENDED ) ),
264
          Setting.of( title( KEY_DEF_DELIM_ENDED ),
265
                      stringProperty( KEY_DEF_DELIM_ENDED ) )
266
        )
267
      ),
268
      Category.of(
269
        get( KEY_UI_FONT ),
270
        Group.of(
271
          get( KEY_UI_FONT_EDITOR ),
272
          Setting.of( label( KEY_UI_FONT_EDITOR_NAME ) ),
273
          Setting.of( title( KEY_UI_FONT_EDITOR_NAME ),
274
                      createFontNameField(
275
                        stringProperty( KEY_UI_FONT_EDITOR_NAME ),
276
                        doubleProperty( KEY_UI_FONT_EDITOR_SIZE ) ),
277
                      stringProperty( KEY_UI_FONT_EDITOR_NAME ) ),
278
          Setting.of( label( KEY_UI_FONT_EDITOR_SIZE ) ),
279
          Setting.of( title( KEY_UI_FONT_EDITOR_SIZE ),
280
                      doubleProperty( KEY_UI_FONT_EDITOR_SIZE ) )
281
        ),
282
        Group.of(
283
          get( KEY_UI_FONT_PREVIEW ),
284
          Setting.of( label( KEY_UI_FONT_PREVIEW_NAME ) ),
285
          Setting.of( title( KEY_UI_FONT_PREVIEW_NAME ),
286
                      createFontNameField(
287
                        stringProperty( KEY_UI_FONT_PREVIEW_NAME ),
288
                        doubleProperty( KEY_UI_FONT_PREVIEW_SIZE ) ),
289
                      stringProperty( KEY_UI_FONT_PREVIEW_NAME ) ),
290
          Setting.of( label( KEY_UI_FONT_PREVIEW_SIZE ) ),
291
          Setting.of( title( KEY_UI_FONT_PREVIEW_SIZE ),
292
                      doubleProperty( KEY_UI_FONT_PREVIEW_SIZE ) ),
293
          Setting.of( label( KEY_UI_FONT_PREVIEW_MONO_NAME ) ),
294
          Setting.of( title( KEY_UI_FONT_PREVIEW_MONO_NAME ),
295
                      createFontNameField(
296
                        stringProperty( KEY_UI_FONT_PREVIEW_MONO_NAME ),
297
                        doubleProperty( KEY_UI_FONT_PREVIEW_MONO_SIZE ) ),
298
                      stringProperty( KEY_UI_FONT_PREVIEW_MONO_NAME ) ),
299
          Setting.of( label( KEY_UI_FONT_PREVIEW_MONO_SIZE ) ),
300
          Setting.of( title( KEY_UI_FONT_PREVIEW_MONO_SIZE ),
301
                      doubleProperty( KEY_UI_FONT_PREVIEW_MONO_SIZE ) )
302
        )
303
      ),
304
      Category.of(
305
        get( KEY_UI_SKIN ),
306
        Group.of(
307
          get( KEY_UI_SKIN_SELECTION ),
308
          Setting.of( label( KEY_UI_SKIN_SELECTION ) ),
309
          Setting.of( title( KEY_UI_SKIN_SELECTION ),
310
                      skinListProperty(),
311
                      skinProperty( KEY_UI_SKIN_SELECTION ) )
312
        ),
313
        Group.of(
314
          get( KEY_UI_SKIN_CUSTOM ),
315
          Setting.of( label( KEY_UI_SKIN_CUSTOM ) ),
316
          Setting.of( title( KEY_UI_SKIN_CUSTOM ),
317
                      fileProperty( KEY_UI_SKIN_CUSTOM ), false )
318
        )
319
      ),
320
      Category.of(
321
        get( KEY_UI_PREVIEW ),
322
        Group.of(
323
          get( KEY_UI_PREVIEW_STYLESHEET ),
324
          Setting.of( label( KEY_UI_PREVIEW_STYLESHEET ) ),
325
          Setting.of( title( KEY_UI_PREVIEW_STYLESHEET ),
326
                      fileProperty( KEY_UI_PREVIEW_STYLESHEET ), false )
327
        )
328
      ),
329
      Category.of(
330
        get( KEY_LANGUAGE ),
331
        Group.of(
332
          get( KEY_LANGUAGE_LOCALE ),
333
          Setting.of( label( KEY_LANGUAGE_LOCALE ) ),
334
          Setting.of( title( KEY_LANGUAGE_LOCALE ),
335
                      localeListProperty(),
336
                      localeProperty( KEY_LANGUAGE_LOCALE ) )
337
        )
338
      )};
339
  }
340
341
  @SuppressWarnings( "unchecked" )
342
  private Setting<StringField, StringProperty> createMultilineSetting(
343
    final String description, final Key property ) {
344
    final Setting<StringField, StringProperty> setting =
345
      Setting.of( description, stringProperty( property ) );
346
    final var field = setting.getElement();
347
    field.multiline( true );
348
349
    return setting;
350
  }
351
352
  private void initKeyEventHandler( final PreferencesFx preferences ) {
353
    final var view = preferences.getView();
354
    final var nodes = view.getChildrenUnmodifiable();
355
    final var master = (MasterDetailPane) nodes.get( 0 );
356
    final var detail = (NavigationView) master.getDetailNode();
357
    final var pane = (DialogPane) view.getParent();
358
359
    detail.setOnKeyReleased( ( key ) -> {
360
      switch( key.getCode() ) {
361
        case ENTER -> ((Button) pane.lookupButton( OK )).fire();
362
        case ESCAPE -> ((Button) pane.lookupButton( CANCEL )).fire();
363
      }
364
    } );
365
  }
366
367
  /**
368
   * Creates a label for the given key after interpolating its value.
369
   *
370
   * @param key The key to find in the resource bundle.
371
   * @return The value of the key as a label.
372
   */
373
  private Node label( final Key key ) {
374
    return label( key, (String[]) null );
375
  }
376
377
  private Node label( final Key key, final String... values ) {
378
    return new Label( get( key.toString() + ".desc", (Object[]) values ) );
379
  }
380
381
  private String title( final Key key ) {
382
    return get( key.toString() + ".title" );
383
  }
384
385
  private ObjectProperty<File> fileProperty( final Key key ) {
386
    return mWorkspace.fileProperty( key );
387
  }
388
389
  private StringProperty stringProperty( final Key key ) {
390
    return mWorkspace.stringProperty( key );
391
  }
392
393
  private BooleanProperty booleanProperty( final Key key ) {
394
    return mWorkspace.booleanProperty( key );
395
  }
396
397
  @SuppressWarnings( "SameParameterValue" )
398
  private IntegerProperty integerProperty( final Key key ) {
399
    return mWorkspace.integerProperty( key );
400
  }
401
402
  @SuppressWarnings( "SameParameterValue" )
403
  private DoubleProperty doubleProperty( final Key key ) {
404
    return mWorkspace.doubleProperty( key );
405
  }
406
407
  private ObjectProperty<String> skinProperty( final Key key ) {
408
    return mWorkspace.skinProperty( key );
409
  }
410
411
  private ObjectProperty<String> localeProperty( final Key key ) {
412
    return mWorkspace.localeProperty( key );
413
  }
414
415
  private PreferencesFx getPreferencesFx() {
416
    return mPreferencesFx;
6
import com.dlsc.preferencesfx.model.Category;
7
import com.dlsc.preferencesfx.model.Group;
8
import com.dlsc.preferencesfx.model.Setting;
9
import com.dlsc.preferencesfx.util.StorageHandler;
10
import com.dlsc.preferencesfx.view.NavigationView;
11
import javafx.beans.property.*;
12
import javafx.scene.Node;
13
import javafx.scene.control.Button;
14
import javafx.scene.control.DialogPane;
15
import javafx.scene.control.Label;
16
import org.controlsfx.control.MasterDetailPane;
17
18
import java.io.File;
19
import java.util.Map.Entry;
20
21
import static com.dlsc.formsfx.model.structure.Field.ofStringType;
22
import static com.dlsc.preferencesfx.PreferencesFxEvent.EVENT_PREFERENCES_SAVED;
23
import static com.keenwrite.Messages.get;
24
import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG;
25
import static com.keenwrite.preferences.AppKeys.*;
26
import static com.keenwrite.preferences.LocaleProperty.localeListProperty;
27
import static com.keenwrite.preferences.SkinProperty.skinListProperty;
28
import static com.keenwrite.preferences.TableField.ofListType;
29
import static javafx.scene.control.ButtonType.CANCEL;
30
import static javafx.scene.control.ButtonType.OK;
31
32
/**
33
 * Provides the ability for users to configure their preferences. This links
34
 * the {@link Workspace} model with the {@link PreferencesFx} view, in MVC.
35
 */
36
@SuppressWarnings( "SameParameterValue" )
37
public final class PreferencesController {
38
39
  private final Workspace mWorkspace;
40
  private final PreferencesFx mPreferencesFx;
41
42
  public PreferencesController( final Workspace workspace ) {
43
    mWorkspace = workspace;
44
45
    // Order matters: set the workspace before creating the dialog.
46
    mPreferencesFx = createPreferencesFx();
47
48
    initKeyEventHandler( mPreferencesFx );
49
    initSaveEventHandler( mPreferencesFx );
50
  }
51
52
  /**
53
   * Display the user preferences settings dialog (non-modal).
54
   */
55
  public void show() {
56
    mPreferencesFx.show( false );
57
  }
58
59
  private StringField createFontNameField(
60
    final StringProperty fontName, final DoubleProperty fontSize ) {
61
    final var control = new SimpleFontControl( "Change" );
62
63
    control.fontSizeProperty().addListener( ( c, o, n ) -> {
64
      if( n != null ) {
65
        fontSize.set( n.doubleValue() );
66
      }
67
    } );
68
69
    return ofStringType( fontName ).render( control );
70
  }
71
72
  /**
73
   * Convenience method to create a helper class for the user interface. This
74
   * establishes a key-value pair for the view.
75
   *
76
   * @param persist A reference to the values that will be persisted.
77
   * @param <K>     The type of key, usually a string.
78
   * @param <V>     The type of value, usually a string.
79
   * @return UI data model container that may update the persistent state.
80
   */
81
  private <K, V> TableField<Entry<K, V>> createTableField(
82
    final ListProperty<Entry<K, V>> persist ) {
83
    return ofListType( persist ).render( new SimpleTableControl<>() );
84
  }
85
86
  /**
87
   * Creates the preferences dialog based using
88
   * {@link SkeletonStorageHandler} and
89
   * numerous {@link Category} objects.
90
   *
91
   * @return A component for editing preferences.
92
   * @throws RuntimeException Could not construct the {@link PreferencesFx}
93
   *                          object (e.g., illegal access permissions,
94
   *                          unmapped XML resource).
95
   */
96
  private PreferencesFx createPreferencesFx() {
97
    return PreferencesFx.of( createStorageHandler(), createCategories() )
98
                        .instantPersistent( false )
99
                        .dialogIcon( ICON_DIALOG );
100
  }
101
102
  /**
103
   * Override the {@link PreferencesFx} storage handler to perform no actions.
104
   * Persistence is accomplished using the {@link XmlStore}.
105
   *
106
   * @return A no-op {@link StorageHandler} implementation.
107
   */
108
  private StorageHandler createStorageHandler() {
109
    return new SkeletonStorageHandler();
110
  }
111
112
  private Category[] createCategories() {
113
    return new Category[]{
114
      Category.of(
115
        get( KEY_DOC ),
116
        Group.of(
117
          get( KEY_DOC_META ),
118
          Setting.of( label( KEY_DOC_META ) ),
119
          Setting.of( title( KEY_DOC_META ),
120
                      createTableField( listEntryProperty( KEY_DOC_META ) ),
121
                      listEntryProperty( KEY_DOC_META ) )
122
        )
123
      ),
124
      Category.of(
125
        get( KEY_TYPESET ),
126
        Group.of(
127
          get( KEY_TYPESET_CONTEXT ),
128
          Setting.of( label( KEY_TYPESET_CONTEXT_THEMES_PATH ) ),
129
          Setting.of( title( KEY_TYPESET_CONTEXT_THEMES_PATH ),
130
                      fileProperty( KEY_TYPESET_CONTEXT_THEMES_PATH ), true ),
131
          Setting.of( label( KEY_TYPESET_CONTEXT_CLEAN ) ),
132
          Setting.of( title( KEY_TYPESET_CONTEXT_CLEAN ),
133
                      booleanProperty( KEY_TYPESET_CONTEXT_CLEAN ) )
134
        ),
135
        Group.of(
136
          get( KEY_TYPESET_TYPOGRAPHY ),
137
          Setting.of( label( KEY_TYPESET_TYPOGRAPHY_QUOTES ) ),
138
          Setting.of( title( KEY_TYPESET_TYPOGRAPHY_QUOTES ),
139
                      booleanProperty( KEY_TYPESET_TYPOGRAPHY_QUOTES ) )
140
        )
141
      ),
142
      Category.of(
143
        get( KEY_EDITOR ),
144
        Group.of(
145
          get( KEY_EDITOR_AUTOSAVE ),
146
          Setting.of( label( KEY_EDITOR_AUTOSAVE ) ),
147
          Setting.of( title( KEY_EDITOR_AUTOSAVE ),
148
                      integerProperty( KEY_EDITOR_AUTOSAVE ) )
149
        )
150
      ),
151
      Category.of(
152
        get( KEY_R ),
153
        Group.of(
154
          get( KEY_R_DIR ),
155
          Setting.of( label( KEY_R_DIR ) ),
156
          Setting.of( title( KEY_R_DIR ),
157
                      fileProperty( KEY_R_DIR ), true )
158
        ),
159
        Group.of(
160
          get( KEY_R_SCRIPT ),
161
          Setting.of( label( KEY_R_SCRIPT ) ),
162
          createMultilineSetting( "Script", KEY_R_SCRIPT )
163
        ),
164
        Group.of(
165
          get( KEY_R_DELIM_BEGAN ),
166
          Setting.of( label( KEY_R_DELIM_BEGAN ) ),
167
          Setting.of( title( KEY_R_DELIM_BEGAN ),
168
                      stringProperty( KEY_R_DELIM_BEGAN ) )
169
        ),
170
        Group.of(
171
          get( KEY_R_DELIM_ENDED ),
172
          Setting.of( label( KEY_R_DELIM_ENDED ) ),
173
          Setting.of( title( KEY_R_DELIM_ENDED ),
174
                      stringProperty( KEY_R_DELIM_ENDED ) )
175
        )
176
      ),
177
      Category.of(
178
        get( KEY_IMAGES ),
179
        Group.of(
180
          get( KEY_IMAGES_DIR ),
181
          Setting.of( label( KEY_IMAGES_DIR ) ),
182
          Setting.of( title( KEY_IMAGES_DIR ),
183
                      fileProperty( KEY_IMAGES_DIR ), true )
184
        ),
185
        Group.of(
186
          get( KEY_IMAGES_ORDER ),
187
          Setting.of( label( KEY_IMAGES_ORDER ) ),
188
          Setting.of( title( KEY_IMAGES_ORDER ),
189
                      stringProperty( KEY_IMAGES_ORDER ) )
190
        ),
191
        Group.of(
192
          get( KEY_IMAGES_RESIZE ),
193
          Setting.of( label( KEY_IMAGES_RESIZE ) ),
194
          Setting.of( title( KEY_IMAGES_RESIZE ),
195
                      booleanProperty( KEY_IMAGES_RESIZE ) )
196
        ),
197
        Group.of(
198
          get( KEY_IMAGES_SERVER ),
199
          Setting.of( label( KEY_IMAGES_SERVER ) ),
200
          Setting.of( title( KEY_IMAGES_SERVER ),
201
                      stringProperty( KEY_IMAGES_SERVER ) )
202
        )
203
      ),
204
      Category.of(
205
        get( KEY_DEF ),
206
        Group.of(
207
          get( KEY_DEF_PATH ),
208
          Setting.of( label( KEY_DEF_PATH ) ),
209
          Setting.of( title( KEY_DEF_PATH ),
210
                      fileProperty( KEY_DEF_PATH ), false )
211
        ),
212
        Group.of(
213
          get( KEY_DEF_DELIM_BEGAN ),
214
          Setting.of( label( KEY_DEF_DELIM_BEGAN ) ),
215
          Setting.of( title( KEY_DEF_DELIM_BEGAN ),
216
                      stringProperty( KEY_DEF_DELIM_BEGAN ) )
217
        ),
218
        Group.of(
219
          get( KEY_DEF_DELIM_ENDED ),
220
          Setting.of( label( KEY_DEF_DELIM_ENDED ) ),
221
          Setting.of( title( KEY_DEF_DELIM_ENDED ),
222
                      stringProperty( KEY_DEF_DELIM_ENDED ) )
223
        )
224
      ),
225
      Category.of(
226
        get( KEY_UI_FONT ),
227
        Group.of(
228
          get( KEY_UI_FONT_EDITOR ),
229
          Setting.of( label( KEY_UI_FONT_EDITOR_NAME ) ),
230
          Setting.of( title( KEY_UI_FONT_EDITOR_NAME ),
231
                      createFontNameField(
232
                        stringProperty( KEY_UI_FONT_EDITOR_NAME ),
233
                        doubleProperty( KEY_UI_FONT_EDITOR_SIZE ) ),
234
                      stringProperty( KEY_UI_FONT_EDITOR_NAME ) ),
235
          Setting.of( label( KEY_UI_FONT_EDITOR_SIZE ) ),
236
          Setting.of( title( KEY_UI_FONT_EDITOR_SIZE ),
237
                      doubleProperty( KEY_UI_FONT_EDITOR_SIZE ) )
238
        ),
239
        Group.of(
240
          get( KEY_UI_FONT_PREVIEW ),
241
          Setting.of( label( KEY_UI_FONT_PREVIEW_NAME ) ),
242
          Setting.of( title( KEY_UI_FONT_PREVIEW_NAME ),
243
                      createFontNameField(
244
                        stringProperty( KEY_UI_FONT_PREVIEW_NAME ),
245
                        doubleProperty( KEY_UI_FONT_PREVIEW_SIZE ) ),
246
                      stringProperty( KEY_UI_FONT_PREVIEW_NAME ) ),
247
          Setting.of( label( KEY_UI_FONT_PREVIEW_SIZE ) ),
248
          Setting.of( title( KEY_UI_FONT_PREVIEW_SIZE ),
249
                      doubleProperty( KEY_UI_FONT_PREVIEW_SIZE ) ),
250
          Setting.of( label( KEY_UI_FONT_PREVIEW_MONO_NAME ) ),
251
          Setting.of( title( KEY_UI_FONT_PREVIEW_MONO_NAME ),
252
                      createFontNameField(
253
                        stringProperty( KEY_UI_FONT_PREVIEW_MONO_NAME ),
254
                        doubleProperty( KEY_UI_FONT_PREVIEW_MONO_SIZE ) ),
255
                      stringProperty( KEY_UI_FONT_PREVIEW_MONO_NAME ) ),
256
          Setting.of( label( KEY_UI_FONT_PREVIEW_MONO_SIZE ) ),
257
          Setting.of( title( KEY_UI_FONT_PREVIEW_MONO_SIZE ),
258
                      doubleProperty( KEY_UI_FONT_PREVIEW_MONO_SIZE ) )
259
        )
260
      ),
261
      Category.of(
262
        get( KEY_UI_SKIN ),
263
        Group.of(
264
          get( KEY_UI_SKIN_SELECTION ),
265
          Setting.of( label( KEY_UI_SKIN_SELECTION ) ),
266
          Setting.of( title( KEY_UI_SKIN_SELECTION ),
267
                      skinListProperty(),
268
                      skinProperty( KEY_UI_SKIN_SELECTION ) )
269
        ),
270
        Group.of(
271
          get( KEY_UI_SKIN_CUSTOM ),
272
          Setting.of( label( KEY_UI_SKIN_CUSTOM ) ),
273
          Setting.of( title( KEY_UI_SKIN_CUSTOM ),
274
                      fileProperty( KEY_UI_SKIN_CUSTOM ), false )
275
        )
276
      ),
277
      Category.of(
278
        get( KEY_UI_PREVIEW ),
279
        Group.of(
280
          get( KEY_UI_PREVIEW_STYLESHEET ),
281
          Setting.of( label( KEY_UI_PREVIEW_STYLESHEET ) ),
282
          Setting.of( title( KEY_UI_PREVIEW_STYLESHEET ),
283
                      fileProperty( KEY_UI_PREVIEW_STYLESHEET ), false )
284
        )
285
      ),
286
      Category.of(
287
        get( KEY_LANGUAGE ),
288
        Group.of(
289
          get( KEY_LANGUAGE_LOCALE ),
290
          Setting.of( label( KEY_LANGUAGE_LOCALE ) ),
291
          Setting.of( title( KEY_LANGUAGE_LOCALE ),
292
                      localeListProperty(),
293
                      localeProperty( KEY_LANGUAGE_LOCALE ) )
294
        )
295
      )
296
    };
297
  }
298
299
  @SuppressWarnings( "unchecked" )
300
  private Setting<StringField, StringProperty> createMultilineSetting(
301
    final String description, final Key property ) {
302
    final Setting<StringField, StringProperty> setting =
303
      Setting.of( description, stringProperty( property ) );
304
    final var field = setting.getElement();
305
    field.multiline( true );
306
307
    return setting;
308
  }
309
310
  /**
311
   * Map ENTER and ESCAPE keys to OK and CANCEL buttons, respectively.
312
   */
313
  private void initKeyEventHandler( final PreferencesFx preferences ) {
314
    final var view = preferences.getView();
315
    final var nodes = view.getChildrenUnmodifiable();
316
    final var master = (MasterDetailPane) nodes.get( 0 );
317
    final var detail = (NavigationView) master.getDetailNode();
318
    final var pane = (DialogPane) view.getParent();
319
320
    detail.setOnKeyReleased( key -> {
321
      switch( key.getCode() ) {
322
        case ENTER -> ((Button) pane.lookupButton( OK )).fire();
323
        case ESCAPE -> ((Button) pane.lookupButton( CANCEL )).fire();
324
      }
325
    } );
326
  }
327
328
  /**
329
   * Called when the user clicks the APPLY or OK buttons in the dialog.
330
   *
331
   * @param preferences Preferences widget.
332
   */
333
  private void initSaveEventHandler( final PreferencesFx preferences ) {
334
    preferences.addEventHandler(
335
      EVENT_PREFERENCES_SAVED, event -> mWorkspace.save()
336
    );
337
  }
338
339
  /**
340
   * Creates a label for the given key after interpolating its value.
341
   *
342
   * @param key The key to find in the resource bundle.
343
   * @return The value of the key as a label.
344
   */
345
  private Node label( final Key key ) {
346
    return label( key, (String[]) null );
347
  }
348
349
  private Node label( final Key key, final String... values ) {
350
    return new Label( get( key.toString() + ".desc", (Object[]) values ) );
351
  }
352
353
  private String title( final Key key ) {
354
    return get( key.toString() + ".title" );
355
  }
356
357
  private ObjectProperty<File> fileProperty( final Key key ) {
358
    return mWorkspace.fileProperty( key );
359
  }
360
361
  private StringProperty stringProperty( final Key key ) {
362
    return mWorkspace.stringProperty( key );
363
  }
364
365
  private BooleanProperty booleanProperty( final Key key ) {
366
    return mWorkspace.booleanProperty( key );
367
  }
368
369
  @SuppressWarnings( "SameParameterValue" )
370
  private IntegerProperty integerProperty( final Key key ) {
371
    return mWorkspace.integerProperty( key );
372
  }
373
374
  @SuppressWarnings( "SameParameterValue" )
375
  private DoubleProperty doubleProperty( final Key key ) {
376
    return mWorkspace.doubleProperty( key );
377
  }
378
379
  private ObjectProperty<String> skinProperty( final Key key ) {
380
    return mWorkspace.skinProperty( key );
381
  }
382
383
  private ObjectProperty<String> localeProperty( final Key key ) {
384
    return mWorkspace.localeProperty( key );
385
  }
386
387
  private <K, V> ListProperty<Entry<K, V>> listEntryProperty( final Key key ) {
388
    return mWorkspace.listsProperty( key );
417389
  }
418390
}
A src/main/java/com/keenwrite/preferences/SimpleTableControl.java
1
/* Copyright 2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.preferences;
3
4
import com.dlsc.preferencesfx.formsfx.view.controls.SimpleControl;
5
import com.keenwrite.ui.table.AltTableCell;
6
import javafx.beans.property.SimpleObjectProperty;
7
import javafx.event.ActionEvent;
8
import javafx.event.EventHandler;
9
import javafx.geometry.Insets;
10
import javafx.scene.control.Button;
11
import javafx.scene.control.ButtonBar;
12
import javafx.scene.control.TableColumn;
13
import javafx.scene.control.TableColumn.CellEditEvent;
14
import javafx.scene.control.TableView;
15
import javafx.scene.layout.VBox;
16
import javafx.util.StringConverter;
17
18
import java.util.AbstractMap.SimpleEntry;
19
import java.util.ArrayList;
20
import java.util.Map.Entry;
21
import java.util.concurrent.atomic.AtomicBoolean;
22
import java.util.function.BiFunction;
23
import java.util.function.Function;
24
25
import static com.keenwrite.ui.fonts.IconFactory.createGraphic;
26
import static java.util.Arrays.asList;
27
import static javafx.scene.control.SelectionMode.MULTIPLE;
28
import static javafx.scene.control.TableView.CONSTRAINED_RESIZE_POLICY;
29
30
public class SimpleTableControl<K, V, F extends TableField<Entry<K, V>>>
31
  extends SimpleControl<F, VBox> {
32
33
  private static long sCounter;
34
35
  public SimpleTableControl() {}
36
37
  @Override
38
  public void initializeParts() {
39
    super.initializeParts();
40
41
    final var model = field.viewProperty();
42
    final var table = new TableView<>( model );
43
44
    table.setColumnResizePolicy( CONSTRAINED_RESIZE_POLICY );
45
    table.setEditable( true );
46
    table.getColumns().addAll(
47
      asList(
48
        createEditableColumnKey( table ),
49
        createEditableColumnValue( table )
50
      )
51
    );
52
    table.getSelectionModel().setSelectionMode( MULTIPLE );
53
54
    final var inserted = workaround( table );
55
56
    final var buttons = new ButtonBar();
57
    buttons.getButtons().addAll(
58
      createButton(
59
        "Add", "PLUS",
60
        event -> {
61
          sCounter++;
62
63
          inserted.set( true );
64
          model.add( createEntry( "key" + sCounter, "value" + sCounter ) );
65
        }
66
      ),
67
68
      createButton(
69
        "Delete", "TRASH",
70
        event -> {
71
          final var selectionModel = table.getSelectionModel();
72
          final var selection = selectionModel.getSelectedItems();
73
74
          if( selection != null && !selection.isEmpty() ) {
75
            final var items = table.getItems();
76
            final var rows = new ArrayList<>( selection );
77
            rows.forEach( items::remove );
78
79
            selectionModel.clearSelection();
80
          }
81
        }
82
      )
83
    );
84
85
    final var vbox = new VBox();
86
    vbox.setSpacing( 5 );
87
    vbox.setPadding( new Insets( 10, 0, 0, 10 ) );
88
    vbox.getChildren().addAll( table, buttons );
89
90
    super.node = vbox;
91
  }
92
93
  @SuppressWarnings( "unchecked" )
94
  private Entry<K, V> createEntry( final String k, final String v ) {
95
    return new SimpleEntry<>( (K) k, (V) v );
96
  }
97
98
  /**
99
   * TODO: Delete method when bug is fixed. See the
100
   * <a href="https://github.com/dlsc-software-consulting-gmbh/PreferencesFX/issues/413">issue
101
   * tracker</a> for details about the bug.
102
   *
103
   * @param table Add a width listener to correct a slight width change.
104
   * @return A Boolean lock so that the bug fix and "Add" button can
105
   * be used to ensure regular resizes don't interfere with programmatic ones.
106
   */
107
  private AtomicBoolean workaround(
108
    final TableView<Entry<K, V>> table ) {
109
    final var inserted = new AtomicBoolean( true );
110
111
    table.widthProperty().addListener( ( c, o, n ) -> {
112
      if( (o != null && n != null)
113
        && o.intValue() == n.intValue() - 2
114
        && inserted.getAndSet( false ) ) {
115
        table.setPrefWidth( table.getPrefWidth() - 2 );
116
      }
117
    } );
118
119
    return inserted;
120
  }
121
122
  private Button createButton(
123
    final String label,
124
    final String graphic,
125
    final EventHandler<ActionEvent> handler ) {
126
    assert label != null;
127
    assert !label.isBlank();
128
    assert graphic != null;
129
    assert !graphic.isBlank();
130
    assert handler != null;
131
132
    final var button = new Button( label, createGraphic( graphic ) );
133
    button.setOnAction( handler );
134
    return button;
135
  }
136
137
  private TableColumn<Entry<K, V>, K> createEditableColumnKey(
138
    final TableView<Entry<K, V>> table ) {
139
    return createColumn(
140
      table,
141
      Entry::getKey,
142
      ( e, o ) -> new SimpleEntry<>( e.getNewValue(), o.getValue() ),
143
      "Key",
144
      .2
145
    );
146
  }
147
148
  private TableColumn<Entry<K, V>, V> createEditableColumnValue(
149
    final TableView<Entry<K, V>> table ) {
150
    return createColumn(
151
      table,
152
      Entry::getValue,
153
      ( e, o ) -> new SimpleEntry<>( o.getKey(), e.getNewValue() ),
154
      "Value",
155
      .8
156
    );
157
  }
158
159
  /**
160
   * Creates a table column having cells that be edited.
161
   *
162
   * @param table    The table to which the column belongs.
163
   * @param mapEntry Data model backing the edited text.
164
   * @param label    Column name.
165
   * @param width    Fraction of table width (1 = 100%).
166
   * @param <T>      The return type for the column (i.e., key or value).
167
   * @return The newly configured column.
168
   */
169
  private <T> TableColumn<Entry<K, V>, T> createColumn(
170
    final TableView<Entry<K, V>> table,
171
    final Function<Entry<K, V>, T> mapEntry,
172
    final BiFunction<CellEditEvent<Entry<K, V>, T>, Entry<K, V>, Entry<K, V>> creator,
173
    final String label,
174
    final double width
175
  ) {
176
    final var column = new TableColumn<Entry<K, V>, T>( label );
177
178
    column.setEditable( true );
179
    column.setResizable( true );
180
    column.prefWidthProperty().bind( table.widthProperty().multiply( width ) );
181
182
    column.setOnEditCommit( event -> {
183
      final var index = event.getTablePosition().getRow();
184
      final var view = event.getTableView();
185
      final var old = view.getItems().get( index );
186
187
      // Update the data model with the new column value.
188
      view.getItems().set( index, creator.apply( event, old ) );
189
    } );
190
191
    column.setCellValueFactory(
192
      cellData ->
193
        new SimpleObjectProperty<>( mapEntry.apply( cellData.getValue() ) )
194
    );
195
196
    column.setCellFactory(
197
      tableColumn -> new AltTableCell<>(
198
        new StringConverter<>() {
199
          @Override
200
          public String toString( final T object ) {
201
            return object.toString();
202
          }
203
204
          @Override
205
          @SuppressWarnings( "unchecked" )
206
          public T fromString( final String string ) {
207
            return (T) string;
208
          }
209
        }
210
      )
211
    );
212
213
    return column;
214
  }
215
216
  /**
217
   * Calling {@link #initializeParts()} also performs layout because no handles
218
   * are kept to the widgets after initialization.
219
   */
220
  @Override
221
  public void layoutParts() {}
222
}
1223
A src/main/java/com/keenwrite/preferences/SkeletonStorageHandler.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.preferences;
3
4
import com.dlsc.preferencesfx.PreferencesFx;
5
import com.dlsc.preferencesfx.util.StorageHandler;
6
import javafx.collections.ObservableList;
7
8
import java.util.prefs.Preferences;
9
10
/**
11
 * Prevents {@link PreferencesFx} from saving. Saving and loading preferences
12
 * and application window state is accomplished by the {@link Workspace}. This
13
 * is required to change the user preferences file location and data format.
14
 *
15
 * @see XmlStore
16
 * @see Workspace
17
 */
18
public final class SkeletonStorageHandler implements StorageHandler {
19
  @Override
20
  public void saveSelectedCategory( final String breadcrumb ) {}
21
22
  @Override
23
  public String loadSelectedCategory() {
24
    return "";
25
  }
26
27
  @Override
28
  public void saveDividerPosition( final double dividerPosition ) {}
29
30
  @Override
31
  public double loadDividerPosition() {
32
    return 0;
33
  }
34
35
  @Override
36
  public void saveWindowWidth( final double windowWidth ) {}
37
38
  @Override
39
  public double loadWindowWidth() {
40
    return 0;
41
  }
42
43
  @Override
44
  public void saveWindowHeight( final double windowHeight ) {}
45
46
  @Override
47
  public double loadWindowHeight() {
48
    return 0;
49
  }
50
51
  @Override
52
  public void saveWindowPosX( final double windowPosX ) {}
53
54
  @Override
55
  public double loadWindowPosX() {
56
    return 0;
57
  }
58
59
  @Override
60
  public void saveWindowPosY( final double windowPosY ) {}
61
62
  @Override
63
  public double loadWindowPosY() {
64
    return 0;
65
  }
66
67
  @Override
68
  public void saveObject( final String breadcrumb, final Object object ) {}
69
70
  @Override
71
  public Object loadObject(
72
    final String breadcrumb, final Object defaultObject ) {
73
    return defaultObject;
74
  }
75
76
  @Override
77
  public <T> T loadObject(
78
    final String breadcrumb, final Class<T> type, final T defaultObject ) {
79
    return defaultObject;
80
  }
81
82
  @Override
83
  @SuppressWarnings( "rawtypes" )
84
  public ObservableList loadObservableList(
85
    final String breadcrumb, final ObservableList defaultObservableList ) {
86
    return defaultObservableList;
87
  }
88
89
  @Override
90
  public <T> ObservableList<T> loadObservableList(
91
    final String breadcrumb,
92
    final Class<T> type,
93
    final ObservableList<T> defaultObservableList ) {
94
    return defaultObservableList;
95
  }
96
97
  @Override
98
  public boolean clearPreferences() {
99
    return false;
100
  }
101
102
  @Override
103
  public Preferences getPreferences() {
104
    return null;
105
  }
106
}
1107
M src/main/java/com/keenwrite/preferences/SkinProperty.java
3131
  }
3232
33
  public SkinProperty( final String skin ) {
34
    super( skin );
35
  }
36
33
  /**
34
   * Returns the list of available skin names to change the UI fonts and
35
   * colours.
36
   *
37
   * @return A selection of skins.
38
   */
3739
  public static ObservableList<String> skinListProperty() {
40
    assert !sSkins.isEmpty();
41
3842
    return listProperty( sSkins );
3943
  }
...
5155
   */
5256
  public static String toFilename( final String skin ) {
57
    assert skin != null;
58
5359
    return sanitize( skin ).toLowerCase().replace( ' ', '_' );
5460
  }
...
6167
   */
6268
  private static String sanitize( final String skin ) {
69
    assert skin != null;
70
6371
    return sSkins.contains( skin ) ? skin : SKIN_DEFAULT;
72
  }
73
74
  public SkinProperty( final String skin ) {
75
    super( skin );
6476
  }
6577
}
A src/main/java/com/keenwrite/preferences/TableField.java
1
/* Copyright 2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.preferences;
3
4
import com.dlsc.formsfx.model.structure.Field;
5
import com.dlsc.formsfx.model.util.BindingMode;
6
import javafx.beans.property.ListProperty;
7
import javafx.beans.property.Property;
8
import javafx.beans.property.SimpleListProperty;
9
10
import java.util.ArrayList;
11
12
import static com.dlsc.formsfx.model.util.BindingMode.CONTINUOUS;
13
import static javafx.collections.FXCollections.observableList;
14
15
/**
16
 * Responsible for binding a form field to a map of values that, ultimately,
17
 * users may edit.
18
 *
19
 * @param <P> The type of {@link Property} to store in the list.
20
 */
21
public class TableField<P> extends Field<TableField<P>> {
22
23
  /**
24
   * Create a writeable list as the data model.
25
   */
26
  private final ListProperty<P> mViewProperty = new SimpleListProperty<>(
27
    observableList( new ArrayList<>() )
28
  );
29
30
  /**
31
   * Contains the data model entries to persist.
32
   */
33
  private final ListProperty<P> mSaveProperty;
34
35
  /**
36
   * Creates a new {@link TableField} with a reference to the list that is to
37
   * be persisted.
38
   *
39
   * @param persist A list of items that will be persisted.
40
   * @param <P>     The type of elements in the list to persist.
41
   * @return A new {@link TableField} used to help render a UI widget.
42
   */
43
  public static <P> TableField<P> ofListType( final ListProperty<P> persist ) {
44
    return new TableField<>( persist );
45
  }
46
47
  private TableField( final ListProperty<P> property ) {
48
    mSaveProperty = property;
49
  }
50
51
  /**
52
   * Returns the data model that seeds the user interface. At any point the
53
   * user may cancel editing, which will revert to the previously persisted
54
   * set.
55
   *
56
   * @return The source for values displayed in the UI.
57
   */
58
  public ListProperty<P> viewProperty() {
59
    return mViewProperty;
60
  }
61
62
  /**
63
   * Called when a new UI instance is opened.
64
   *
65
   * @param bindingMode Indicates how the view data model is bound to the
66
   *                    persistence data model.
67
   */
68
  @Override
69
  public void setBindingMode( final BindingMode bindingMode ) {
70
    if( CONTINUOUS.equals( bindingMode ) ) {
71
      mViewProperty.addAll( mSaveProperty );
72
    }
73
  }
74
75
  /**
76
   * Answers whether the user input is valid.
77
   *
78
   * @return {@code true} Users may provide any key or value strings.
79
   */
80
  @Override
81
  protected boolean validate() {
82
    return true;
83
  }
84
85
  /**
86
   * Update the properties to save by copying the properties updated in the
87
   * user interface (i.e., the view). To be clear, the properties are not
88
   * persisted after calling this method, merely moved out of the UI data
89
   * model and into the to-be-saved data model.
90
   */
91
  @Override
92
  public void persist() {
93
    mSaveProperty.clear();
94
    mSaveProperty.addAll( mViewProperty );
95
  }
96
97
  /**
98
   * The {@link TableField} doesn't bind values, as such the reset can be
99
   * a no-op because only {@link #persist()} will update the properties to
100
   * save.
101
   */
102
  @Override
103
  public void reset() {}
104
}
1105
M src/main/java/com/keenwrite/preferences/Workspace.java
22
package com.keenwrite.preferences;
33
4
import com.keenwrite.constants.Constants;
5
import com.keenwrite.sigils.Sigils;
6
import javafx.application.Platform;
7
import javafx.beans.property.*;
8
import javafx.collections.ObservableList;
9
import org.apache.commons.configuration2.XMLConfiguration;
10
import org.apache.commons.configuration2.builder.fluent.Configurations;
11
import org.apache.commons.configuration2.io.FileHandler;
12
13
import java.io.File;
14
import java.time.Year;
15
import java.time.ZonedDateTime;
16
import java.util.*;
17
import java.util.function.BiConsumer;
18
import java.util.function.BooleanSupplier;
19
import java.util.function.Consumer;
20
import java.util.function.Function;
21
22
import static com.keenwrite.Bootstrap.APP_TITLE_LOWERCASE;
23
import static com.keenwrite.Launcher.getVersion;
24
import static com.keenwrite.constants.Constants.*;
25
import static com.keenwrite.events.StatusEvent.clue;
26
import static com.keenwrite.preferences.WorkspaceKeys.*;
27
import static java.lang.String.valueOf;
28
import static java.lang.System.getProperty;
29
import static java.time.format.DateTimeFormatter.RFC_1123_DATE_TIME;
30
import static java.util.Map.entry;
31
import static javafx.application.Platform.runLater;
32
import static javafx.collections.FXCollections.observableArrayList;
33
import static javafx.collections.FXCollections.observableSet;
34
35
/**
36
 * Responsible for defining behaviours for separate projects. A workspace has
37
 * the ability to save and restore a session, including the window dimensions,
38
 * tab setup, files, and user preferences.
39
 * <p>
40
 * The configuration must support hierarchical (nested) configuration nodes
41
 * to persist the user interface state. Although possible with a flat
42
 * configuration file, it's not nearly as simple or elegant.
43
 * </p>
44
 * <p>
45
 * Neither JSON nor HOCON support schema validation and versioning, which makes
46
 * XML the more suitable configuration file format. Schema validation and
47
 * versioning provide future-proofing and ease of reading and upgrading previous
48
 * versions of the configuration file.
49
 * </p>
50
 * <p>
51
 * Persistent preferences may be set directly by the user or indirectly by
52
 * the act of using the application.
53
 * </p>
54
 * <p>
55
 * Note the following definitions:
56
 * </p>
57
 * <dl>
58
 *   <dt>File</dt>
59
 *   <dd>References a file name (no path), path, or directory.</dd>
60
 *   <dt>Path</dt>
61
 *   <dd>Fully qualified file name, which includes all parent directories.</dd>
62
 *   <dt>Dir</dt>
63
 *   <dd>Directory without file name ({@link File#isDirectory()} is true).</dd>
64
 * </dl>
65
 */
66
public final class Workspace {
67
  private final Map<Key, Property<?>> VALUES = Map.ofEntries(
68
    entry( KEY_META_VERSION, asStringProperty( getVersion() ) ),
69
    entry( KEY_META_NAME, asStringProperty( "default" ) ),
70
71
    entry( KEY_DOC_TITLE, asStringProperty( "title" ) ),
72
    entry( KEY_DOC_AUTHOR, asStringProperty( getProperty( "user.name" ) ) ),
73
    entry( KEY_DOC_BYLINE, asStringProperty( getProperty( "user.name" ) ) ),
74
    entry( KEY_DOC_ADDRESS, asStringProperty( "" ) ),
75
    entry( KEY_DOC_PHONE, asStringProperty( "" ) ),
76
    entry( KEY_DOC_EMAIL, asStringProperty( "" ) ),
77
    entry( KEY_DOC_KEYWORDS, asStringProperty( "science, nature" ) ),
78
    entry( KEY_DOC_COPYRIGHT, asStringProperty( getYear() ) ),
79
    entry( KEY_DOC_DATE, asStringProperty( getDate() ) ),
80
81
    entry( KEY_EDITOR_AUTOSAVE, asIntegerProperty( 30 ) ),
82
83
    entry( KEY_R_SCRIPT, asStringProperty( "" ) ),
84
    entry( KEY_R_DIR, asFileProperty( USER_DIRECTORY ) ),
85
    entry( KEY_R_DELIM_BEGAN, asStringProperty( R_DELIM_BEGAN_DEFAULT ) ),
86
    entry( KEY_R_DELIM_ENDED, asStringProperty( R_DELIM_ENDED_DEFAULT ) ),
87
88
    entry( KEY_IMAGES_DIR, asFileProperty( USER_DIRECTORY ) ),
89
    entry( KEY_IMAGES_ORDER, asStringProperty( PERSIST_IMAGES_DEFAULT ) ),
90
    entry( KEY_IMAGES_RESIZE, asBooleanProperty( true ) ),
91
    entry( KEY_IMAGES_SERVER, asStringProperty( DIAGRAM_SERVER_NAME ) ),
92
93
    entry( KEY_DEF_PATH, asFileProperty( DEFINITION_DEFAULT ) ),
94
    entry( KEY_DEF_DELIM_BEGAN, asStringProperty( DEF_DELIM_BEGAN_DEFAULT ) ),
95
    entry( KEY_DEF_DELIM_ENDED, asStringProperty( DEF_DELIM_ENDED_DEFAULT ) ),
96
97
    entry( KEY_UI_RECENT_DIR, asFileProperty( USER_DIRECTORY ) ),
98
    entry( KEY_UI_RECENT_DOCUMENT, asFileProperty( DOCUMENT_DEFAULT ) ),
99
    entry( KEY_UI_RECENT_DEFINITION, asFileProperty( DEFINITION_DEFAULT ) ),
100
    entry( KEY_UI_RECENT_EXPORT, asFileProperty( PDF_DEFAULT ) ),
101
102
    //@formatter:off
103
    entry( KEY_UI_FONT_EDITOR_NAME, asStringProperty( FONT_NAME_EDITOR_DEFAULT ) ),
104
    entry( KEY_UI_FONT_EDITOR_SIZE, asDoubleProperty( FONT_SIZE_EDITOR_DEFAULT ) ),
105
    entry( KEY_UI_FONT_PREVIEW_NAME, asStringProperty( FONT_NAME_PREVIEW_DEFAULT ) ),
106
    entry( KEY_UI_FONT_PREVIEW_SIZE, asDoubleProperty( FONT_SIZE_PREVIEW_DEFAULT ) ),
107
    entry( KEY_UI_FONT_PREVIEW_MONO_NAME, asStringProperty( FONT_NAME_PREVIEW_MONO_NAME_DEFAULT ) ),
108
    entry( KEY_UI_FONT_PREVIEW_MONO_SIZE, asDoubleProperty( FONT_SIZE_PREVIEW_MONO_SIZE_DEFAULT ) ),
109
110
    entry( KEY_UI_WINDOW_X, asDoubleProperty( WINDOW_X_DEFAULT ) ),
111
    entry( KEY_UI_WINDOW_Y, asDoubleProperty( WINDOW_Y_DEFAULT ) ),
112
    entry( KEY_UI_WINDOW_W, asDoubleProperty( WINDOW_W_DEFAULT ) ),
113
    entry( KEY_UI_WINDOW_H, asDoubleProperty( WINDOW_H_DEFAULT ) ),
114
    entry( KEY_UI_WINDOW_MAX, asBooleanProperty() ),
115
    entry( KEY_UI_WINDOW_FULL, asBooleanProperty() ),
116
117
    entry( KEY_UI_SKIN_SELECTION, asSkinProperty( SKIN_DEFAULT ) ),
118
    entry( KEY_UI_SKIN_CUSTOM, asFileProperty( SKIN_CUSTOM_DEFAULT ) ),
119
120
    entry( KEY_UI_PREVIEW_STYLESHEET, asFileProperty( PREVIEW_CUSTOM_DEFAULT ) ),
121
122
    entry( KEY_LANGUAGE_LOCALE, asLocaleProperty( LOCALE_DEFAULT ) ),
123
124
    entry( KEY_TYPESET_CONTEXT_CLEAN, asBooleanProperty( true ) ),
125
    entry( KEY_TYPESET_CONTEXT_THEMES_PATH, asFileProperty( USER_DIRECTORY ) ),
126
    entry( KEY_TYPESET_CONTEXT_THEME_SELECTION, asStringProperty( "boschet" ) ),
127
    entry( KEY_TYPESET_TYPOGRAPHY_QUOTES, asBooleanProperty( true ) )
128
    //@formatter:on
129
  );
130
131
  private StringProperty asStringProperty( final String defaultValue ) {
132
    return new SimpleStringProperty( defaultValue );
133
  }
134
135
  @SuppressWarnings( "SameParameterValue" )
136
  private IntegerProperty asIntegerProperty( final int defaultValue ) {
137
    return new SimpleIntegerProperty( defaultValue );
138
  }
139
140
  private DoubleProperty asDoubleProperty( final double defaultValue ) {
141
    return new SimpleDoubleProperty( defaultValue );
142
  }
143
144
  private BooleanProperty asBooleanProperty() {
145
    return new SimpleBooleanProperty();
146
  }
147
148
  @SuppressWarnings( "SameParameterValue" )
149
  private BooleanProperty asBooleanProperty( final boolean defaultValue ) {
150
    return new SimpleBooleanProperty( defaultValue );
151
  }
152
153
  private FileProperty asFileProperty( final File defaultValue ) {
154
    return new FileProperty( defaultValue );
155
  }
156
157
  @SuppressWarnings( "SameParameterValue" )
158
  private SkinProperty asSkinProperty( final String defaultValue ) {
159
    return new SkinProperty( defaultValue );
160
  }
161
162
  @SuppressWarnings( "SameParameterValue" )
163
  private LocaleProperty asLocaleProperty( final Locale defaultValue ) {
164
    return new LocaleProperty( defaultValue );
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
      FileProperty.class, File::new
178
    );
179
180
  private static final Map<Class<?>, Function<String, Object>> MARSHALL =
181
    Map.of(
182
      LocaleProperty.class, LocaleProperty::toLanguageTag
183
    );
184
185
  private final Map<Key, SetProperty<?>> SETS = Map.ofEntries(
186
    entry(
187
      KEY_UI_FILES_PATH,
188
      new SimpleSetProperty<>( observableSet( new HashSet<>() ) )
189
    )
190
  );
191
192
  /**
193
   * Creates a new {@link Workspace} that will attempt to load a configuration
194
   * file. If the configuration file cannot be loaded, the workspace settings
195
   * will return default values. This allows unit tests to provide an instance
196
   * of {@link Workspace} when necessary without encountering failures.
197
   */
198
  public Workspace() {
199
    load( FILE_PREFERENCES );
200
  }
201
202
  /**
203
   * Creates a new {@link Workspace} that will attempt to load the given
204
   * configuration file.
205
   *
206
   * @param filename The file to load.
207
   */
208
  public Workspace( final String filename ) {
209
    load( filename );
210
  }
211
212
  /**
213
   * Creates an instance of {@link ObservableList} that is based on a
214
   * modifiable observable array list for the given items.
215
   *
216
   * @param items The items to wrap in an observable list.
217
   * @param <E>   The type of items to add to the list.
218
   * @return An observable property that can have its contents modified.
219
   */
220
  public static <E> ObservableList<E> listProperty( final Set<E> items ) {
221
    return new SimpleListProperty<>( observableArrayList( items ) );
222
  }
223
224
  /**
225
   * Returns a value that represents a setting in the application that the user
226
   * may configure, either directly or indirectly.
227
   *
228
   * @param key The reference to the users' preference stored in deference
229
   *            of app reëntrance.
230
   * @return An observable property to be persisted.
231
   */
232
  @SuppressWarnings( "unchecked" )
233
  public <T, U extends Property<T>> U valuesProperty( final Key key ) {
234
    assert key != null;
235
    // The type that goes into the map must come out.
236
    return (U) VALUES.get( key );
237
  }
238
239
  /**
240
   * Returns a list of values that represent a setting in the application that
241
   * the user may configure, either directly or indirectly. The property
242
   * returned is backed by a mutable {@link Set}.
243
   *
244
   * @param key The {@link Key} associated with a preference value.
245
   * @return An observable property to be persisted.
246
   */
247
  @SuppressWarnings( "unchecked" )
248
  public <T> SetProperty<T> setsProperty( final Key key ) {
249
    assert key != null;
250
    // The type that goes into the map must come out.
251
    return (SetProperty<T>) SETS.get( key );
252
  }
253
254
  /**
255
   * Returns the {@link Boolean} preference value associated with the given
256
   * {@link Key}. The caller must be sure that the given {@link Key} is
257
   * associated with a value that matches the return type.
258
   *
259
   * @param key The {@link Key} associated with a preference value.
260
   * @return The value associated with the given {@link Key}.
261
   */
262
  public boolean toBoolean( final Key key ) {
263
    assert key != null;
264
    return (Boolean) valuesProperty( key ).getValue();
265
  }
266
267
  /**
268
   * Returns the {@link Integer} preference value associated with the given
269
   * {@link Key}. The caller must be sure that the given {@link Key} is
270
   * associated with a value that matches the return type.
271
   *
272
   * @param key The {@link Key} associated with a preference value.
273
   * @return The value associated with the given {@link Key}.
274
   */
275
  public int toInteger( final Key key ) {
276
    assert key != null;
277
    return (Integer) valuesProperty( key ).getValue();
278
  }
279
280
  /**
281
   * Returns the {@link Double} preference value associated with the given
282
   * {@link Key}. The caller must be sure that the given {@link Key} is
283
   * associated with a value that matches the return type.
284
   *
285
   * @param key The {@link Key} associated with a preference value.
286
   * @return The value associated with the given {@link Key}.
287
   */
288
  public double toDouble( final Key key ) {
289
    assert key != null;
290
    return (Double) valuesProperty( key ).getValue();
291
  }
292
293
  public File toFile( final Key key ) {
294
    assert key != null;
295
    return fileProperty( key ).get();
296
  }
297
298
  public String toString( final Key key ) {
299
    assert key != null;
300
    return stringProperty( key ).get();
301
  }
302
303
  public Sigils toSigils( final Key began, final Key ended ) {
304
    assert began != null;
305
    assert ended != null;
306
    return new Sigils( stringProperty( began ), stringProperty( ended ) );
307
  }
308
309
  @SuppressWarnings( "SameParameterValue" )
310
  public IntegerProperty integerProperty( final Key key ) {
311
    assert key != null;
312
    return valuesProperty( key );
313
  }
314
315
  @SuppressWarnings( "SameParameterValue" )
316
  public DoubleProperty doubleProperty( final Key key ) {
317
    assert key != null;
318
    return valuesProperty( key );
319
  }
320
321
  /**
322
   * Returns the {@link File} {@link Property} associated with the given
323
   * {@link Key} from the internal list of preference values. The caller
324
   * must be sure that the given {@link Key} is associated with a {@link File}
325
   * {@link Property}.
326
   *
327
   * @param key The {@link Key} associated with a preference value.
328
   * @return The value associated with the given {@link Key}.
329
   */
330
  public ObjectProperty<File> fileProperty( final Key key ) {
331
    assert key != null;
332
    return valuesProperty( key );
333
  }
334
335
  public ObjectProperty<String> skinProperty( final Key key ) {
336
    assert key != null;
337
    return valuesProperty( key );
338
  }
339
340
  public LocaleProperty localeProperty( final Key key ) {
341
    assert key != null;
342
    return valuesProperty( key );
343
  }
344
345
  /**
346
   * Returns the language locale setting for the
347
   * {@link WorkspaceKeys#KEY_LANGUAGE_LOCALE} key.
348
   *
349
   * @return The user's current locale setting.
350
   */
351
  public Locale getLocale() {
352
    return localeProperty( KEY_LANGUAGE_LOCALE ).toLocale();
353
  }
354
355
  public StringProperty stringProperty( final Key key ) {
356
    assert key != null;
357
    return valuesProperty( key );
358
  }
359
360
  public BooleanProperty booleanProperty( final Key key ) {
361
    assert key != null;
362
    return valuesProperty( key );
363
  }
364
365
  public void loadValueKeys( final Consumer<Key> consumer ) {
366
    VALUES.keySet().forEach( consumer );
367
  }
368
369
  public void loadSetKeys( final Consumer<Key> consumer ) {
370
    SETS.keySet().forEach( consumer );
371
  }
372
373
  /**
374
   * Calls the given consumer for all single-value keys. For lists, see
375
   * {@link #saveSets(BiConsumer)}.
376
   *
377
   * @param consumer Called to accept each preference key value.
378
   */
379
  public void saveValues( final BiConsumer<Key, Property<?>> consumer ) {
380
    VALUES.forEach( consumer );
381
  }
382
383
  /**
384
   * Calls the given consumer for all multi-value keys. For single items, see
385
   * {@link #saveValues(BiConsumer)}. Callers are responsible for iterating
386
   * over the list of items retrieved through this method.
387
   *
388
   * @param consumer Called to accept each preference key list.
389
   */
390
  public void saveSets( final BiConsumer<Key, SetProperty<?>> consumer ) {
391
    SETS.forEach( consumer );
392
  }
393
394
  /**
395
   * Delegates to {@link #listen(Key, ReadOnlyProperty, BooleanSupplier)},
396
   * providing a value of {@code true} for the {@link BooleanSupplier} to
397
   * indicate the property changes always take effect.
398
   *
399
   * @param key      The value to bind to the internal key property.
400
   * @param property The external property value that sets the internal value.
401
   */
402
  public <T> void listen( final Key key, final ReadOnlyProperty<T> property ) {
403
    listen( key, property, () -> true );
404
  }
405
406
  /**
407
   * Binds a read-only property to a value in the preferences. This allows
408
   * user interface properties to change and the preferences will be
409
   * synchronized automatically.
410
   * <p>
411
   * This calls {@link Platform#runLater(Runnable)} to ensure that all pending
412
   * application window states are finished before assessing whether property
413
   * changes should be applied. Without this, exiting the application while the
414
   * window is maximized would persist the window's maximum dimensions,
415
   * preventing restoration to its prior, non-maximum size.
416
   * </p>
417
   *
418
   * @param key      The value to bind to the internal key property.
419
   * @param property The external property value that sets the internal value.
420
   * @param enabled  Indicates whether property changes should be applied.
421
   */
422
  public <T> void listen(
423
    final Key key,
424
    final ReadOnlyProperty<T> property,
425
    final BooleanSupplier enabled ) {
426
    property.addListener(
427
      ( c, o, n ) -> runLater( () -> {
428
        if( enabled.getAsBoolean() ) {
429
          valuesProperty( key ).setValue( n );
430
        }
431
      } )
432
    );
433
  }
434
435
  /**
436
   * Saves the current workspace.
437
   */
438
  public void save() {
439
    try {
440
      final var config = new XMLConfiguration();
441
442
      // The root config key can only be set for an empty configuration file.
443
      config.setRootElementName( APP_TITLE_LOWERCASE );
444
      valuesProperty( KEY_META_VERSION ).setValue( getVersion() );
445
446
      saveValues( ( key, property ) ->
447
                    config.setProperty( key.toString(), marshall( property ) )
448
      );
449
450
      saveSets( ( key, set ) -> {
451
        final var keyName = key.toString();
452
        set.forEach( ( value ) -> config.addProperty( keyName, value ) );
453
      } );
454
      new FileHandler( config ).save( FILE_PREFERENCES );
455
    } catch( final Exception ex ) {
456
      clue( ex );
457
    }
458
  }
459
460
  /**
461
   * Attempts to load the {@link Constants#FILE_PREFERENCES} configuration file.
462
   * If not found, this will fall back to an empty configuration file, leaving
463
   * the application to fill in default values.
464
   *
465
   * @param filename The file containing user preferences to load.
466
   */
467
  private void load( final String filename ) {
468
    try {
469
      final var config = new Configurations().xml( filename );
470
471
      loadValueKeys( ( key ) -> {
472
        final var configValue = config.getProperty( key.toString() );
473
474
        // Allow other properties to load, even if any are missing.
475
        if( configValue != null ) {
476
          final var propertyValue = valuesProperty( key );
477
          propertyValue.setValue( unmarshall( propertyValue, configValue ) );
478
        }
479
      } );
480
481
      loadSetKeys( ( key ) -> {
482
        final var configSet =
483
          new LinkedHashSet<>( config.getList( key.toString() ) );
484
        final var propertySet = setsProperty( key );
485
        propertySet.setValue( observableSet( configSet ) );
486
      } );
487
    } catch( final Exception ex ) {
488
      clue( ex );
489
    }
490
  }
491
492
  private Object unmarshall(
493
    final Property<?> property, final Object configValue ) {
494
    final var setting = configValue.toString();
495
496
    return UNMARSHALL
497
      .getOrDefault( property.getClass(), ( value ) -> value )
498
      .apply( setting );
499
  }
500
501
  private Object marshall( final Property<?> property ) {
502
    return property.getValue() == null
503
      ? null
504
      : MARSHALL
505
      .getOrDefault( property.getClass(), ( __ ) -> property.getValue() )
506
      .apply( property.getValue().toString() );
507
  }
508
509
  private String getYear() {
510
    return valueOf( Year.now().getValue() );
511
  }
512
513
  private String getDate() {
514
    return ZonedDateTime.now().format( RFC_1123_DATE_TIME );
4
import com.keenwrite.io.MediaType;
5
import com.keenwrite.sigils.PropertyKeyOperator;
6
import com.keenwrite.sigils.SigilKeyOperator;
7
import javafx.application.Platform;
8
import javafx.beans.property.*;
9
import javafx.collections.ObservableList;
10
11
import java.io.File;
12
import java.nio.file.Path;
13
import java.util.*;
14
import java.util.Map.Entry;
15
import java.util.function.BooleanSupplier;
16
import java.util.function.Function;
17
18
import static com.keenwrite.Bootstrap.APP_TITLE_LOWERCASE;
19
import static com.keenwrite.Launcher.getVersion;
20
import static com.keenwrite.constants.Constants.*;
21
import static com.keenwrite.events.StatusEvent.clue;
22
import static com.keenwrite.preferences.AppKeys.*;
23
import static java.util.Map.entry;
24
import static javafx.application.Platform.runLater;
25
import static javafx.collections.FXCollections.observableArrayList;
26
import static javafx.collections.FXCollections.observableSet;
27
28
/**
29
 * Responsible for defining behaviours for separate projects. A workspace has
30
 * the ability to save and restore a session, including the window dimensions,
31
 * tab setup, files, and user preferences.
32
 * <p>
33
 * The configuration must support hierarchical (nested) configuration nodes
34
 * to persist the user interface state. Although possible with a flat
35
 * configuration file, it's not nearly as simple or elegant.
36
 * </p>
37
 * <p>
38
 * Neither JSON nor HOCON support schema validation and versioning, which makes
39
 * XML the more suitable configuration file format. Schema validation and
40
 * versioning provide future-proofing and ease of reading and upgrading previous
41
 * versions of the configuration file.
42
 * </p>
43
 * <p>
44
 * Persistent preferences may be set directly by the user or indirectly by
45
 * the act of using the application.
46
 * </p>
47
 * <p>
48
 * Note the following definitions:
49
 * </p>
50
 * <dl>
51
 *   <dt>File</dt>
52
 *   <dd>References a file name (no path), path, or directory.</dd>
53
 *   <dt>Path</dt>
54
 *   <dd>Fully qualified file name, which includes all parent directories.</dd>
55
 *   <dt>Dir</dt>
56
 *   <dd>Directory without file name ({@link File#isDirectory()} is true).</dd>
57
 * </dl>
58
 */
59
public final class Workspace implements KeyConfiguration {
60
  /**
61
   * Main configuration values, single text strings.
62
   */
63
  private final Map<Key, Property<?>> mValues = Map.ofEntries(
64
    entry( KEY_META_VERSION, asStringProperty( getVersion() ) ),
65
    entry( KEY_META_NAME, asStringProperty( "default" ) ),
66
67
    entry( KEY_EDITOR_AUTOSAVE, asIntegerProperty( 30 ) ),
68
69
    entry( KEY_R_SCRIPT, asStringProperty( "" ) ),
70
    entry( KEY_R_DIR, asFileProperty( USER_DIRECTORY ) ),
71
    entry( KEY_R_DELIM_BEGAN, asStringProperty( R_DELIM_BEGAN_DEFAULT ) ),
72
    entry( KEY_R_DELIM_ENDED, asStringProperty( R_DELIM_ENDED_DEFAULT ) ),
73
74
    entry( KEY_IMAGES_DIR, asFileProperty( USER_DIRECTORY ) ),
75
    entry( KEY_IMAGES_ORDER, asStringProperty( PERSIST_IMAGES_DEFAULT ) ),
76
    entry( KEY_IMAGES_RESIZE, asBooleanProperty( true ) ),
77
    entry( KEY_IMAGES_SERVER, asStringProperty( DIAGRAM_SERVER_NAME ) ),
78
79
    entry( KEY_DEF_PATH, asFileProperty( DEFINITION_DEFAULT ) ),
80
    entry( KEY_DEF_DELIM_BEGAN, asStringProperty( DEF_DELIM_BEGAN_DEFAULT ) ),
81
    entry( KEY_DEF_DELIM_ENDED, asStringProperty( DEF_DELIM_ENDED_DEFAULT ) ),
82
83
    entry( KEY_UI_RECENT_DIR, asFileProperty( USER_DIRECTORY ) ),
84
    entry( KEY_UI_RECENT_DOCUMENT, asFileProperty( DOCUMENT_DEFAULT ) ),
85
    entry( KEY_UI_RECENT_DEFINITION, asFileProperty( DEFINITION_DEFAULT ) ),
86
    entry( KEY_UI_RECENT_EXPORT, asFileProperty( PDF_DEFAULT ) ),
87
88
    //@formatter:off
89
    entry( KEY_UI_FONT_EDITOR_NAME, asStringProperty( FONT_NAME_EDITOR_DEFAULT ) ),
90
    entry( KEY_UI_FONT_EDITOR_SIZE, asDoubleProperty( FONT_SIZE_EDITOR_DEFAULT ) ),
91
    entry( KEY_UI_FONT_PREVIEW_NAME, asStringProperty( FONT_NAME_PREVIEW_DEFAULT ) ),
92
    entry( KEY_UI_FONT_PREVIEW_SIZE, asDoubleProperty( FONT_SIZE_PREVIEW_DEFAULT ) ),
93
    entry( KEY_UI_FONT_PREVIEW_MONO_NAME, asStringProperty( FONT_NAME_PREVIEW_MONO_NAME_DEFAULT ) ),
94
    entry( KEY_UI_FONT_PREVIEW_MONO_SIZE, asDoubleProperty( FONT_SIZE_PREVIEW_MONO_SIZE_DEFAULT ) ),
95
96
    entry( KEY_UI_WINDOW_X, asDoubleProperty( WINDOW_X_DEFAULT ) ),
97
    entry( KEY_UI_WINDOW_Y, asDoubleProperty( WINDOW_Y_DEFAULT ) ),
98
    entry( KEY_UI_WINDOW_W, asDoubleProperty( WINDOW_W_DEFAULT ) ),
99
    entry( KEY_UI_WINDOW_H, asDoubleProperty( WINDOW_H_DEFAULT ) ),
100
    entry( KEY_UI_WINDOW_MAX, asBooleanProperty() ),
101
    entry( KEY_UI_WINDOW_FULL, asBooleanProperty() ),
102
103
    entry( KEY_UI_SKIN_SELECTION, asSkinProperty( SKIN_DEFAULT ) ),
104
    entry( KEY_UI_SKIN_CUSTOM, asFileProperty( SKIN_CUSTOM_DEFAULT ) ),
105
106
    entry( KEY_UI_PREVIEW_STYLESHEET, asFileProperty( PREVIEW_CUSTOM_DEFAULT ) ),
107
108
    entry( KEY_LANGUAGE_LOCALE, asLocaleProperty( LOCALE_DEFAULT ) ),
109
110
    entry( KEY_TYPESET_CONTEXT_CLEAN, asBooleanProperty( true ) ),
111
    entry( KEY_TYPESET_CONTEXT_THEMES_PATH, asFileProperty( USER_DIRECTORY ) ),
112
    entry( KEY_TYPESET_CONTEXT_THEME_SELECTION, asStringProperty( "boschet" ) ),
113
    entry( KEY_TYPESET_TYPOGRAPHY_QUOTES, asBooleanProperty( true ) )
114
    //@formatter:on
115
  );
116
117
  /**
118
   * Sets of configuration values, all the same type (e.g., file names),
119
   * where the key name doesn't change per set.
120
   */
121
  private final Map<Key, SetProperty<?>> mSets = Map.ofEntries(
122
    entry(
123
      KEY_UI_RECENT_OPEN_PATH,
124
      createSetProperty( new HashSet<String>() )
125
    )
126
  );
127
128
  /**
129
   * Lists of configuration values, such as key-value pairs where both the
130
   * key name and the value must be preserved per list.
131
   */
132
  private final Map<Key, ListProperty<?>> mLists = Map.ofEntries(
133
    entry(
134
      KEY_DOC_META,
135
      createListProperty( new LinkedList<Entry<String, String>>() )
136
    )
137
  );
138
139
  /**
140
   * Helps instantiate {@link Property} instances for XML configuration items.
141
   */
142
  private static final Map<Class<?>, Function<String, Object>> UNMARSHALL =
143
    Map.of(
144
      LocaleProperty.class, LocaleProperty::parseLocale,
145
      SimpleBooleanProperty.class, Boolean::parseBoolean,
146
      SimpleIntegerProperty.class, Integer::parseInt,
147
      SimpleDoubleProperty.class, Double::parseDouble,
148
      SimpleFloatProperty.class, Float::parseFloat,
149
      SimpleStringProperty.class, String::new,
150
      SimpleObjectProperty.class, String::new,
151
      SkinProperty.class, String::new,
152
      FileProperty.class, File::new
153
    );
154
155
  /**
156
   * The asymmetry with respect to {@link #UNMARSHALL} is because most objects
157
   * can simply call {@link Object#toString()} to convert the value to a string.
158
   */
159
  private static final Map<Class<?>, Function<String, Object>> MARSHALL =
160
    Map.of(
161
      LocaleProperty.class, LocaleProperty::toLanguageTag
162
    );
163
164
  /**
165
   * Converts the given {@link Property} value to a string.
166
   *
167
   * @param property The {@link Property} to convert.
168
   * @return A string representation of the given property, or the empty
169
   * string if no conversion was possible.
170
   */
171
  private static String marshall( final Property<?> property ) {
172
    final var v = property.getValue();
173
174
    return v == null
175
      ? ""
176
      : MARSHALL
177
      .getOrDefault( property.getClass(), __ -> property.getValue() )
178
      .apply( v.toString() )
179
      .toString();
180
  }
181
182
  private static Object unmarshall(
183
    final Property<?> property, final Object configValue ) {
184
    final var v = configValue.toString();
185
186
    return UNMARSHALL
187
      .getOrDefault( property.getClass(), value -> property.getValue() )
188
      .apply( v );
189
  }
190
191
  /**
192
   * Creates an instance of {@link ObservableList} that is based on a
193
   * modifiable observable array list for the given items.
194
   *
195
   * @param items The items to wrap in an observable list.
196
   * @param <E>   The type of items to add to the list.
197
   * @return An observable property that can have its contents modified.
198
   */
199
  public static <E> ObservableList<E> listProperty( final Set<E> items ) {
200
    return new SimpleListProperty<>( observableArrayList( items ) );
201
  }
202
203
  private static <E> SetProperty<E> createSetProperty( final Set<E> set ) {
204
    return new SimpleSetProperty<>( observableSet( set ) );
205
  }
206
207
  private static <E> ListProperty<E> createListProperty( final List<E> list ) {
208
    return new SimpleListProperty<>( observableArrayList( list ) );
209
  }
210
211
  private static StringProperty asStringProperty( final String value ) {
212
    return new SimpleStringProperty( value );
213
  }
214
215
  private static BooleanProperty asBooleanProperty() {
216
    return new SimpleBooleanProperty();
217
  }
218
219
  /**
220
   * @param value Default value.
221
   */
222
  @SuppressWarnings( "SameParameterValue" )
223
  private static BooleanProperty asBooleanProperty( final boolean value ) {
224
    return new SimpleBooleanProperty( value );
225
  }
226
227
  /**
228
   * @param value Default value.
229
   */
230
  @SuppressWarnings( "SameParameterValue" )
231
  private static IntegerProperty asIntegerProperty( final int value ) {
232
    return new SimpleIntegerProperty( value );
233
  }
234
235
  /**
236
   * @param value Default value.
237
   */
238
  private static DoubleProperty asDoubleProperty( final double value ) {
239
    return new SimpleDoubleProperty( value );
240
  }
241
242
  /**
243
   * @param value Default value.
244
   */
245
  private static FileProperty asFileProperty( final File value ) {
246
    return new FileProperty( value );
247
  }
248
249
  /**
250
   * @param value Default value.
251
   */
252
  @SuppressWarnings( "SameParameterValue" )
253
  private static LocaleProperty asLocaleProperty( final Locale value ) {
254
    return new LocaleProperty( value );
255
  }
256
257
  /**
258
   * @param value Default value.
259
   */
260
  @SuppressWarnings( "SameParameterValue" )
261
  private static SkinProperty asSkinProperty( final String value ) {
262
    return new SkinProperty( value );
263
  }
264
265
  /**
266
   * Creates a new {@link Workspace} that will attempt to load the users'
267
   * preferences. If the configuration file cannot be loaded, the workspace
268
   * settings returns default values.
269
   */
270
  public Workspace() {
271
    load();
272
  }
273
274
  /**
275
   * Attempts to load the app's configuration file.
276
   */
277
  private void load() {
278
    final var store = createXmlStore();
279
    store.load( FILE_PREFERENCES );
280
281
    mValues.keySet().forEach( key -> {
282
      try {
283
        final var storeValue = store.getValue( key );
284
        final var property = valuesProperty( key );
285
286
        property.setValue( unmarshall( property, storeValue ) );
287
      } catch( final NoSuchElementException ignored ) {
288
        // When no configuration (item), use the default value.
289
      }
290
    } );
291
292
    mSets.keySet().forEach( key -> {
293
      final var set = store.getSet( key );
294
      final SetProperty<String> property = setsProperty( key );
295
296
      property.setValue( observableSet( set ) );
297
    } );
298
299
    mLists.keySet().forEach( key -> {
300
      final var map = store.getMap( key );
301
      final ListProperty<Entry<String, String>> property = listsProperty( key );
302
      final var list = map
303
        .entrySet()
304
        .stream()
305
        .toList();
306
307
      property.setValue( observableArrayList( list ) );
308
    } );
309
  }
310
311
  /**
312
   * Saves the current workspace.
313
   */
314
  public void save() {
315
    final var store = createXmlStore();
316
317
    try {
318
      // Update the string values to include the application version.
319
      valuesProperty( KEY_META_VERSION ).setValue( getVersion() );
320
321
      mValues.forEach( ( k, v ) -> store.setValue( k, marshall( v ) ) );
322
      mSets.forEach( store::setSet );
323
      mLists.forEach( store::setMap );
324
325
      store.save( FILE_PREFERENCES );
326
    } catch( final Exception ex ) {
327
      clue( ex );
328
    }
329
  }
330
331
  /**
332
   * Returns a value that represents a setting in the application that the user
333
   * may configure, either directly or indirectly.
334
   *
335
   * @param key The reference to the users' preference stored in deference
336
   *            of app reëntrance.
337
   * @return An observable property to be persisted.
338
   */
339
  @SuppressWarnings( "unchecked" )
340
  public <T, U extends Property<T>> U valuesProperty( final Key key ) {
341
    assert key != null;
342
    return (U) mValues.get( key );
343
  }
344
345
  /**
346
   * Returns a set of values that represent a setting in the application that
347
   * the user may configure, either directly or indirectly. The property
348
   * returned is backed by a {@link Set}.
349
   *
350
   * @param key The {@link Key} associated with a preference value.
351
   * @return An observable property to be persisted.
352
   */
353
  @SuppressWarnings( "unchecked" )
354
  public <T> SetProperty<T> setsProperty( final Key key ) {
355
    assert key != null;
356
    return (SetProperty<T>) mSets.get( key );
357
  }
358
359
  /**
360
   * Returns a list of values that represent a setting in the application that
361
   * the user may configure, either directly or indirectly. The property
362
   * returned is backed by a mutable {@link List}.
363
   *
364
   * @param key The {@link Key} associated with a preference value.
365
   * @return An observable property to be persisted.
366
   */
367
  @SuppressWarnings( "unchecked" )
368
  public <K, V> ListProperty<Entry<K, V>> listsProperty( final Key key ) {
369
    assert key != null;
370
    return (ListProperty<Entry<K, V>>) mLists.get( key );
371
  }
372
373
  /**
374
   * Returns the {@link String} {@link Property} associated with the given
375
   * {@link Key} from the internal list of preference values. The caller
376
   * must be sure that the given {@link Key} is associated with a {@link File}
377
   * {@link Property}.
378
   *
379
   * @param key The {@link Key} associated with a preference value.
380
   * @return The value associated with the given {@link Key}.
381
   */
382
  public StringProperty stringProperty( final Key key ) {
383
    assert key != null;
384
    return valuesProperty( key );
385
  }
386
387
  /**
388
   * Returns the {@link Boolean} {@link Property} associated with the given
389
   * {@link Key} from the internal list of preference values. The caller
390
   * must be sure that the given {@link Key} is associated with a {@link File}
391
   * {@link Property}.
392
   *
393
   * @param key The {@link Key} associated with a preference value.
394
   * @return The value associated with the given {@link Key}.
395
   */
396
  public BooleanProperty booleanProperty( final Key key ) {
397
    assert key != null;
398
    return valuesProperty( key );
399
  }
400
401
  /**
402
   * Returns the {@link Integer} {@link Property} associated with the given
403
   * {@link Key} from the internal list of preference values. The caller
404
   * must be sure that the given {@link Key} is associated with a {@link File}
405
   * {@link Property}.
406
   *
407
   * @param key The {@link Key} associated with a preference value.
408
   * @return The value associated with the given {@link Key}.
409
   */
410
  public IntegerProperty integerProperty( final Key key ) {
411
    assert key != null;
412
    return valuesProperty( key );
413
  }
414
415
  /**
416
   * Returns the {@link Double} {@link Property} associated with the given
417
   * {@link Key} from the internal list of preference values. The caller
418
   * must be sure that the given {@link Key} is associated with a {@link File}
419
   * {@link Property}.
420
   *
421
   * @param key The {@link Key} associated with a preference value.
422
   * @return The value associated with the given {@link Key}.
423
   */
424
  public DoubleProperty doubleProperty( final Key key ) {
425
    assert key != null;
426
    return valuesProperty( key );
427
  }
428
429
  /**
430
   * Returns the {@link File} {@link Property} associated with the given
431
   * {@link Key} from the internal list of preference values. The caller
432
   * must be sure that the given {@link Key} is associated with a {@link File}
433
   * {@link Property}.
434
   *
435
   * @param key The {@link Key} associated with a preference value.
436
   * @return The value associated with the given {@link Key}.
437
   */
438
  public ObjectProperty<File> fileProperty( final Key key ) {
439
    assert key != null;
440
    return valuesProperty( key );
441
  }
442
443
  /**
444
   * Returns the {@link Locale} {@link Property} associated with the given
445
   * {@link Key} from the internal list of preference values. The caller
446
   * must be sure that the given {@link Key} is associated with a {@link File}
447
   * {@link Property}.
448
   *
449
   * @param key The {@link Key} associated with a preference value.
450
   * @return The value associated with the given {@link Key}.
451
   */
452
  public LocaleProperty localeProperty( final Key key ) {
453
    assert key != null;
454
    return valuesProperty( key );
455
  }
456
457
  public ObjectProperty<String> skinProperty( final Key key ) {
458
    assert key != null;
459
    return valuesProperty( key );
460
  }
461
462
  @Override
463
  public String getString( final Key key ) {
464
    assert key != null;
465
    return stringProperty( key ).get();
466
  }
467
468
  /**
469
   * Returns the {@link Boolean} preference value associated with the given
470
   * {@link Key}. The caller must be sure that the given {@link Key} is
471
   * associated with a value that matches the return type.
472
   *
473
   * @param key The {@link Key} associated with a preference value.
474
   * @return The value associated with the given {@link Key}.
475
   */
476
  @Override
477
  public boolean getBoolean( final Key key ) {
478
    assert key != null;
479
    return booleanProperty( key ).get();
480
  }
481
482
  /**
483
   * Returns the {@link Integer} preference value associated with the given
484
   * {@link Key}. The caller must be sure that the given {@link Key} is
485
   * associated with a value that matches the return type.
486
   *
487
   * @param key The {@link Key} associated with a preference value.
488
   * @return The value associated with the given {@link Key}.
489
   */
490
  @Override
491
  public int getInteger( final Key key ) {
492
    assert key != null;
493
    return integerProperty( key ).get();
494
  }
495
496
  /**
497
   * Returns the {@link Double} preference value associated with the given
498
   * {@link Key}. The caller must be sure that the given {@link Key} is
499
   * associated with a value that matches the return type.
500
   *
501
   * @param key The {@link Key} associated with a preference value.
502
   * @return The value associated with the given {@link Key}.
503
   */
504
  @Override
505
  public double getDouble( final Key key ) {
506
    assert key != null;
507
    return doubleProperty( key ).get();
508
  }
509
510
  /**
511
   * Returns the {@link File} preference value associated with the given
512
   * {@link Key}. The caller must be sure that the given {@link Key} is
513
   * associated with a value that matches the return type.
514
   *
515
   * @param key The {@link Key} associated with a preference value.
516
   * @return The value associated with the given {@link Key}.
517
   */
518
  @Override
519
  public File getFile( final Key key ) {
520
    assert key != null;
521
    return fileProperty( key ).get();
522
  }
523
524
  /**
525
   * Returns the language locale setting for the
526
   * {@link AppKeys#KEY_LANGUAGE_LOCALE} key.
527
   *
528
   * @return The user's current locale setting.
529
   */
530
  public Locale getLocale() {
531
    return localeProperty( KEY_LANGUAGE_LOCALE ).toLocale();
532
  }
533
534
  public SigilKeyOperator createDefinitionKeyOperator() {
535
    final var began = getString( KEY_DEF_DELIM_BEGAN );
536
    final var ended = getString( KEY_DEF_DELIM_ENDED );
537
538
    return new SigilKeyOperator( began, ended );
539
  }
540
541
  public SigilKeyOperator createPropertyKeyOperator() {
542
    return new PropertyKeyOperator();
543
  }
544
545
  /**
546
   * Delegates to {@link #listen(Key, ReadOnlyProperty, BooleanSupplier)},
547
   * providing a value of {@code true} for the {@link BooleanSupplier} to
548
   * indicate the property changes always take effect.
549
   *
550
   * @param key      The value to bind to the internal key property.
551
   * @param property The external property value that sets the internal value.
552
   */
553
  public <T> void listen( final Key key, final ReadOnlyProperty<T> property ) {
554
    assert key != null;
555
    assert property != null;
556
557
    listen( key, property, () -> true );
558
  }
559
560
  /**
561
   * Binds a read-only property to a value in the preferences. This allows
562
   * user interface properties to change and the preferences will be
563
   * synchronized automatically.
564
   * <p>
565
   * This calls {@link Platform#runLater(Runnable)} to ensure that all pending
566
   * application window states are finished before assessing whether property
567
   * changes should be applied. Without this, exiting the application while the
568
   * window is maximized would persist the window's maximum dimensions,
569
   * preventing restoration to its prior, non-maximum size.
570
   *
571
   * @param key      The value to bind to the internal key property.
572
   * @param property The external property value that sets the internal value.
573
   * @param enabled  Indicates whether property changes should be applied.
574
   */
575
  public <T> void listen(
576
    final Key key,
577
    final ReadOnlyProperty<T> property,
578
    final BooleanSupplier enabled ) {
579
    assert key != null;
580
    assert property != null;
581
    assert enabled != null;
582
583
    property.addListener(
584
      ( c, o, n ) -> runLater( () -> {
585
        if( enabled.getAsBoolean() ) {
586
          valuesProperty( key ).setValue( n );
587
        }
588
      } )
589
    );
590
  }
591
592
  /**
593
   * Returns the sigil operator for the given {@link MediaType}.
594
   *
595
   * @param mediaType The type of file being edited.
596
   */
597
  public SigilKeyOperator createSigilOperator( final MediaType mediaType ) {
598
    assert mediaType != null;
599
600
    return mediaType == MediaType.TEXT_PROPERTIES
601
      ? createPropertyKeyOperator()
602
      : createDefinitionKeyOperator();
603
  }
604
605
  /**
606
   * Returns the sigil operator for the given {@link Path}.
607
   *
608
   * @param path The type of file being edited, from its extension.
609
   */
610
  public SigilKeyOperator createSigilOperator( final Path path ) {
611
    assert path != null;
612
613
    return createSigilOperator( MediaType.valueFrom( path ) );
614
  }
615
616
  /**
617
   * Creates a lightweight persistence mechanism for user preferences.
618
   *
619
   * @return The {@link XmlStore} that helps with persisting application state.
620
   */
621
  private XmlStore createXmlStore() {
622
    // Root-level configuration item is the application name.
623
    return new XmlStore( APP_TITLE_LOWERCASE );
515624
  }
516625
}
D src/main/java/com/keenwrite/preferences/WorkspaceKeys.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.preferences;
3
4
import static com.keenwrite.preferences.Key.key;
5
6
/**
7
 * Responsible for defining constants used throughout the application that
8
 * represent persisted preferences.
9
 */
10
public final class WorkspaceKeys {
11
  //@formatter:off
12
  private static final Key KEY_ROOT = key( "workspace" );
13
14
  public static final Key KEY_META = key( KEY_ROOT, "meta" );
15
  public static final Key KEY_META_NAME = key( KEY_META, "name" );
16
  public static final Key KEY_META_VERSION = key( KEY_META, "version" );
17
18
  public static final Key KEY_DOC = key( KEY_ROOT, "document" );
19
  public static final Key KEY_DOC_TITLE = key( KEY_DOC, "title" );
20
  public static final Key KEY_DOC_AUTHOR = key( KEY_DOC, "author" );
21
  public static final Key KEY_DOC_BYLINE = key( KEY_DOC, "byline" );
22
  public static final Key KEY_DOC_ADDRESS = key( KEY_DOC, "address" );
23
  public static final Key KEY_DOC_PHONE = key( KEY_DOC, "phone" );
24
  public static final Key KEY_DOC_EMAIL = key( KEY_DOC, "email" );
25
  public static final Key KEY_DOC_KEYWORDS = key( KEY_DOC, "keywords" );
26
  public static final Key KEY_DOC_DATE = key( KEY_DOC, "date" );
27
  public static final Key KEY_DOC_COPYRIGHT = key( KEY_DOC, "copyright" );
28
29
  public static final Key KEY_EDITOR = key( KEY_ROOT, "editor" );
30
  public static final Key KEY_EDITOR_AUTOSAVE = key( KEY_EDITOR, "autosave" );
31
32
  public static final Key KEY_R = key( KEY_ROOT, "r" );
33
  public static final Key KEY_R_SCRIPT = key( KEY_R, "script" );
34
  public static final Key KEY_R_DIR = key( KEY_R, "dir" );
35
  public static final Key KEY_R_DELIM = key( KEY_R, "delimiter" );
36
  public static final Key KEY_R_DELIM_BEGAN = key( KEY_R_DELIM, "began" );
37
  public static final Key KEY_R_DELIM_ENDED = key( KEY_R_DELIM, "ended" );
38
39
  public static final Key KEY_IMAGES = key( KEY_ROOT, "images" );
40
  public static final Key KEY_IMAGES_DIR = key( KEY_IMAGES, "dir" );
41
  public static final Key KEY_IMAGES_ORDER = key( KEY_IMAGES, "order" );
42
  public static final Key KEY_IMAGES_RESIZE = key( KEY_IMAGES, "resize" );
43
  public static final Key KEY_IMAGES_SERVER = key( KEY_IMAGES, "server" );
44
45
  public static final Key KEY_DEF = key( KEY_ROOT, "definition" );
46
  public static final Key KEY_DEF_PATH = key( KEY_DEF, "path" );
47
  public static final Key KEY_DEF_DELIM = key( KEY_DEF, "delimiter" );
48
  public static final Key KEY_DEF_DELIM_BEGAN = key( KEY_DEF_DELIM, "began" );
49
  public static final Key KEY_DEF_DELIM_ENDED = key( KEY_DEF_DELIM, "ended" );
50
51
  public static final Key KEY_UI = key( KEY_ROOT, "ui" );
52
53
  public static final Key KEY_UI_RECENT = key( KEY_UI, "recent" );
54
  public static final Key KEY_UI_RECENT_DIR = key( KEY_UI_RECENT, "dir" );
55
  public static final Key KEY_UI_RECENT_DOCUMENT = key( KEY_UI_RECENT, "document" );
56
  public static final Key KEY_UI_RECENT_DEFINITION = key( KEY_UI_RECENT, "definition" );
57
  public static final Key KEY_UI_RECENT_EXPORT = key( KEY_UI_RECENT, "export" );
58
59
  public static final Key KEY_UI_FILES = key( KEY_UI, "files" );
60
  public static final Key KEY_UI_FILES_PATH = key( KEY_UI_FILES, "path" );
61
62
  public static final Key KEY_UI_FONT = key( KEY_UI, "font" );
63
  public static final Key KEY_UI_FONT_EDITOR = key( KEY_UI_FONT, "editor" );
64
  public static final Key KEY_UI_FONT_EDITOR_NAME = key( KEY_UI_FONT_EDITOR, "name" );
65
  public static final Key KEY_UI_FONT_EDITOR_SIZE = key( KEY_UI_FONT_EDITOR, "size" );
66
  public static final Key KEY_UI_FONT_PREVIEW = key( KEY_UI_FONT, "preview" );
67
  public static final Key KEY_UI_FONT_PREVIEW_NAME = key( KEY_UI_FONT_PREVIEW, "name" );
68
  public static final Key KEY_UI_FONT_PREVIEW_SIZE = key( KEY_UI_FONT_PREVIEW, "size" );
69
  public static final Key KEY_UI_FONT_PREVIEW_MONO = key( KEY_UI_FONT_PREVIEW, "mono" );
70
  public static final Key KEY_UI_FONT_PREVIEW_MONO_NAME = key( KEY_UI_FONT_PREVIEW_MONO, "name" );
71
  public static final Key KEY_UI_FONT_PREVIEW_MONO_SIZE = key( KEY_UI_FONT_PREVIEW_MONO, "size" );
72
73
  public static final Key KEY_UI_WINDOW = key( KEY_UI, "window" );
74
  public static final Key KEY_UI_WINDOW_X = key( KEY_UI_WINDOW, "x" );
75
  public static final Key KEY_UI_WINDOW_Y = key( KEY_UI_WINDOW, "y" );
76
  public static final Key KEY_UI_WINDOW_W = key( KEY_UI_WINDOW, "width" );
77
  public static final Key KEY_UI_WINDOW_H = key( KEY_UI_WINDOW, "height" );
78
  public static final Key KEY_UI_WINDOW_MAX = key( KEY_UI_WINDOW, "maximized" );
79
  public static final Key KEY_UI_WINDOW_FULL = key( KEY_UI_WINDOW, "full" );
80
81
  public static final Key KEY_UI_SKIN = key( KEY_UI, "skin" );
82
  public static final Key KEY_UI_SKIN_SELECTION = key( KEY_UI_SKIN, "selection" );
83
  public static final Key KEY_UI_SKIN_CUSTOM = key( KEY_UI_SKIN, "custom" );
84
85
  public static final Key KEY_UI_PREVIEW = key( KEY_UI, "preview" );
86
  public static final Key KEY_UI_PREVIEW_STYLESHEET = key( KEY_UI_PREVIEW, "stylesheet" );
87
88
  public static final Key KEY_LANGUAGE = key( KEY_ROOT, "language" );
89
  public static final Key KEY_LANGUAGE_LOCALE = key( KEY_LANGUAGE, "locale" );
90
91
  public static final Key KEY_TYPESET = key( KEY_ROOT, "typeset" );
92
  public static final Key KEY_TYPESET_CONTEXT = key( KEY_TYPESET, "context" );
93
  public static final Key KEY_TYPESET_CONTEXT_THEMES = key( KEY_TYPESET_CONTEXT, "themes" );
94
  public static final Key KEY_TYPESET_CONTEXT_THEMES_PATH = key( KEY_TYPESET_CONTEXT_THEMES, "path" );
95
  public static final Key KEY_TYPESET_CONTEXT_THEME_SELECTION = key( KEY_TYPESET_CONTEXT_THEMES, "selection" );
96
  public static final Key KEY_TYPESET_CONTEXT_CLEAN = key( KEY_TYPESET_CONTEXT, "clean" );
97
  public static final Key KEY_TYPESET_TYPOGRAPHY = key( KEY_TYPESET, "typography" );
98
  public static final Key KEY_TYPESET_TYPOGRAPHY_QUOTES = key( KEY_TYPESET_TYPOGRAPHY, "quotes" );
99
  //@formatter:on
100
101
  /**
102
   * Only for constants, do not instantiate.
103
   */
104
  private WorkspaceKeys() { }
105
}
1061
D src/main/java/com/keenwrite/preferences/XmlStorageHandler.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.preferences;
3
4
import com.dlsc.preferencesfx.PreferencesFx;
5
import com.dlsc.preferencesfx.util.StorageHandler;
6
import javafx.collections.ObservableList;
7
8
import java.util.prefs.Preferences;
9
10
/**
11
 * Prevents {@link PreferencesFx} from saving. Saving and loading preferences
12
 * and application window state is accomplished by the {@link Workspace}.
13
 * <p>
14
 * This implies that undo/redo functionality must be disabled because the
15
 * {@link Workspace} does not preserve previous states.
16
 * </p>
17
 */
18
public final class XmlStorageHandler implements StorageHandler {
19
  @Override
20
  public void saveSelectedCategory( final String breadcrumb ) { }
21
22
  @Override
23
  public String loadSelectedCategory() {
24
    return "";
25
  }
26
27
  @Override
28
  public void saveDividerPosition( final double dividerPosition ) {
29
  }
30
31
  @Override
32
  public double loadDividerPosition() {
33
    return 0;
34
  }
35
36
  @Override
37
  public void saveWindowWidth( final double windowWidth ) { }
38
39
  @Override
40
  public double loadWindowWidth() {
41
    return 0;
42
  }
43
44
  @Override
45
  public void saveWindowHeight( final double windowHeight ) { }
46
47
  @Override
48
  public double loadWindowHeight() {
49
    return 0;
50
  }
51
52
  @Override
53
  public void saveWindowPosX( final double windowPosX ) { }
54
55
  @Override
56
  public double loadWindowPosX() {
57
    return 0;
58
  }
59
60
  @Override
61
  public void saveWindowPosY( final double windowPosY ) { }
62
63
  @Override
64
  public double loadWindowPosY() {
65
    return 0;
66
  }
67
68
  @Override
69
  public void saveObject( final String breadcrumb, final Object object ) { }
70
71
  @Override
72
  public Object loadObject(
73
    final String breadcrumb, final Object defaultObject ) {
74
    return defaultObject;
75
  }
76
77
  @Override
78
  public <T> T loadObject(
79
    final String breadcrumb, final Class<T> type, final T defaultObject ) {
80
    return defaultObject;
81
  }
82
83
  @Override
84
  @SuppressWarnings("rawtypes")
85
  public ObservableList loadObservableList(
86
    final String breadcrumb, final ObservableList defaultObservableList ) {
87
    return defaultObservableList;
88
  }
89
90
  @Override
91
  public <T> ObservableList<T> loadObservableList(
92
    final String breadcrumb,
93
    final Class<T> type,
94
    final ObservableList<T> defaultObservableList ) {
95
    return defaultObservableList;
96
  }
97
98
  @Override
99
  public boolean clearPreferences() {
100
    return false;
101
  }
102
103
  @Override
104
  public Preferences getPreferences() {
105
    return null;
106
  }
107
}
1081
A src/main/java/com/keenwrite/preferences/XmlStore.java
1
package com.keenwrite.preferences;
2
3
import com.keenwrite.dom.DocumentParser;
4
import javafx.beans.property.ListProperty;
5
import javafx.beans.property.SetProperty;
6
import org.w3c.dom.Document;
7
import org.w3c.dom.Element;
8
import org.w3c.dom.Node;
9
10
import javax.xml.xpath.XPath;
11
import javax.xml.xpath.XPathExpression;
12
import javax.xml.xpath.XPathExpressionException;
13
import java.io.File;
14
import java.io.FileWriter;
15
import java.io.IOException;
16
import java.util.*;
17
import java.util.Map.Entry;
18
import java.util.function.Consumer;
19
20
import static javax.xml.xpath.XPathConstants.NODE;
21
22
/**
23
 * Responsible for managing XML documents, which includes reading, writing,
24
 * retrieving, and setting elements. This is an alternative to Apache
25
 * Commons Configuration, JAXB, and Jackson. All of them are heavyweight and
26
 * the latter are difficult to use with dynamic data (because they require
27
 * annotations).
28
 * <p>
29
 * <strong>Note:</strong> It is preferable to use a different instance when
30
 * loading and saving the documents. Otherwise, old and duplicate data will
31
 * persist. Using a new instance ensures that elements removed from the
32
 * user preferences will not persist across XML configuration file versions.
33
 */
34
public class XmlStore {
35
  private static final String SEPARATOR = "/";
36
37
  private final String mRoot;
38
  private Document mDocument = DocumentParser.newDocument();
39
40
  /**
41
   * Constructs a new instance with a blank {@link Document}. Call the
42
   * {@link #load(File)} method to populate the document from a {@link File},
43
   * or {@link #save(File)} to persist the current document state.
44
   *
45
   * @param root The root-level document element.
46
   */
47
  public XmlStore( final String root ) {
48
    assert root != null;
49
50
    mRoot = root;
51
  }
52
53
  /**
54
   * Loads the given configuration file into a document object model.
55
   * Clients of this class can set and retrieve elements via the requisite
56
   * access methods.
57
   *
58
   * @param config File containing persistent user preferences.
59
   */
60
  public void load( final File config ) {
61
    assert config != null;
62
    assert config.isFile();
63
64
    try {
65
      mDocument = DocumentParser.parse( config );
66
    } catch( final Exception ignored ) {
67
      mDocument = DocumentParser.newDocument();
68
    }
69
  }
70
71
  /**
72
   * Returns the document value associated with the given key name.
73
   *
74
   * @param key {@link Key} name to retrieve.
75
   * @return The value associated with the key.
76
   * @throws NoSuchElementException No value could be found for the key.
77
   */
78
  public String getValue( final Key key ) throws NoSuchElementException {
79
    assert key != null;
80
81
    try {
82
      final var node = toNode( key, mDocument );
83
84
      if( node != null ) {
85
        return node.getTextContent();
86
      }
87
    } catch( final XPathExpressionException ignored ) {}
88
89
    throw new NoSuchElementException( key.toString() );
90
  }
91
92
  /**
93
   * Returns a set of document values associated with the given key name. This
94
   * is suitable for basic sets, such as:
95
   * <pre>
96
   *   {@code
97
   *   <recent>
98
   *     <file>/tmp/filename.txt</file>
99
   *     <file>/home/username/document.md</file>
100
   *     <file>/usr/local/share/app/conf/help.Rmd</file>
101
   *   </recent>}
102
   * </pre>
103
   * <p>
104
   * The {@code file} element name can be ignored.
105
   *
106
   * @param key {@link Key} name to retrieve.
107
   * @return The values associated with the key, or an empty set if none found.
108
   */
109
  public Set<String> getSet( final Key key ) {
110
    assert key != null;
111
112
    final var set = new LinkedHashSet<String>();
113
114
    visit( key, node -> set.add( node.getTextContent() ) );
115
116
    return set;
117
  }
118
119
  /**
120
   * Returns a map of name/value pairs associated with the given key name.
121
   * This is suitable for mapped values, such as:
122
   * <pre>
123
   *   {@code
124
   *   <meta>
125
   *     <title>{{book.title}}</title>
126
   *     <author>{{book.author}}</author>
127
   *     <date>{{book.publish.date}}</date>
128
   *   </meta>}
129
   * </pre>
130
   * <p>
131
   * The element names under the {@code meta} node must be preserved along
132
   * with their values. Resolving the values based on the variable definitions
133
   * (in moustache syntax) is not a responsibility of this class.
134
   *
135
   * @param key {@link Key} name to retrieve (e.g., {@code meta}).
136
   * @return A map of element names to element values, or an empty map if
137
   * none found.
138
   */
139
  public Map<String, String> getMap( final Key key ) {
140
    assert key != null;
141
142
    // Create a new key that will match all child nodes under the given key,
143
    // extracting each element as a name/value pair for the resulting map.
144
    final var all = Key.key( key, "*" );
145
    final var map = new LinkedHashMap<String, String>();
146
147
    visit( all, node -> map.put( node.getNodeName(), node.getTextContent() ) );
148
149
    return map;
150
  }
151
152
  /**
153
   * Call to write the user preferences to a file.
154
   *
155
   * @param config The file wherein the preferences are saved.
156
   * @throws IOException Could not write to the file.
157
   */
158
  public void save( final File config ) throws IOException {
159
    assert config != null;
160
161
    try( final var writer = new FileWriter( config ) ) {
162
      writer.write( DocumentParser.toString( mDocument ) );
163
    }
164
  }
165
166
  public void setValue( final Key key, final String value ) {
167
    assert key != null;
168
    assert value != null;
169
170
    try {
171
      final var node = upsert( key, mDocument );
172
173
      node.setTextContent( value );
174
    } catch( final XPathExpressionException ignored ) {}
175
  }
176
177
  public void setSet( final Key key, final SetProperty<?> set ) {
178
    assert key != null;
179
    assert set != null;
180
181
    Node node = null;
182
183
    try {
184
      for( final var item : set ) {
185
        if( node == null ) {
186
          node = upsert( key, mDocument );
187
        }
188
        else {
189
          final var doc = node.getOwnerDocument();
190
          final var sibling = doc.createElement( key.name() );
191
          var parent = node.getParentNode();
192
193
          if( parent == null ) {
194
            parent = doc.getDocumentElement();
195
          }
196
197
          parent.appendChild( sibling );
198
          node = sibling;
199
        }
200
201
        node.setTextContent( item.toString() );
202
      }
203
    } catch( final XPathExpressionException ignored ) {}
204
  }
205
206
  /**
207
   * @param key  The application key representing a user preference.
208
   * @param list List of {@link Entry} items.
209
   */
210
  public void setMap( final Key key, final ListProperty<?> list ) {
211
    assert key != null;
212
    assert list != null;
213
214
    for( final var item : list ) {
215
      if( item instanceof Entry entry ) {
216
        try {
217
          final var child = Key.key( key, entry.getKey().toString() );
218
          final var node = upsert( child, mDocument );
219
220
          node.setTextContent( entry.getValue().toString() );
221
        } catch( final XPathExpressionException ignored ) {}
222
      }
223
    }
224
  }
225
226
  private Node toNode( final Key key, final Document doc )
227
    throws XPathExpressionException {
228
    final var xpath = toXPath( key );
229
    final var expr = DocumentParser.compile( xpath );
230
    final var element = expr.evaluate( doc, NODE );
231
232
    return element instanceof Node node ? node : null;
233
  }
234
235
  /**
236
   * Provides the equivalent of update-or-insert behaviour provided by some
237
   * SQL databases. Finds the element in the document represented by the
238
   * given {@link Key}. If no element is found then the full path to the
239
   * element is created. In essence, this method converts a hierarchy of
240
   * {@link Key} names into a hierarchy of {@link Document} {@link Element}s
241
   * (i.e., {@link Node}s).
242
   * <p>
243
   * For example, given a key named {@code workspace.meta.version}, this will
244
   * produce a document structure that, when exported as XML, resembles:
245
   * <pre>{@code
246
   *   <root>
247
   *     <workspace>
248
   *       <meta>
249
   *         <version/>
250
   *       </meta>
251
   *     </workspace>
252
   *   </root>
253
   * }</pre>
254
   * <p>
255
   * The calling code is responsible for populating the {@link Node} returned
256
   * with its particular value. In the example above, the text content of the
257
   * {@link Node} would be filled with the application version number.
258
   *
259
   * @param key The application key representing a user preference.
260
   * @param doc The document that may contain an xpath for the {@link Key}.
261
   * @return The existing or new element.
262
   */
263
  private Node upsert( final Key key, final Document doc )
264
    throws XPathExpressionException {
265
    assert key != null;
266
    assert doc != null;
267
268
    final var missing = new Stack<Key>();
269
    Key visitor = key;
270
    Node parent = null;
271
272
    do {
273
      final var node = toNode( visitor, doc );
274
275
      // If an element exists on the first iteration, return it because there
276
      // is no missing hierarchy to create.
277
      if( node != null ) {
278
        if( missing.isEmpty() ) {
279
          return node;
280
        }
281
282
        parent = node;
283
      }
284
      else {
285
        // Track the number of elements in the hierarchy that don't exist.
286
        missing.push( visitor );
287
288
        // Attempt to find the parent xpath in the document.
289
        visitor = visitor.parent();
290
      }
291
    }
292
    while( visitor != null && parent == null );
293
294
    // If the document is empty, update the top-level document element.
295
    if( parent == null ) {
296
      parent = doc.getDocumentElement();
297
298
      // If there is still no top-level element, then create it.
299
      if( parent == null ) {
300
        parent = doc.createElement( mRoot );
301
        doc.appendChild( parent );
302
      }
303
    }
304
305
    assert parent != null;
306
307
    // Create the hierarchy.
308
    while( !missing.isEmpty() ) {
309
      visitor = missing.pop();
310
311
      final var child = doc.createElement( visitor.name() );
312
      parent.appendChild( child );
313
      parent = child;
314
    }
315
316
    return parent;
317
  }
318
319
  /**
320
   * Abstraction for functionality that requires iterating over multiple
321
   * nodes under a particular xpath.
322
   *
323
   * @param key      {@link #toXPath(Key) Compiled} into an {@link XPath}.
324
   * @param consumer Accepts each node that matches the {@link XPath}.
325
   */
326
  private void visit( final Key key, final Consumer<Node> consumer ) {
327
    assert key != null;
328
    assert consumer != null;
329
330
    try {
331
      final var xpath = toXPath( key );
332
333
      DocumentParser.visit( mDocument, xpath, consumer );
334
    } catch( final XPathExpressionException ignored ) {
335
      // Programming error. Triggered by loading a previous config version?
336
    }
337
  }
338
339
  /**
340
   * Creates an {@link XPathExpression} value based on the given {@link Key}.
341
   *
342
   * @param key The {@link Key} to convert to an xpath string.
343
   * @return The given {@link Key} compiled into an {@link XPathExpression}.
344
   * @throws XPathExpressionException Could not compile the {@link Key}.
345
   */
346
  private StringBuilder toXPath( final Key key )
347
    throws XPathExpressionException {
348
    assert key != null;
349
350
    final var sb = new StringBuilder( 128 );
351
352
    key.walk( sb::append, SEPARATOR );
353
    sb.insert( 0, SEPARATOR );
354
355
    if( !mRoot.isBlank() ) {
356
      sb.insert( 0, SEPARATOR + mRoot );
357
    }
358
359
    return sb;
360
  }
361
362
  /**
363
   * Pretty-prints the XML document into a string. Meant to be used for
364
   * debugging. To save the configuration, see {@link #save(File)}.
365
   *
366
   * @return The document in a well-formed, indented, string format.
367
   */
368
  @Override
369
  public String toString() {
370
    return DocumentParser.toString( mDocument );
371
  }
372
}
1373
M src/main/java/com/keenwrite/preview/ChainedReplacedElementFactory.java
33
44
import com.keenwrite.ui.adapters.ReplacedElementAdapter;
5
import com.keenwrite.util.BoundedCache;
5
import com.keenwrite.collections.BoundedCache;
66
import org.w3c.dom.Element;
77
import org.xhtmlrenderer.extend.ReplacedElement;
M src/main/java/com/keenwrite/preview/HtmlPreview.java
2424
import static com.keenwrite.events.ScrollLockEvent.fireScrollLockEvent;
2525
import static com.keenwrite.events.StatusEvent.clue;
26
import static com.keenwrite.preferences.WorkspaceKeys.*;
27
import static com.keenwrite.ui.fonts.IconFactory.getIconFont;
28
import static java.awt.BorderLayout.*;
29
import static java.awt.event.KeyEvent.*;
30
import static java.lang.String.format;
31
import static javafx.scene.CacheHint.SPEED;
32
import static javax.swing.JComponent.WHEN_IN_FOCUSED_WINDOW;
33
import static javax.swing.KeyStroke.getKeyStroke;
34
import static javax.swing.SwingUtilities.invokeLater;
35
import static org.controlsfx.glyphfont.FontAwesome.Glyph.LOCK;
36
import static org.controlsfx.glyphfont.FontAwesome.Glyph.UNLOCK_ALT;
37
import static org.jsoup.Jsoup.parse;
38
39
/**
40
 * Responsible for parsing an HTML document.
41
 */
42
public final class HtmlPreview extends SwingNode implements ComponentListener {
43
  /**
44
   * Converts a text string to a structured HTML document.
45
   */
46
  private static final DocumentConverter CONVERTER = new DocumentConverter();
47
48
  /**
49
   * Used to populate the {@link #HTML_HEAD} with stylesheet file references.
50
   */
51
  private static final String HTML_STYLESHEET =
52
    "<link rel='stylesheet' href='%s'/>";
53
54
  private static final String HTML_BASE =
55
    "<base href='%s'/>";
56
57
  /**
58
   * Render CSS using points (pt) not pixels (px) to reduce the chance of
59
   * poor rendering. The {@link #generateHead()} method fills placeholders.
60
   * When the user has not set a locale, only one stylesheet is added to
61
   * the document. In order, the placeholders are as follows:
62
   * <ol>
63
   * <li>%s --- language</li>
64
   * <li>%s --- default stylesheet</li>
65
   * <li>%s --- language-specific stylesheet</li>
66
   * <li>%s --- user-customized stylesheet</li>
67
   * <li>%s --- font family</li>
68
   * <li>%d --- font size (must be pixels, not points due to bug)</li>
69
   * <li>%s --- base href</li>
70
   * </p>
71
   */
72
  private static final String HTML_HEAD =
73
    """
74
      <!doctype html>
75
      <html lang='%s'><head><title> </title><meta charset='utf-8'/>
76
      %s%s%s<style>body{font-family:'%s';font-size: %dpx;}</style>%s</head><body>
77
      """;
78
79
  private static final String HTML_TAIL = "</body></html>";
80
81
  private static final URL HTML_STYLE_PREVIEW = toUrl( STYLESHEET_PREVIEW );
82
83
  /**
84
   * Reusing this buffer prevents repetitious memory re-allocations.
85
   */
86
  private final StringBuilder mDocument = new StringBuilder( 65536 );
87
88
  private HtmlRenderer mPreview;
89
  private JScrollPane mScrollPane;
90
  private String mBaseUriPath = "";
91
  private String mHead;
92
93
  private volatile boolean mLocked;
94
  private final JButton mScrollLockButton = new JButton();
95
  private final Workspace mWorkspace;
96
97
  /**
98
   * Creates a new preview pane that can scroll to the caret position within the
99
   * document.
100
   *
101
   * @param workspace Contains locale and font size information.
102
   */
103
  public HtmlPreview( final Workspace workspace ) {
104
    mWorkspace = workspace;
105
    mHead = generateHead();
106
107
    // Attempts to prevent a flash of black un-styled content upon load.
108
    setStyle( "-fx-background-color: white;" );
109
110
    invokeLater( () -> {
111
      mPreview = new FlyingSaucerPanel();
112
      mScrollPane = new JScrollPane( (Component) mPreview );
113
      final var verticalBar = mScrollPane.getVerticalScrollBar();
114
      final var verticalPanel = new JPanel( new BorderLayout() );
115
116
      final var map = verticalBar.getInputMap( WHEN_IN_FOCUSED_WINDOW );
117
      addKeyboardEvents( map );
118
119
      mScrollLockButton.setFont( getIconFont( 14 ) );
120
      mScrollLockButton.setText( getLockText( mLocked ) );
121
      mScrollLockButton.setMargin( new Insets( 1, 0, 0, 0 ) );
122
      mScrollLockButton.addActionListener( e -> fireScrollLockEvent( !mLocked ) );
123
124
      verticalPanel.add( verticalBar, CENTER );
125
      verticalPanel.add( mScrollLockButton, PAGE_END );
126
127
      final var wrapper = new JPanel( new BorderLayout() );
128
      wrapper.add( mScrollPane, CENTER );
129
      wrapper.add( verticalPanel, LINE_END );
130
131
      // Enabling the cache attempts to prevent black flashes when resizing.
132
      setCache( true );
133
      setCacheHint( SPEED );
134
      setContent( wrapper );
135
      wrapper.addComponentListener( this );
136
    } );
137
138
    localeProperty().addListener( ( c, o, n ) -> rerender() );
139
    fontFamilyProperty().addListener( ( c, o, n ) -> rerender() );
140
    fontSizeProperty().addListener( ( c, o, n ) -> rerender() );
141
142
    register( this );
143
  }
144
145
  @Subscribe
146
  public void handle( final ScrollLockEvent event ) {
147
    mLocked = event.isLocked();
148
    invokeLater( () -> mScrollLockButton.setText( getLockText( mLocked ) ) );
149
  }
150
151
  /**
152
   * Updates the internal HTML source shown in the preview pane.
153
   *
154
   * @param html The new HTML document to display.
155
   */
156
  public void render( final String html ) {
157
    final var doc = CONVERTER.fromJsoup( parse( decorate( html ) ) );
158
    final var uri = getBaseUri();
159
    doc.setDocumentURI( uri );
160
161
    invokeLater( () -> mPreview.render( doc, uri ) );
162
163
    DocumentChangedEvent.fire( html );
164
  }
165
166
  /**
167
   * Clears the caches then re-renders the content.
168
   */
169
  public void refresh() {
170
    mPreview.clearCache();
171
    rerender();
172
  }
173
174
  /**
175
   * Recomputes the HTML head then renders the document.
176
   */
177
  private void rerender() {
178
    mHead = generateHead();
179
    render( mDocument.toString() );
180
  }
181
182
  /**
183
   * Attaches the HTML head prefix and HTML tail suffix to the given HTML
184
   * string.
185
   *
186
   * @param html The HTML to adorn with opening and closing tags.
187
   * @return A complete HTML document, ready for rendering.
188
   */
189
  private String decorate( final String html ) {
190
    mDocument.setLength( 0 );
191
    mDocument.append( html );
192
193
    // Head and tail must be separate from document due to re-rendering.
194
    return mHead + mDocument + HTML_TAIL;
195
  }
196
197
  /**
198
   * Called when settings are changed that affect the HTML document preamble.
199
   * This is a minor performance optimization to avoid generating the head
200
   * each time that the document itself changes.
201
   *
202
   * @return A new doctype and HTML {@code head} element.
203
   */
204
  private String generateHead() {
205
    final var locale = getLocale();
206
    final var base = getBaseUri();
207
    final var custom = getCustomStylesheetUrl();
208
209
    // Point sizes are converted to pixels because of a rendering bug.
210
    return format(
211
      HTML_HEAD,
212
      locale.getLanguage(),
213
      toStylesheetString( HTML_STYLE_PREVIEW ),
214
      toStylesheetString( toUrl( locale ) ),
215
      toStylesheetString( custom ),
216
      getFontFamily(),
217
      toPixels( getFontSize() ),
218
      base.isBlank() ? "" : format( HTML_BASE, base )
219
    );
220
  }
221
222
  /**
223
   * Clears the preview pane by rendering an empty string.
224
   */
225
  public void clear() {
226
    render( "" );
227
  }
228
229
  /**
230
   * Sets the base URI to the containing directory the file being edited.
231
   *
232
   * @param path The path to the file being edited.
233
   */
234
  public void setBaseUri( final Path path ) {
235
    final var parent = path.getParent();
236
    mBaseUriPath = parent == null ? "" : parent.toUri().toString();
237
  }
238
239
  /**
240
   * Scrolls to the closest element matching the given identifier without
241
   * waiting for the document to be ready.
242
   *
243
   * @param id Scroll the preview pane to this unique paragraph identifier.
244
   */
245
  public void scrollTo( final String id ) {
246
    if( !mLocked ) {
247
      invokeLater( () -> {
248
        mPreview.scrollTo( id, mScrollPane );
249
        mScrollPane.repaint();
250
      } );
251
    }
252
  }
253
254
  private String getBaseUri() {
255
    return mBaseUriPath;
256
  }
257
258
  private JScrollPane getScrollPane() {
259
    return mScrollPane;
260
  }
261
262
  public JScrollBar getVerticalScrollBar() {
263
    return getScrollPane().getVerticalScrollBar();
264
  }
265
266
  /**
267
   * Returns the ISO 639 alpha-2 or alpha-3 language code followed by a hyphen
268
   * followed by the ISO 15924 alpha-4 script code, followed by an ISO 3166
269
   * alpha-2 country code or UN M.49 numeric-3 area code. For example, this
270
   * could return "en-Latn-CA" for Canadian English written in the Latin
271
   * character set.
272
   *
273
   * @return Unique identifier for language and country.
274
   */
275
  private static URL toUrl( final Locale locale ) {
276
    return toUrl(
277
      String.format(
278
        sSettings.getSetting( STYLESHEET_PREVIEW_LOCALE, "" ),
279
        locale.getLanguage(),
280
        locale.getScript(),
281
        locale.getCountry()
282
      )
283
    );
284
  }
285
286
  private static URL toUrl( final String path ) {
287
    return HtmlPreview.class.getResource( path );
288
  }
289
290
  private Locale getLocale() {
291
    return localeProperty().toLocale();
292
  }
293
294
  private LocaleProperty localeProperty() {
295
    return mWorkspace.localeProperty( KEY_LANGUAGE_LOCALE );
296
  }
297
298
  private String getFontFamily() {
299
    return fontFamilyProperty().get();
300
  }
301
302
  private StringProperty fontFamilyProperty() {
303
    return mWorkspace.stringProperty( KEY_UI_FONT_PREVIEW_NAME );
304
  }
305
306
  private double getFontSize() {
307
    return fontSizeProperty().get();
308
  }
309
310
  /**
311
   * Returns the font size in points.
312
   *
313
   * @return The user-defined font size (in pt).
314
   */
315
  private DoubleProperty fontSizeProperty() {
316
    return mWorkspace.doubleProperty( KEY_UI_FONT_PREVIEW_SIZE );
317
  }
318
319
  private String getLockText( final boolean locked ) {
320
    return Character.toString( (locked ? LOCK : UNLOCK_ALT).getChar() );
321
  }
322
323
  private URL getCustomStylesheetUrl() {
324
    try {
325
      return mWorkspace.toFile( KEY_UI_PREVIEW_STYLESHEET ).toURI().toURL();
326
    } catch( final Exception ex ) {
327
      clue( ex );
328
      return null;
329
    }
330
  }
331
332
  /**
333
   * Maps keyboard events to scrollbar commands so that users may control
334
   * the {@link HtmlPreview} panel using the keyboard.
335
   *
336
   * @param map The map to update with keyboard events.
337
   */
338
  private void addKeyboardEvents( final InputMap map ) {
339
    map.put( getKeyStroke( VK_DOWN, 0 ), "positiveUnitIncrement" );
340
    map.put( getKeyStroke( VK_UP, 0 ), "negativeUnitIncrement" );
341
    map.put( getKeyStroke( VK_PAGE_DOWN, 0 ), "positiveBlockIncrement" );
342
    map.put( getKeyStroke( VK_PAGE_UP, 0 ), "negativeBlockIncrement" );
343
    map.put( getKeyStroke( VK_HOME, 0 ), "minScroll" );
344
    map.put( getKeyStroke( VK_END, 0 ), "maxScroll" );
345
  }
346
347
  @Override
348
  public void componentResized( final ComponentEvent e ) {
349
    if( mWorkspace.toBoolean( KEY_IMAGES_RESIZE ) ) {
26
import static com.keenwrite.preferences.AppKeys.*;
27
import static com.keenwrite.ui.fonts.IconFactory.getIconFont;
28
import static java.awt.BorderLayout.*;
29
import static java.awt.event.KeyEvent.*;
30
import static java.lang.String.format;
31
import static javafx.scene.CacheHint.SPEED;
32
import static javax.swing.JComponent.WHEN_IN_FOCUSED_WINDOW;
33
import static javax.swing.KeyStroke.getKeyStroke;
34
import static javax.swing.SwingUtilities.invokeLater;
35
import static org.controlsfx.glyphfont.FontAwesome.Glyph.LOCK;
36
import static org.controlsfx.glyphfont.FontAwesome.Glyph.UNLOCK_ALT;
37
import static org.jsoup.Jsoup.parse;
38
39
/**
40
 * Responsible for parsing an HTML document.
41
 */
42
public final class HtmlPreview extends SwingNode implements ComponentListener {
43
  /**
44
   * Converts a text string to a structured HTML document.
45
   */
46
  private static final DocumentConverter CONVERTER = new DocumentConverter();
47
48
  /**
49
   * Used to populate the {@link #HTML_HEAD} with stylesheet file references.
50
   */
51
  private static final String HTML_STYLESHEET =
52
    "<link rel='stylesheet' href='%s'/>";
53
54
  private static final String HTML_BASE =
55
    "<base href='%s'/>";
56
57
  /**
58
   * Render CSS using points (pt) not pixels (px) to reduce the chance of
59
   * poor rendering. The {@link #generateHead()} method fills placeholders.
60
   * When the user has not set a locale, only one stylesheet is added to
61
   * the document. In order, the placeholders are as follows:
62
   * <ol>
63
   * <li>%s --- language</li>
64
   * <li>%s --- default stylesheet</li>
65
   * <li>%s --- language-specific stylesheet</li>
66
   * <li>%s --- user-customized stylesheet</li>
67
   * <li>%s --- font family</li>
68
   * <li>%d --- font size (must be pixels, not points due to bug)</li>
69
   * <li>%s --- base href</li>
70
   * </p>
71
   */
72
  private static final String HTML_HEAD =
73
    """
74
      <!doctype html>
75
      <html lang='%s'><head><title> </title><meta charset='utf-8'/>
76
      %s%s%s<style>body{font-family:'%s';font-size: %dpx;}</style>%s</head><body>
77
      """;
78
79
  private static final String HTML_TAIL = "</body></html>";
80
81
  private static final URL HTML_STYLE_PREVIEW = toUrl( STYLESHEET_PREVIEW );
82
83
  /**
84
   * Reusing this buffer prevents repetitious memory re-allocations.
85
   */
86
  private final StringBuilder mDocument = new StringBuilder( 65536 );
87
88
  private HtmlRenderer mPreview;
89
  private JScrollPane mScrollPane;
90
  private String mBaseUriPath = "";
91
  private String mHead;
92
93
  private volatile boolean mScrollLocked;
94
  private final JButton mScrollLockButton = new JButton();
95
  private final Workspace mWorkspace;
96
97
  /**
98
   * Creates a new preview pane that can scroll to the caret position within the
99
   * document.
100
   *
101
   * @param workspace Contains locale and font size information.
102
   */
103
  public HtmlPreview( final Workspace workspace ) {
104
    mWorkspace = workspace;
105
    mHead = generateHead();
106
107
    // Attempts to prevent a flash of black un-styled content upon load.
108
    setStyle( "-fx-background-color: white;" );
109
110
    invokeLater( () -> {
111
      mPreview = new FlyingSaucerPanel();
112
      mScrollPane = new JScrollPane( (Component) mPreview );
113
      final var verticalBar = mScrollPane.getVerticalScrollBar();
114
      final var verticalPanel = new JPanel( new BorderLayout() );
115
116
      final var map = verticalBar.getInputMap( WHEN_IN_FOCUSED_WINDOW );
117
      addKeyboardEvents( map );
118
119
      mScrollLockButton.setFont( getIconFont( 14 ) );
120
      mScrollLockButton.setText( getLockText( mScrollLocked ) );
121
      mScrollLockButton.setMargin( new Insets( 1, 0, 0, 0 ) );
122
      mScrollLockButton.addActionListener( e -> fireScrollLockEvent( !mScrollLocked ) );
123
124
      verticalPanel.add( verticalBar, CENTER );
125
      verticalPanel.add( mScrollLockButton, PAGE_END );
126
127
      final var wrapper = new JPanel( new BorderLayout() );
128
      wrapper.add( mScrollPane, CENTER );
129
      wrapper.add( verticalPanel, LINE_END );
130
131
      // Enabling the cache attempts to prevent black flashes when resizing.
132
      setCache( true );
133
      setCacheHint( SPEED );
134
      setContent( wrapper );
135
      wrapper.addComponentListener( this );
136
    } );
137
138
    localeProperty().addListener( ( c, o, n ) -> rerender() );
139
    fontFamilyProperty().addListener( ( c, o, n ) -> rerender() );
140
    fontSizeProperty().addListener( ( c, o, n ) -> rerender() );
141
142
    register( this );
143
  }
144
145
  @Subscribe
146
  public void handle( final ScrollLockEvent event ) {
147
    mScrollLocked = event.isLocked();
148
    invokeLater( () -> mScrollLockButton.setText( getLockText( mScrollLocked ) ) );
149
  }
150
151
  /**
152
   * Updates the internal HTML source shown in the preview pane.
153
   *
154
   * @param html The new HTML document to display.
155
   */
156
  public void render( final String html ) {
157
    final var doc = CONVERTER.fromJsoup( parse( decorate( html ) ) );
158
    final var uri = getBaseUri();
159
    doc.setDocumentURI( uri );
160
161
    invokeLater( () -> mPreview.render( doc, uri ) );
162
163
    DocumentChangedEvent.fire( html );
164
  }
165
166
  /**
167
   * Clears the caches then re-renders the content.
168
   */
169
  public void refresh() {
170
    mPreview.clearCache();
171
    rerender();
172
  }
173
174
  /**
175
   * Recomputes the HTML head then renders the document.
176
   */
177
  private void rerender() {
178
    mHead = generateHead();
179
    render( mDocument.toString() );
180
  }
181
182
  /**
183
   * Attaches the HTML head prefix and HTML tail suffix to the given HTML
184
   * string.
185
   *
186
   * @param html The HTML to adorn with opening and closing tags.
187
   * @return A complete HTML document, ready for rendering.
188
   */
189
  private String decorate( final String html ) {
190
    mDocument.setLength( 0 );
191
    mDocument.append( html );
192
193
    // Head and tail must be separate from document due to re-rendering.
194
    return mHead + mDocument + HTML_TAIL;
195
  }
196
197
  /**
198
   * Called when settings are changed that affect the HTML document preamble.
199
   * This is a minor performance optimization to avoid generating the head
200
   * each time that the document itself changes.
201
   *
202
   * @return A new doctype and HTML {@code head} element.
203
   */
204
  private String generateHead() {
205
    final var locale = getLocale();
206
    final var base = getBaseUri();
207
    final var custom = getCustomStylesheetUrl();
208
209
    // Point sizes are converted to pixels because of a rendering bug.
210
    return format(
211
      HTML_HEAD,
212
      locale.getLanguage(),
213
      toStylesheetString( HTML_STYLE_PREVIEW ),
214
      toStylesheetString( toUrl( locale ) ),
215
      toStylesheetString( custom ),
216
      getFontFamily(),
217
      toPixels( getFontSize() ),
218
      base.isBlank() ? "" : format( HTML_BASE, base )
219
    );
220
  }
221
222
  /**
223
   * Clears the preview pane by rendering an empty string.
224
   */
225
  public void clear() {
226
    render( "" );
227
  }
228
229
  /**
230
   * Sets the base URI to the containing directory the file being edited.
231
   *
232
   * @param path The path to the file being edited.
233
   */
234
  public void setBaseUri( final Path path ) {
235
    final var parent = path.getParent();
236
    mBaseUriPath = parent == null ? "" : parent.toUri().toString();
237
  }
238
239
  /**
240
   * Scrolls to the closest element matching the given identifier without
241
   * waiting for the document to be ready.
242
   *
243
   * @param id Scroll the preview pane to this unique paragraph identifier.
244
   */
245
  public void scrollTo( final String id ) {
246
    if( !mScrollLocked ) {
247
      mPreview.scrollTo( id, mScrollPane );
248
      mScrollPane.repaint();
249
    }
250
  }
251
252
  private String getBaseUri() {
253
    return mBaseUriPath;
254
  }
255
256
  private JScrollPane getScrollPane() {
257
    return mScrollPane;
258
  }
259
260
  public JScrollBar getVerticalScrollBar() {
261
    return getScrollPane().getVerticalScrollBar();
262
  }
263
264
  /**
265
   * Returns the ISO 639 alpha-2 or alpha-3 language code followed by a hyphen
266
   * followed by the ISO 15924 alpha-4 script code, followed by an ISO 3166
267
   * alpha-2 country code or UN M.49 numeric-3 area code. For example, this
268
   * could return "en-Latn-CA" for Canadian English written in the Latin
269
   * character set.
270
   *
271
   * @return Unique identifier for language and country.
272
   */
273
  private static URL toUrl( final Locale locale ) {
274
    return toUrl(
275
      String.format(
276
        sSettings.getSetting( STYLESHEET_PREVIEW_LOCALE, "" ),
277
        locale.getLanguage(),
278
        locale.getScript(),
279
        locale.getCountry()
280
      )
281
    );
282
  }
283
284
  private static URL toUrl( final String path ) {
285
    return HtmlPreview.class.getResource( path );
286
  }
287
288
  private Locale getLocale() {
289
    return localeProperty().toLocale();
290
  }
291
292
  private LocaleProperty localeProperty() {
293
    return mWorkspace.localeProperty( KEY_LANGUAGE_LOCALE );
294
  }
295
296
  private String getFontFamily() {
297
    return fontFamilyProperty().get();
298
  }
299
300
  private StringProperty fontFamilyProperty() {
301
    return mWorkspace.stringProperty( KEY_UI_FONT_PREVIEW_NAME );
302
  }
303
304
  private double getFontSize() {
305
    return fontSizeProperty().get();
306
  }
307
308
  /**
309
   * Returns the font size in points.
310
   *
311
   * @return The user-defined font size (in pt).
312
   */
313
  private DoubleProperty fontSizeProperty() {
314
    return mWorkspace.doubleProperty( KEY_UI_FONT_PREVIEW_SIZE );
315
  }
316
317
  private String getLockText( final boolean locked ) {
318
    return Character.toString( (locked ? LOCK : UNLOCK_ALT).getChar() );
319
  }
320
321
  private URL getCustomStylesheetUrl() {
322
    try {
323
      return mWorkspace.getFile( KEY_UI_PREVIEW_STYLESHEET ).toURI().toURL();
324
    } catch( final Exception ex ) {
325
      clue( ex );
326
      return null;
327
    }
328
  }
329
330
  /**
331
   * Maps keyboard events to scrollbar commands so that users may control
332
   * the {@link HtmlPreview} panel using the keyboard.
333
   *
334
   * @param map The map to update with keyboard events.
335
   */
336
  private void addKeyboardEvents( final InputMap map ) {
337
    map.put( getKeyStroke( VK_DOWN, 0 ), "positiveUnitIncrement" );
338
    map.put( getKeyStroke( VK_UP, 0 ), "negativeUnitIncrement" );
339
    map.put( getKeyStroke( VK_PAGE_DOWN, 0 ), "positiveBlockIncrement" );
340
    map.put( getKeyStroke( VK_PAGE_UP, 0 ), "negativeBlockIncrement" );
341
    map.put( getKeyStroke( VK_HOME, 0 ), "minScroll" );
342
    map.put( getKeyStroke( VK_END, 0 ), "maxScroll" );
343
  }
344
345
  @Override
346
  public void componentResized( final ComponentEvent e ) {
347
    if( mWorkspace.getBoolean( KEY_IMAGES_RESIZE ) ) {
350348
      mPreview.clearCache();
351349
    }
D src/main/java/com/keenwrite/processors/DefinitionProcessor.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.processors;
3
4
import java.util.Map;
5
import java.util.function.Function;
6
7
import static com.keenwrite.processors.text.TextReplacementFactory.replace;
8
9
/**
10
 * Processes interpolated string definitions in the document and inserts
11
 * their values into the post-processed text. The default variable syntax is
12
 * {@code $variable$}.
13
 */
14
public class DefinitionProcessor
15
  extends ExecutorProcessor<String> implements Function<String, String> {
16
17
  private final ProcessorContext mContext;
18
19
  /**
20
   * Constructs a processor capable of interpolating string definitions.
21
   *
22
   * @param successor Subsequent link in the processing chain.
23
   * @param context   Contains resolved definitions map.
24
   */
25
  public DefinitionProcessor(
26
      final Processor<String> successor,
27
      final ProcessorContext context ) {
28
    super( successor );
29
    mContext = context;
30
  }
31
32
  /**
33
   * Processes the given text document by replacing variables with their values.
34
   *
35
   * @param text The document text that includes variables that should be
36
   *             replaced with values when rendered as HTML.
37
   * @return The text with all variables replaced.
38
   */
39
  @Override
40
  public String apply( final String text ) {
41
    return replace( text, getDefinitions() );
42
  }
43
44
  /**
45
   * Returns the map to use for variable substitution.
46
   *
47
   * @return A map of variable names to values.
48
   */
49
  protected Map<String, String> getDefinitions() {
50
    return mContext.getResolvedMap();
51
  }
52
}
531
M src/main/java/com/keenwrite/processors/HtmlPreviewProcessor.java
1515
   * There is only one preview panel.
1616
   */
17
  private static HtmlPreview sHtmlPreviewPane;
17
  private static HtmlPreview sHtmlPreview;
1818
1919
  /**
2020
   * Constructs the end of a processing chain.
2121
   *
22
   * @param htmlPreviewPane The pane to update with the post-processed document.
22
   * @param htmlPreview The pane to update with the post-processed document.
2323
   */
24
  public HtmlPreviewProcessor( final HtmlPreview htmlPreviewPane ) {
25
    sHtmlPreviewPane = htmlPreviewPane;
24
  public HtmlPreviewProcessor( final HtmlPreview htmlPreview ) {
25
    sHtmlPreview = htmlPreview;
2626
  }
2727
...
3737
    assert html != null;
3838
39
    sHtmlPreviewPane.render( html );
39
    sHtmlPreview.render( html );
4040
    return html;
4141
  }
M src/main/java/com/keenwrite/processors/PdfProcessor.java
99
import static com.keenwrite.events.StatusEvent.clue;
1010
import static com.keenwrite.io.MediaType.TEXT_XML;
11
import static com.keenwrite.preferences.AppKeys.*;
12
import static com.keenwrite.typesetting.Typesetter.Mutator;
1113
import static java.nio.file.Files.deleteIfExists;
1214
import static java.nio.file.Files.writeString;
...
3537
    try {
3638
      clue( "Main.status.typeset.create" );
39
      final var workspace = mContext.getWorkspace();
3740
      final var document = TEXT_XML.createTemporaryFile( APP_TITLE_LOWERCASE );
38
      final var pathInput = writeString( document, xhtml );
39
      final var pathOutput = mContext.getOutputPath();
40
      final var typesetter = new Typesetter( mContext.getWorkspace() );
41
      final var typesetter = Typesetter
42
        .builder()
43
        .with( Mutator::setInputPath,
44
               writeString( document, xhtml ) )
45
        .with( Mutator::setOutputPath,
46
               mContext.getOutputPath() )
47
        .with( Mutator::setThemePath,
48
               workspace.getFile( KEY_TYPESET_CONTEXT_THEMES_PATH ) )
49
        .with( Mutator::setThemeName,
50
               workspace.getString( KEY_TYPESET_CONTEXT_THEME_SELECTION ) )
51
        .with( Mutator::setAutoclean,
52
               workspace.getBoolean( KEY_TYPESET_CONTEXT_CLEAN ) )
53
        .build();
4154
42
      typesetter.typeset( pathInput, pathOutput );
55
      typesetter.typeset();
4356
4457
      // Smote the temporary file after typesetting the document.
M src/main/java/com/keenwrite/processors/ProcessorContext.java
55
import com.keenwrite.ExportFormat;
66
import com.keenwrite.constants.Constants;
7
import com.keenwrite.editors.TextDefinition;
87
import com.keenwrite.io.FileType;
98
import com.keenwrite.preferences.Workspace;
10
import com.keenwrite.preview.HtmlPreview;
9
import com.keenwrite.sigils.SigilKeyOperator;
1110
import com.keenwrite.util.GenericBuilder;
12
import javafx.beans.property.ObjectProperty;
11
import com.keenwrite.collections.InterpolatingMap;
1312
1413
import java.io.File;
1514
import java.nio.file.Path;
1615
import java.util.Map;
16
import java.util.concurrent.Callable;
17
import java.util.function.Supplier;
1718
1819
import static com.keenwrite.AbstractFileFactory.lookup;
...
2728
2829
  /**
29
   * Creates a new context for use by the {@link ProcessorFactory} when
30
   * instantiating new {@link Processor} instances. Although all the
31
   * parameters are required, not all {@link Processor} instances will use
32
   * all parameters.
30
   * Responsible for populating the instance variables required by the
31
   * context.
3332
   */
34
  private ProcessorContext( final Mutator mutator ) {
35
    assert mutator != null;
36
37
    mMutator = mutator;
38
  }
39
4033
  public static class Mutator {
41
    private HtmlPreview mHtmlPreview;
42
    private ObjectProperty<TextDefinition> mTextDefinition;
4334
    private Path mInputPath;
4435
    private Path mOutputPath;
45
    private Caret mCaret;
4636
    private ExportFormat mExportFormat;
37
    private Supplier<Map<String, String>> mDefinitions;
38
    private Supplier<Caret> mCaret;
4739
    private Workspace mWorkspace;
48
49
    public void setHtmlPreview( final HtmlPreview htmlPreview ) {
50
      mHtmlPreview = htmlPreview;
51
    }
52
53
    public void setTextDefinition(
54
      final ObjectProperty<TextDefinition> textDefinition ) {
55
      mTextDefinition = textDefinition;
56
    }
5740
5841
    public void setInputPath( final Path inputPath ) {
...
7255
    }
7356
74
    public void setCaret( final Caret caret ) {
57
    /**
58
     * Sets the list of fully interpolated key-value pairs to use when
59
     * substituting variable names back into the document as variable values.
60
     * This uses a {@link Callable} reference so that GUI and command-line
61
     * usage can insert their respective behaviours. That is, this method
62
     * prevents coupling the GUI to the CLI.
63
     *
64
     * @param definitions Defines how to retrieve the definitions.
65
     */
66
    public void setDefinitions(
67
      final Supplier<Map<String, String>> definitions ) {
68
      mDefinitions = definitions;
69
    }
70
71
    /**
72
     * Sets the source for deriving the {@link Caret}. Typically, this is
73
     * the text editor that has focus.
74
     *
75
     * @param caret The source for the currently active caret.
76
     */
77
    public void setCaret( final Supplier<Caret> caret ) {
7578
      mCaret = caret;
7679
    }
...
8689
8790
  public static GenericBuilder<Mutator, ProcessorContext> builder() {
88
    return GenericBuilder.of(
89
      Mutator::new,
90
      ProcessorContext::new
91
    );
92
  }
93
94
  /**
95
   * @param inputPath      Path to the document to process.
96
   * @param outputPath     Fully qualified filename to use when exporting.
97
   * @param format         Indicate configuration options for export format.
98
   * @param preview        Where to display the final (HTML) output.
99
   * @param textDefinition Source for fully expanded interpolated strings.
100
   * @param workspace      Persistent user preferences settings.
101
   * @param caret          Location of the caret in the edited document,
102
   *                       which is used to synchronize the scrollbars.
103
   * @return A context that may be used for processing documents.
104
   */
105
  public static ProcessorContext create(
106
    final Path inputPath,
107
    final Path outputPath,
108
    final ExportFormat format,
109
    final HtmlPreview preview,
110
    final ObjectProperty<TextDefinition> textDefinition,
111
    final Workspace workspace,
112
    final Caret caret ) {
113
    return builder()
114
      .with( Mutator::setInputPath, inputPath )
115
      .with( Mutator::setOutputPath, outputPath )
116
      .with( Mutator::setExportFormat, format )
117
      .with( Mutator::setHtmlPreview, preview )
118
      .with( Mutator::setTextDefinition, textDefinition )
119
      .with( Mutator::setWorkspace, workspace )
120
      .with( Mutator::setCaret, caret )
121
      .build();
91
    return GenericBuilder.of( Mutator::new, ProcessorContext::new );
12292
  }
12393
...
137107
138108
  /**
139
   * @param inputPath  Path to the document to process.
140
   * @param outputPath Fully qualified filename to use when exporting.
141
   * @param format     Indicate configuration options for export format.
142
   * @return A context that may be used for processing documents.
109
   * Creates a new context for use by the {@link ProcessorFactory} when
110
   * instantiating new {@link Processor} instances. Although all the
111
   * parameters are required, not all {@link Processor} instances will use
112
   * all parameters.
143113
   */
144
  public static ProcessorContext create(
145
    final Path inputPath, final Path outputPath, final ExportFormat format ) {
146
    return builder()
147
      .with( Mutator::setInputPath, inputPath )
148
      .with( Mutator::setOutputPath, outputPath )
149
      .with( Mutator::setExportFormat, format )
150
      .build();
151
  }
114
  private ProcessorContext( final Mutator mutator ) {
115
    assert mutator != null;
152116
153
  public boolean isExportFormat( final ExportFormat format ) {
154
    return mMutator.mExportFormat == format;
117
    mMutator = mutator;
155118
  }
156119
157
  HtmlPreview getPreview() {
158
    return mMutator.mHtmlPreview;
120
  /**
121
   * Returns the variable map of definitions, without interpolation.
122
   *
123
   * @return A map to help dereference variables.
124
   */
125
  public Map<String, String> getDefinitions() {
126
    return mMutator.mDefinitions.get();
159127
  }
160128
161129
  /**
162
   * Returns the variable map of interpolated definitions.
130
   * Returns the variable map of definitions, with interpolation.
163131
   *
164132
   * @return A map to help dereference variables.
165133
   */
166
  Map<String, String> getResolvedMap() {
167
    return mMutator.mTextDefinition.get().getDefinitions();
134
  public InterpolatingMap getInterpolatedDefinitions() {
135
    final var map = new InterpolatingMap(
136
      createDefinitionSigilOperator(), getDefinitions()
137
    );
138
139
    map.interpolate();
140
141
    return map;
168142
  }
169143
...
187161
   * @return Caret position in the document.
188162
   */
189
  public Caret getCaret() {
163
  public Supplier<Caret> getCaret() {
190164
    return mMutator.mCaret;
191165
  }
...
204178
   */
205179
  public Path getBaseDir() {
206
    final var path = getDocumentPath().toAbsolutePath().getParent();
180
    final var path = getInputPath().toAbsolutePath().getParent();
207181
    return path == null ? DEFAULT_DIRECTORY : path;
208182
  }
209183
210
  public Path getDocumentPath() {
184
  public Path getInputPath() {
211185
    return mMutator.mInputPath;
212186
  }
213187
214188
  FileType getFileType() {
215
    return lookup( getDocumentPath() );
189
    return lookup( getInputPath() );
216190
  }
217191
218192
  public Workspace getWorkspace() {
219193
    return mMutator.mWorkspace;
194
  }
195
196
  public SigilKeyOperator createSigilOperator() {
197
    return getWorkspace().createSigilOperator( getInputPath() );
198
  }
199
200
  public SigilKeyOperator createDefinitionSigilOperator() {
201
    return getWorkspace().createDefinitionKeyOperator();
220202
  }
221203
}
M src/main/java/com/keenwrite/processors/ProcessorFactory.java
33
44
import com.keenwrite.AbstractFileFactory;
5
import com.keenwrite.preview.HtmlPreview;
65
import com.keenwrite.processors.markdown.MarkdownProcessor;
76
8
import static com.keenwrite.ExportFormat.*;
97
import static com.keenwrite.processors.IdentityProcessor.IDENTITY;
108
119
/**
1210
 * Responsible for creating processors capable of parsing, transforming,
1311
 * interpolating, and rendering known file types.
1412
 */
1513
public final class ProcessorFactory extends AbstractFileFactory {
1614
17
  private final ProcessorContext mContext;
15
  private ProcessorFactory() {
16
  }
17
18
  public static Processor<String> createProcessors(
19
    final ProcessorContext context ) {
20
    return createProcessors( context, null );
21
  }
1822
1923
  /**
20
   * Constructs a factory with the ability to create processors that can perform
21
   * text and caret processing to generate a final preview.
24
   * Creates a new {@link Processor} chain suitable for parsing and rendering
25
   * the file opened at the given tab.
2226
   *
23
   * @param context Parameters needed to construct various processors.
27
   * @param context The tab containing a text editor, path, and caret position.
28
   * @return A processor that can render the given tab's text.
2429
   */
25
  private ProcessorFactory( final ProcessorContext context ) {
26
    mContext = context;
30
  public static Processor<String> createProcessors(
31
    final ProcessorContext context, final Processor<String> preview ) {
32
    return ProcessorFactory.createProcessor( context, preview );
2733
  }
28
29
  private Processor<String> createProcessor() {
30
    final var context = getProcessorContext();
3134
35
  /**
36
   * Constructs processors that chain various processing operations on a
37
   * document to generate a transformed version of the source document.
38
   *
39
   * @param context Parameters needed to construct various processors.
40
   * @param preview The processor to use when no export format is specified.
41
   */
42
  private static Processor<String> createProcessor(
43
    final ProcessorContext context, final Processor<String> preview ) {
3244
    // If the content is not to be exported, then the successor processor
3345
    // is one that parses Markdown into HTML and passes the string to the
...
4052
    // to SVG. Without conversion would require client-side rendering of
4153
    // math (such as using the JavaScript-based KaTeX engine).
42
    final var successor = context.isExportFormat( NONE )
43
      ? createHtmlPreviewProcessor( context )
44
      : context.isExportFormat( XHTML_TEX )
45
      ? createXhtmlProcessor( context )
46
      : context.isExportFormat( APPLICATION_PDF )
47
      ? createPdfProcessor( context )
48
      : createIdentityProcessor( context );
54
    final var successor = switch( context.getExportFormat() ) {
55
      case NONE -> preview;
56
      case XHTML_TEX -> createXhtmlProcessor( context );
57
      case APPLICATION_PDF -> createPdfProcessor( context );
58
      default -> createIdentityProcessor( context );
59
    };
4960
5061
    final var processor = switch( context.getFileType() ) {
51
      case SOURCE, RMARKDOWN -> createMarkdownProcessor( successor );
62
      case SOURCE, RMARKDOWN -> createMarkdownProcessor( successor, context );
5263
      default -> createPreformattedProcessor( successor );
5364
    };
5465
5566
    return new ExecutorProcessor<>( processor );
56
  }
57
58
  /**
59
   * Creates a new {@link Processor} chain suitable for parsing and rendering
60
   * the file opened at the given tab.
61
   *
62
   * @param context The tab containing a text editor, path, and caret position.
63
   * @return A processor that can render the given tab's text.
64
   */
65
  public static Processor<String> createProcessors(
66
    final ProcessorContext context ) {
67
    return new ProcessorFactory( context ).createProcessor();
6867
  }
6968
7069
  /**
7170
   * Instantiates a new {@link Processor} that has no successor and returns
7271
   * the string it was given without modification.
7372
   *
7473
   * @return An instance of {@link Processor} that performs no processing.
7574
   */
7675
  @SuppressWarnings( "unused" )
77
  private Processor<String> createIdentityProcessor(
76
  private static Processor<String> createIdentityProcessor(
7877
    final ProcessorContext ignored ) {
7978
    return IDENTITY;
80
  }
81
82
  /**
83
   * Instantiates a new {@link Processor} that passes an incoming HTML
84
   * string to a user interface widget that can render HTML as a web page.
85
   *
86
   * @return An instance of {@link Processor} that forwards HTML for display.
87
   */
88
  @SuppressWarnings( "unused" )
89
  private Processor<String> createHtmlPreviewProcessor(
90
    final ProcessorContext ignored ) {
91
    return new HtmlPreviewProcessor( getPreviewPane() );
9279
  }
93
9480
  /**
9581
   * Instantiates a {@link Processor} responsible for parsing Markdown and
9682
   * definitions.
9783
   *
9884
   * @return A chain of {@link Processor}s for processing Markdown and
9985
   * definitions.
10086
   */
101
  private Processor<String> createMarkdownProcessor(
102
    final Processor<String> successor ) {
103
    final var dp = createDefinitionProcessor( successor );
104
    return MarkdownProcessor.create( dp, getProcessorContext() );
87
  private static Processor<String> createMarkdownProcessor(
88
    final Processor<String> successor,
89
    final ProcessorContext context ) {
90
    final var dp = createDefinitionProcessor( successor, context );
91
    return MarkdownProcessor.create( dp, context );
10592
  }
10693
107
  private Processor<String> createDefinitionProcessor(
108
    final Processor<String> successor ) {
109
    return new DefinitionProcessor( successor, getProcessorContext() );
94
  private static Processor<String> createDefinitionProcessor(
95
    final Processor<String> successor,
96
    final ProcessorContext context ) {
97
    return new VariableProcessor( successor, context );
11098
  }
11199
...
118106
   * @return An instance of {@link Processor} that completes an HTML document.
119107
   */
120
  private Processor<String> createXhtmlProcessor(
108
  private static Processor<String> createXhtmlProcessor(
121109
    final ProcessorContext context ) {
122110
    return createXhtmlProcessor( IDENTITY, context );
123111
  }
124112
125
  private Processor<String> createXhtmlProcessor(
113
  private static Processor<String> createXhtmlProcessor(
126114
    final Processor<String> successor, final ProcessorContext context ) {
127115
    return new XhtmlProcessor( successor, context );
128116
  }
129117
130
  private Processor<String> createPdfProcessor(
118
  private static Processor<String> createPdfProcessor(
131119
    final ProcessorContext context ) {
132120
    final var pdfp = new PdfProcessor( context );
133121
    return createXhtmlProcessor( pdfp, context );
134122
  }
135123
136
  private Processor<String> createPreformattedProcessor(
124
  private static Processor<String> createPreformattedProcessor(
137125
    final Processor<String> successor ) {
138126
    return new PreformattedProcessor( successor );
139
  }
140
141
  private ProcessorContext getProcessorContext() {
142
    return mContext;
143
  }
144
145
  private HtmlPreview getPreviewPane() {
146
    return getProcessorContext().getPreview();
147127
  }
148128
}
A src/main/java/com/keenwrite/processors/VariableProcessor.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.processors;
3
4
import java.util.HashMap;
5
import java.util.Map;
6
import java.util.function.Function;
7
import java.util.function.UnaryOperator;
8
9
import static com.keenwrite.processors.text.TextReplacementFactory.replace;
10
11
/**
12
 * Processes interpolated string definitions in the document and inserts
13
 * their values into the post-processed text. The default variable syntax is
14
 * <pre>{{variable}}</pre> (a.k.a., moustache syntax).
15
 */
16
public class VariableProcessor
17
  extends ExecutorProcessor<String> implements Function<String, String> {
18
19
  private final ProcessorContext mContext;
20
  private final UnaryOperator<String> mSigilOperator;
21
22
  /**
23
   * Constructs a processor capable of interpolating string definitions.
24
   *
25
   * @param successor Subsequent link in the processing chain.
26
   * @param context   Contains resolved definitions map.
27
   */
28
  public VariableProcessor(
29
    final Processor<String> successor,
30
    final ProcessorContext context ) {
31
    super( successor );
32
33
    mSigilOperator = createKeyOperator( context );
34
    mContext = context;
35
  }
36
37
  /**
38
   * Subclasses may change the type of operation performed on keys, such as
39
   * wrapping key names in sigils.
40
   *
41
   * @param context Provides the name of the file being edited.
42
   * @return An operator for transforming key names.
43
   */
44
  protected UnaryOperator<String> createKeyOperator(
45
    final ProcessorContext context ) {
46
    return context.createSigilOperator();
47
  }
48
49
  /**
50
   * Returns the map to use for variable substitution.
51
   *
52
   * @return A map of variable names to values, with keys wrapped in sigils.
53
   */
54
  protected Map<String, String> getDefinitions() {
55
    return entoken( mContext.getInterpolatedDefinitions() );
56
  }
57
58
  /**
59
   * Subclasses may override this method to change how keys are wrapped
60
   * in sigils.
61
   *
62
   * @param key The key to enwrap.
63
   * @return The wrapped key.
64
   */
65
  protected String processKey( final String key ) {
66
    return mSigilOperator.apply( key );
67
  }
68
69
  /**
70
   * Subclasses may override this method to modify values prior to use. This
71
   * can be used, for example, to escape values prior to evaluating by a
72
   * scripting engine.
73
   *
74
   * @param value The value to process.
75
   * @return The processed value.
76
   */
77
  protected String processValue( final String value ) {
78
    return value;
79
  }
80
81
  /**
82
   * Processes the given text document by replacing variables with their values.
83
   *
84
   * @param text The document text that includes variables that should be
85
   *             replaced with values when rendered as HTML.
86
   * @return The text with all variables replaced.
87
   */
88
  @Override
89
  public String apply( final String text ) {
90
    return replace( text, getDefinitions() );
91
  }
92
93
  /**
94
   * Converts the given map from regular variables to processor-specific
95
   * variables.
96
   *
97
   * @param map Map of variable names to values.
98
   * @return Map of variables with the keys and values subjected to
99
   * post-processing.
100
   */
101
  protected Map<String, String> entoken( final Map<String, String> map ) {
102
    final var result = new HashMap<String, String>( map.size() );
103
104
    map.forEach( ( k, v ) -> result.put( processKey( k ), processValue( v ) ) );
105
106
    return result;
107
  }
108
109
  protected ProcessorContext getContext() {
110
    return mContext;
111
  }
112
}
1113
M src/main/java/com/keenwrite/processors/XhtmlProcessor.java
33
44
import com.keenwrite.dom.DocumentParser;
5
import com.keenwrite.preferences.Key;
65
import com.keenwrite.preferences.Workspace;
76
import com.keenwrite.ui.heuristics.WordCounter;
87
import com.whitemagicsoftware.keenquotes.Contractions;
98
import com.whitemagicsoftware.keenquotes.Converter;
10
import javafx.beans.property.StringProperty;
9
import javafx.beans.property.ListProperty;
1110
import org.w3c.dom.Document;
1211
1312
import java.io.FileNotFoundException;
1413
import java.nio.file.Path;
14
import java.util.LinkedHashMap;
1515
import java.util.List;
1616
import java.util.Locale;
1717
import java.util.Map;
18
import java.util.Map.Entry;
1819
import java.util.regex.Pattern;
1920
2021
import static com.keenwrite.Bootstrap.APP_TITLE_LOWERCASE;
21
import static com.keenwrite.dom.DocumentParser.*;
22
import static com.keenwrite.dom.DocumentParser.createMeta;
23
import static com.keenwrite.dom.DocumentParser.visit;
2224
import static com.keenwrite.events.StatusEvent.clue;
2325
import static com.keenwrite.io.HttpFacade.httpGet;
24
import static com.keenwrite.preferences.WorkspaceKeys.*;
25
import static com.keenwrite.processors.text.TextReplacementFactory.replace;
26
import static com.keenwrite.preferences.AppKeys.*;
2627
import static com.keenwrite.util.ProtocolScheme.getProtocol;
2728
import static com.whitemagicsoftware.keenquotes.Converter.CHARS;
...
4748
4849
  private final ProcessorContext mContext;
50
51
  /**
52
   * Adorns the given document with {@code html}, {@code head}, and
53
   * {@code body} elements.
54
   *
55
   * @param html The document to decorate.
56
   * @return A document with a typical HTML structure.
57
   */
58
  private static String decorate( final String html ) {
59
    return
60
      "<html><head><title> </title><meta charset='utf8'/></head><body>"
61
        + html
62
        + "</body></html>";
63
  }
4964
5065
  public XhtmlProcessor(
...
7186
      setMetaData( doc );
7287
73
      walk( doc, "//img", node -> {
88
      visit( doc, "//img", node -> {
7489
        try {
7590
          final var attrs = node.getAttributes();
...
105120
   */
106121
  private void setMetaData( final Document doc ) {
107
    final var metadata = createMetaData( doc );
108
109
    walk( doc, "/html/head", node ->
122
    final var metadata = createMetaDataMap( doc );
123
    visit( doc, "/html/head", node ->
110124
      metadata.entrySet()
111125
              .forEach( entry -> node.appendChild( createMeta( doc, entry ) ) )
112126
    );
113
    walk( doc, "/html/head/title", node -> node.setTextContent( title() ) );
127
128
    final var title = metadata.get( "title" );
129
    if( title != null ) {
130
      visit( doc, "/html/head/title", node -> node.setTextContent( title ) );
131
    }
114132
  }
115133
116134
  /**
117135
   * Generates document metadata, including word count.
118136
   *
119137
   * @param doc The document containing the text to tally.
120138
   * @return A map of metadata key/value pairs.
121139
   */
122
  private Map<String, String> createMetaData( final Document doc ) {
123
    return Map.of(
124
      "author", author(),
125
      "byline", byLine(),
126
      "address", address(),
127
      "phone", phone(),
128
      "email", email(),
129
      "count", wordCount( doc ),
130
      "keywords", keywords(),
131
      "copyright", copyright(),
132
      "date", date()
140
  private Map<String, String> createMetaDataMap( final Document doc ) {
141
    final Map<String, String> result = new LinkedHashMap<>();
142
    final var metadata = getMetaData();
143
    final var map = mContext.getInterpolatedDefinitions();
144
145
    metadata.forEach( entry -> result.put(
146
      entry.getKey(), map.interpolate( entry.getValue() ) )
133147
    );
148
    result.put( "count", wordCount( doc ) );
149
150
    return result;
151
  }
152
153
  /**
154
   * The metadata is in list form because the user interface for entering the
155
   * key-value pairs is a table, which requires a generic {@link List} rather
156
   * than a generic {@link Map}.
157
   *
158
   * @return The document metadata.
159
   */
160
  private ListProperty<Entry<String, String>> getMetaData() {
161
    return getWorkspace().listsProperty( KEY_DOC_META );
134162
  }
135163
...
198226
199227
  private String getImagePath() {
200
    return getWorkspace().toFile( KEY_IMAGES_DIR ).toString();
228
    return getWorkspace().getFile( KEY_IMAGES_DIR ).toString();
201229
  }
202230
203231
  private String getImageOrder() {
204
    return getWorkspace().toString( KEY_IMAGES_ORDER );
232
    return getWorkspace().getString( KEY_IMAGES_ORDER );
205233
  }
206234
...
220248
221249
  private Locale locale() {return getWorkspace().getLocale();}
222
223
  private String title() {
224
    return resolve( KEY_DOC_TITLE );
225
  }
226
227
  private String author() {
228
    return resolve( KEY_DOC_AUTHOR );
229
  }
230
231
  private String byLine() {
232
    return resolve( KEY_DOC_BYLINE );
233
  }
234
235
  private String address() {
236
    return resolve( KEY_DOC_ADDRESS ).replaceAll( "\n", "\\\\\\break{}" );
237
  }
238
239
  private String phone() {
240
    return resolve( KEY_DOC_PHONE );
241
  }
242
243
  private String email() {
244
    return resolve( KEY_DOC_EMAIL );
245
  }
246250
247251
  private String wordCount( final Document doc ) {
248252
    final var sb = new StringBuilder( 65536 * 10 );
249253
250
    walk(
254
    visit(
251255
      doc,
252256
      "//*[normalize-space( text() ) != '']",
253257
      node -> sb.append( node.getTextContent() )
254258
    );
255259
256260
    return valueOf( WordCounter.create( locale() ).count( sb.toString() ) );
257
  }
258
259
  private String keywords() {
260
    return resolve( KEY_DOC_KEYWORDS );
261
  }
262
263
  private String copyright() {
264
    return resolve( KEY_DOC_COPYRIGHT );
265
  }
266
267
  private String date() {
268
    return resolve( KEY_DOC_DATE );
269261
  }
270262
271263
  /**
272264
   * Answers whether straight quotation marks should be curled.
273265
   *
274266
   * @return {@code false} to prevent curling straight quotes.
275267
   */
276268
  private boolean curl() {
277
    return getWorkspace().toBoolean( KEY_TYPESET_TYPOGRAPHY_QUOTES );
278
  }
279
280
  private String resolve( final Key key ) {
281
    return replace( asString( key ), mContext.getResolvedMap() );
282
  }
283
284
  private String asString( final Key key ) {
285
    return stringProperty( key ).get();
286
  }
287
288
  private StringProperty stringProperty( final Key key ) {
289
    return getWorkspace().stringProperty( key );
269
    return getWorkspace().getBoolean( KEY_TYPESET_TYPOGRAPHY_QUOTES );
290270
  }
291271
M src/main/java/com/keenwrite/processors/markdown/MarkdownProcessor.java
33
44
import com.keenwrite.io.MediaType;
5
import com.keenwrite.processors.DefinitionProcessor;
5
import com.keenwrite.processors.VariableProcessor;
66
import com.keenwrite.processors.Processor;
77
import com.keenwrite.processors.ProcessorContext;
...
5353
  @Override
5454
  List<Extension> createExtensions( final ProcessorContext context ) {
55
    final var editorFile = context.getDocumentPath();
55
    final var editorFile = context.getInputPath();
5656
    final var mediaType = MediaType.valueFrom( editorFile );
5757
    final Processor<String> processor;
...
6464
    }
6565
    else {
66
      processor = new DefinitionProcessor( IDENTITY, context );
66
      processor = new VariableProcessor( IDENTITY, context );
6767
    }
6868
M src/main/java/com/keenwrite/processors/markdown/extensions/CaretExtension.java
1616
import org.jetbrains.annotations.NotNull;
1717
18
import java.util.function.Supplier;
19
1820
import static com.keenwrite.constants.Constants.CARET_ID;
1921
import static com.keenwrite.processors.markdown.extensions.EmptyNode.EMPTY_NODE;
...
2628
public class CaretExtension extends HtmlRendererAdapter {
2729
28
  private final Caret mCaret;
30
  private final Supplier<Caret> mCaret;
2931
3032
  private CaretExtension( final ProcessorContext context ) {
...
4850
   */
4951
  public static class IdAttributeProvider implements AttributeProvider {
50
    private final Caret mCaret;
52
    private final Supplier<Caret> mCaret;
5153
    private boolean mAdded;
5254
53
    public IdAttributeProvider( final Caret caret ) {
55
    public IdAttributeProvider( final Supplier<Caret> caret ) {
5456
      mCaret = caret;
5557
    }
5658
57
    private static AttributeProviderFactory createFactory( final Caret caret ) {
59
    private static AttributeProviderFactory createFactory(
60
      final Supplier<Caret> caret ) {
5861
      return new IndependentAttributeProviderFactory() {
5962
        @Override
...
7376
        return;
7477
      }
78
79
      final var caret = mCaret.get();
7580
7681
      // If a table block has been earmarked with an empty node, it means
...
9297
      }
9398
94
      final var outside = mCaret.isAfterText() ? 1 : 0;
99
      final var outside = caret.isAfterText() ? 1 : 0;
95100
      final var began = curr.getStartOffset();
96101
      final var ended = curr.getEndOffset() + outside;
97102
      final var prev = curr.getPrevious();
98103
99104
      // If the caret is within the bounds of the current node or the
100105
      // caret is within the bounds of the end of the previous node and
101106
      // the start of the current node, then mark the current node with
102107
      // a caret indicator.
103
      if( mCaret.isBetweenText( began, ended ) ||
104
        prev != null && mCaret.isBetweenText( prev.getEndOffset(), began ) ) {
108
      if( caret.isBetweenText( began, ended ) ||
109
        prev != null && caret.isBetweenText( prev.getEndOffset(), began ) ) {
105110
        // This line empowers synchronizing the text editor with the preview.
106111
        attributes.addValue( AttributeImpl.of( "id", CARET_ID ) );
M src/main/java/com/keenwrite/processors/markdown/extensions/ImageLinkExtension.java
1818
import static com.keenwrite.ExportFormat.NONE;
1919
import static com.keenwrite.events.StatusEvent.clue;
20
import static com.keenwrite.preferences.WorkspaceKeys.KEY_IMAGES_DIR;
21
import static com.keenwrite.preferences.WorkspaceKeys.KEY_IMAGES_ORDER;
20
import static com.keenwrite.preferences.AppKeys.KEY_IMAGES_DIR;
21
import static com.keenwrite.preferences.AppKeys.KEY_IMAGES_ORDER;
2222
import static com.keenwrite.util.ProtocolScheme.getProtocol;
2323
import static com.vladsch.flexmark.html.HtmlRenderer.Builder;
...
148148
149149
    private Path getUserImagesDir() {
150
      return mWorkspace.toFile( KEY_IMAGES_DIR ).toPath();
150
      return mWorkspace.getFile( KEY_IMAGES_DIR ).toPath();
151151
    }
152152
153153
    private Iterable<String> getImageExtensions() {
154
      return on( ' ' ).split( mWorkspace.toString( KEY_IMAGES_ORDER ) );
154
      return on( ' ' ).split( mWorkspace.getString( KEY_IMAGES_ORDER ) );
155155
    }
156156
M src/main/java/com/keenwrite/processors/markdown/extensions/fences/FencedBlockExtension.java
44
import com.keenwrite.preferences.Workspace;
55
import com.keenwrite.preview.DiagramUrlGenerator;
6
import com.keenwrite.processors.DefinitionProcessor;
6
import com.keenwrite.processors.VariableProcessor;
77
import com.keenwrite.processors.Processor;
88
import com.keenwrite.processors.ProcessorContext;
...
2323
import java.util.Set;
2424
25
import static com.keenwrite.preferences.WorkspaceKeys.KEY_IMAGES_SERVER;
25
import static com.keenwrite.preferences.AppKeys.KEY_IMAGES_SERVER;
2626
import static com.vladsch.flexmark.html.HtmlRenderer.Builder;
2727
import static com.vladsch.flexmark.html.renderer.CoreNodeRenderer.CODE_CONTENT;
...
5151
   * to generate SVG files of text diagrams.
5252
   * <p>
53
   * Internally, this creates a {@link DefinitionProcessor} to substitute
53
   * Internally, this creates a {@link VariableProcessor} to substitute
5454
   * variable definitions. This is necessary because the order of processors
55
   * matters. If the {@link DefinitionProcessor} comes before an instance of
55
   * matters. If the {@link VariableProcessor} comes before an instance of
5656
   * {@link MarkdownProcessor}, for example, then the caret position in the
5757
   * preview pane will not align with the caret position in the editor
...
107107
          final var content = node.getContentChars().normalizeEOL();
108108
          final var text = mProcessor.apply( content );
109
          final var server = mWorkspace.toString( KEY_IMAGES_SERVER );
109
          final var server = mWorkspace.getString( KEY_IMAGES_SERVER );
110110
          final var source = DiagramUrlGenerator.toUrl( server, type, text );
111111
          final var link = context.resolveLink( LINK, source, false );
M src/main/java/com/keenwrite/processors/markdown/extensions/r/RExtension.java
77
import com.keenwrite.processors.r.InlineRProcessor;
88
import com.keenwrite.processors.r.RProcessor;
9
import com.keenwrite.sigils.RSigilOperator;
109
import com.vladsch.flexmark.ast.Paragraph;
11
import com.vladsch.flexmark.ast.Text;
1210
import com.vladsch.flexmark.parser.InlineParserExtensionFactory;
13
import com.vladsch.flexmark.parser.InlineParserFactory;
1411
import com.vladsch.flexmark.parser.delimiter.DelimiterProcessor;
1512
import com.vladsch.flexmark.parser.internal.InlineParserImpl;
...
2320
2421
import static com.keenwrite.processors.IdentityProcessor.IDENTITY;
25
import static com.keenwrite.processors.markdown.extensions.EmptyNode.EMPTY_NODE;
2622
import static com.vladsch.flexmark.parser.Parser.Builder;
2723
import static com.vladsch.flexmark.parser.Parser.ParserExtension;
2824
2925
/**
3026
 * Responsible for processing inline R statements (denoted using the
31
 * {@link RSigilOperator#PREFIX}) to prevent them from being converted to
27
 * {@link InlineRProcessor#PREFIX}) to prevent them from being converted to
3228
 * HTML {@code <code>} elements and stop them from interfering with TeX
3329
 * statements. Note that TeX statements are processed using a Markdown
3430
 * extension, rather than an implementation of {@link Processor}. For this
3531
 * reason, some pre-conversion is necessary.
3632
 */
3733
public final class RExtension implements ParserExtension {
38
  private final InlineParserFactory INLINE_FACTORY = InlineParser::new;
3934
  private final RProcessor mProcessor;
4035
  private final BaseMarkdownProcessor mMarkdownProcessor;
...
5752
  @Override
5853
  public void extend( final Builder builder ) {
59
    builder.customInlineParserFactory( INLINE_FACTORY );
54
    builder.customInlineParserFactory( InlineParser::new );
6055
  }
6156
6257
  @Override
63
  public void parserOptions( final MutableDataHolder options ) {
64
  }
58
  public void parserOptions( final MutableDataHolder options ) {}
6559
6660
  /**
...
8478
      final LinkRefProcessorData referenceLinkProcessors,
8579
      final List<InlineParserExtensionFactory> inlineParserExtensions ) {
86
      super( options,
87
             specialCharacters,
88
             delimiterCharacters,
89
             delimiterProcessors,
90
             referenceLinkProcessors,
91
             inlineParserExtensions );
80
      super(
81
        options,
82
        specialCharacters,
83
        delimiterCharacters,
84
        delimiterProcessors,
85
        referenceLinkProcessors,
86
        inlineParserExtensions
87
      );
9288
      mProcessor.init();
9389
    }
9490
9591
    /**
9692
     * The superclass handles a number backtick parsing edge cases; this method
9793
     * changes the behaviour to retain R code snippets, identified by
98
     * {@link RSigilOperator#PREFIX}, so that subsequent processing can
94
     * {@link InlineRProcessor#PREFIX}, so that subsequent processing can
9995
     * invoke R. If other languages are added, the {@link InlineParser} will
10096
     * have to be rewritten to identify more than merely R.
...
114110
          final var code = codeNode.getChars().toString();
115111
116
          if( code.startsWith( RSigilOperator.PREFIX ) ) {
112
          if( code.startsWith( InlineRProcessor.PREFIX ) ) {
117113
            codeNode.unlink();
114
118115
            final var rText = mProcessor.apply( code );
119116
            var node = mMarkdownProcessor.toNode( rText );
120
121
            if( node.getFirstChild() instanceof Paragraph ) {
122
              node = new Text( rText );
123
            }
124
            else {
125
              node = node.getFirstChild();
126117
127
              if( node != null ) {
128
                // Mark the node as being generated code, such as text returned
129
                // from an R function.
130
                node.appendChild( EMPTY_NODE );
131
              }
118
            if( node.getFirstChild() instanceof Paragraph paragraph ) {
119
              node = paragraph.getFirstChild();
132120
            }
133121
D src/main/java/com/keenwrite/processors/markdown/extensions/r/ROutputProcessor.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.processors.markdown.extensions.r;
3
4
import com.keenwrite.processors.ExecutorProcessor;
5
import com.keenwrite.processors.r.InlineRProcessor;
6
import com.keenwrite.processors.markdown.MarkdownProcessor;
7
import com.keenwrite.processors.markdown.extensions.tex.TeXExtension;
8
import com.vladsch.flexmark.ast.Paragraph;
9
import com.vladsch.flexmark.ast.Text;
10
import com.vladsch.flexmark.html.HtmlRenderer;
11
import com.vladsch.flexmark.parser.Parser;
12
import com.vladsch.flexmark.util.ast.IParse;
13
import com.vladsch.flexmark.util.ast.IRender;
14
15
/**
16
 * Responsible for parsing the output from an R eval statement. This class
17
 * is used to avoid an circular dependency whereby the {@link InlineRProcessor}
18
 * must treat the output from an R function call as Markdown, which would
19
 * otherwise require a {@link MarkdownProcessor} instance; however, the
20
 * {@link MarkdownProcessor} class gives precedence to its extensions, which
21
 * means the {@link TeXExtension} will be executed <em>before</em> the
22
 * {@link InlineRProcessor}, thereby being exposed to backticks in a TeX
23
 * macro---a syntax error. To break the cycle, the {@link InlineRProcessor}
24
 * uses this class instead of {@link MarkdownProcessor}.
25
 */
26
public class ROutputProcessor extends ExecutorProcessor<String> {
27
  private final IParse mParser = Parser.builder().build();
28
  private final IRender mRenderer = HtmlRenderer.builder().build();
29
30
  @Override
31
  public String apply( final String markdown ) {
32
    var node = mParser.parse( markdown ).getFirstChild();
33
34
    if( node == null ) {
35
      node = new Text();
36
    }
37
    else if( node.isOrDescendantOfType( Paragraph.class ) ) {
38
      node = new Text( node.getChars() );
39
    }
40
41
    // Trimming prevents displaced commas and unwanted newlines.
42
    return mRenderer.render( node ).trim();
43
  }
44
}
451
A src/main/java/com/keenwrite/processors/r/Engine.java
1
/* Copyright 2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.processors.r;
3
4
import com.keenwrite.collections.BoundedCache;
5
6
import javax.script.ScriptEngine;
7
import javax.script.ScriptEngineManager;
8
import java.util.Map;
9
10
import static com.keenwrite.Messages.get;
11
import static com.keenwrite.events.StatusEvent.clue;
12
import static java.lang.Math.min;
13
14
/**
15
 * Responsible for executing R statements, which can also update the engine's
16
 * state.
17
 */
18
public final class Engine {
19
  /**
20
   * Inline R expressions that have already been evaluated.
21
   */
22
  private static final Map<String, String> sCache =
23
    new BoundedCache<>( 512 );
24
25
  /**
26
   * Engine for evaluating R expressions.
27
   */
28
  private static final ScriptEngine sEngine =
29
    (new ScriptEngineManager()).getEngineByName( "Renjin" );
30
31
  /**
32
   * Empties the cache.
33
   */
34
  public static void clear() {
35
    sCache.clear();
36
  }
37
38
  /**
39
   * Look up an R expression from the cache then return the resulting object.
40
   * If the R expression hasn't been cached, it'll first be evaluated.
41
   *
42
   * @param r R expression to evaluate.
43
   * @return The object resulting from the evaluation.
44
   */
45
  public static String eval( final String r ) {
46
    return sCache.computeIfAbsent( r, __ -> evaluate( r ) );
47
  }
48
49
  /**
50
   * Returns the result of an R expression as an object converted to string.
51
   *
52
   * @param r R expression to evaluate.
53
   * @return The object resulting from the evaluation.
54
   */
55
  private static String evaluate( final String r ) {
56
    try {
57
      return sEngine.eval( r ).toString();
58
    } catch( final Exception ex ) {
59
      final var expr = r.substring( 0, min( r.length(), 50 ) );
60
      clue( get( "Main.status.error.r", expr, ex.getMessage() ), ex );
61
      throw new IllegalArgumentException( r );
62
    }
63
  }
64
}
165
M src/main/java/com/keenwrite/processors/r/InlineRProcessor.java
33
44
import com.keenwrite.preferences.Workspace;
5
import com.keenwrite.processors.DefinitionProcessor;
65
import com.keenwrite.processors.Processor;
76
import com.keenwrite.processors.ProcessorContext;
8
import com.keenwrite.processors.markdown.extensions.r.ROutputProcessor;
9
import com.keenwrite.util.BoundedCache;
7
import com.keenwrite.processors.VariableProcessor;
8
import com.keenwrite.sigils.RKeyOperator;
109
import javafx.beans.property.Property;
10
import org.jetbrains.annotations.NotNull;
1111
12
import javax.script.ScriptEngine;
13
import javax.script.ScriptEngineManager;
1412
import java.io.File;
1513
import java.nio.file.Path;
16
import java.util.Map;
14
import java.util.HashMap;
1715
import java.util.concurrent.atomic.AtomicBoolean;
1816
1917
import static com.keenwrite.constants.Constants.STATUS_PARSE_ERROR;
20
import static com.keenwrite.Messages.get;
2118
import static com.keenwrite.events.StatusEvent.clue;
22
import static com.keenwrite.preferences.WorkspaceKeys.*;
19
import static com.keenwrite.preferences.AppKeys.KEY_R_DIR;
20
import static com.keenwrite.preferences.AppKeys.KEY_R_SCRIPT;
21
import static com.keenwrite.processors.r.RVariableProcessor.escape;
2322
import static com.keenwrite.processors.text.TextReplacementFactory.replace;
24
import static com.keenwrite.sigils.RSigilOperator.PREFIX;
25
import static com.keenwrite.sigils.RSigilOperator.SUFFIX;
26
import static java.lang.Math.max;
2723
import static java.lang.Math.min;
28
import static java.lang.String.format;
2924
3025
/**
3126
 * Transforms a document containing R statements into Markdown.
3227
 */
33
public final class InlineRProcessor extends DefinitionProcessor {
34
  private final Processor<String> mPostProcessor = new ROutputProcessor();
35
36
  /**
37
   * Where to put document inline evaluated R expressions, constrained to
38
   * avoid running out of memory.
39
   */
40
  private final Map<String, String> mEvalCache =
41
    new BoundedCache<>( 512 );
42
43
  private static final ScriptEngine ENGINE =
44
    (new ScriptEngineManager()).getEngineByName( "Renjin" );
28
public final class InlineRProcessor extends VariableProcessor {
29
  public static final String PREFIX = "`r#";
30
  public static final char SUFFIX = '`';
4531
4632
  private static final int PREFIX_LENGTH = PREFIX.length();
4733
48
  private final AtomicBoolean mDirty = new AtomicBoolean( false );
34
  /**
35
   * Set to {@code true} when the R bootstrap script is loaded successfully.
36
   */
37
  private final AtomicBoolean mReady = new AtomicBoolean();
4938
50
  private final Workspace mWorkspace;
39
  private final RKeyOperator mOperator = new RKeyOperator();
5140
5241
  /**
...
6049
    final ProcessorContext context ) {
6150
    super( successor, context );
62
63
    mWorkspace = context.getWorkspace();
64
65
    bootstrapScriptProperty().addListener(
66
      ( __, oldScript, newScript ) -> setDirty( true ) );
67
    workingDirectoryProperty().addListener(
68
      ( __, oldScript, newScript ) -> setDirty( true ) );
69
70
    // TODO: Watch the "R" property keys in the workspace, directly.
71
72
    // If the user saves the preferences, make sure that any R-related settings
73
    // changes are applied.
74
//    getWorkspace().addSaveEventHandler( ( handler ) -> {
75
//      if( isDirty() ) {
76
//        init();
77
//        setDirty( false );
78
//      }
79
//    } );
80
81
    init();
8251
  }
8352
8453
  /**
85
   * Initialises the R code so that R can find imported libraries. Note that
54
   * Initializes the R code so that R can find imported libraries. Note that
8655
   * any existing R functionality will not be overwritten if this method is
8756
   * called multiple times.
88
   *
89
   * @return {@code true} if initialization completed and all variables were
90
   * replaced; {@code false} if any variables remain.
57
   * <p>
58
   * If the R code to bootstrap contained variables, and they were all updated
59
   * successfully, this will update the internal ready flag to {@code true}.
9160
   */
92
  public boolean init() {
61
  public void init() {
9362
    final var bootstrap = getBootstrapScript();
9463
9564
    if( !bootstrap.isBlank() ) {
9665
      final var wd = getWorkingDirectory();
9766
      final var dir = wd.toString().replace( '\\', '/' );
98
      final var map = getDefinitions();
99
      final var defBegan = mWorkspace.toString( KEY_DEF_DELIM_BEGAN );
100
      final var defEnded = mWorkspace.toString( KEY_DEF_DELIM_ENDED );
101
102
      map.put( defBegan + "application.r.working.directory" + defEnded, dir );
103
104
      final var replaced = replace( bootstrap, map );
105
      final var bIndex = replaced.indexOf( defBegan );
106
107
      // If there's a delimiter in the replaced text it means not all variables
108
      // are bound, which is an error.
109
      if( bIndex >= 0 ) {
110
        var eIndex = replaced.indexOf( defEnded );
111
        eIndex = (eIndex == -1) ? replaced.length() - 1 : max( bIndex, eIndex );
67
      final var definitions = getContext().getDefinitions();
68
      final var map = new HashMap<String, String>( definitions.size() + 1 );
11269
113
        final var def = replaced.substring(
114
          bIndex + defBegan.length(), eIndex );
115
        clue( "Main.status.error.bootstrap.eval",
116
              format( "%s%s%s", defBegan, def, defEnded ) );
70
      definitions.forEach(
71
        ( k, v ) -> map.put( mOperator.apply( k ), escape( v ) )
72
      );
73
      map.put(
74
        mOperator.apply( "application.r.working.directory" ),
75
        escape( dir )
76
      );
11777
118
        return false;
119
      }
120
      else {
121
        eval( replaced );
78
      try {
79
        Engine.eval( replace( bootstrap, map ) );
80
        mReady.set( true );
81
      } catch( final Exception ignored ) {
82
        // A problem with the bootstrap script is likely caused by variables
83
        // not being loaded. This implies that the R processor is being invoked
84
        // too soon.
12285
      }
12386
    }
124
125
    return true;
126
  }
127
128
  /**
129
   * Empties the cache.
130
   */
131
  public void clear() {
132
    mEvalCache.clear();
133
  }
134
135
  /**
136
   * Sets the dirty flag to indicate that the bootstrap script or working
137
   * directory has been modified. Upon saving the preferences, if this flag
138
   * is true, then {@link #init()} will be called to reload the R environment.
139
   *
140
   * @param dirty Set to true to reload changes upon closing preferences.
141
   */
142
  private void setDirty( final boolean dirty ) {
143
    mDirty.set( dirty );
14487
  }
14588
14689
  /**
147
   * Answers whether R-related settings have been modified.
90
   * Answers whether R has been initialized without failures.
14891
   *
149
   * @return {@code true} when the settings have changed.
92
   * @return {@code true} the R engine is ready to process inline R statements.
15093
   */
151
  private boolean isDirty() {
152
    return mDirty.get();
94
  public boolean isReady() {
95
    return mReady.get();
15396
  }
15497
...
163106
   */
164107
  @Override
165
  public String apply( final String text ) {
108
  public @NotNull String apply( final String text ) {
166109
    final int length = text.length();
167110
...
191134
        try {
192135
          // Append the string representation of the result into the text.
193
          sb.append( evalCached( r ) );
136
          sb.append( Engine.eval( r ) );
194137
        } catch( final Exception ex ) {
195138
          // Inform the user that there was a problem.
...
211154
    // Copy from the previous index to the end of the string.
212155
    return sb.append( text.substring( min( prevIndex, length ) ) ).toString();
213
  }
214
215
  /**
216
   * Look up an R expression from the cache then return the resulting object.
217
   * If the R expression hasn't been cached, it'll first be evaluated.
218
   *
219
   * @param r The expression to evaluate.
220
   * @return The object resulting from the evaluation.
221
   */
222
  private String evalCached( final String r ) {
223
    return mEvalCache.computeIfAbsent( r, __ -> evalHtml( r ) );
224
  }
225
226
  /**
227
   * Converts the given string to HTML, trimming new lines, and inlining
228
   * the text if it is a paragraph. Otherwise, the resulting HTML is most likely
229
   * complex (e.g., a Markdown table) and should be rendered as its HTML
230
   * equivalent.
231
   *
232
   * @param r The R expression to evaluate then convert to HTML.
233
   * @return The result from the R expression as an HTML element.
234
   */
235
  private String evalHtml( final String r ) {
236
    return mPostProcessor.apply( eval( r ) );
237
  }
238
239
  /**
240
   * Evaluate an R expression and return the resulting object.
241
   *
242
   * @param r The expression to evaluate.
243
   * @return The object resulting from the evaluation.
244
   */
245
  private String eval( final String r ) {
246
    try {
247
      return ENGINE.eval( r ).toString();
248
    } catch( final Exception ex ) {
249
      final var expr = r.substring( 0, min( r.length(), 50 ) );
250
      clue( get( "Main.status.error.r", expr, ex.getMessage() ), ex );
251
      return "";
252
    }
253156
  }
254157
...
281184
282185
  private Workspace getWorkspace() {
283
    return mWorkspace;
186
    return getContext().getWorkspace();
284187
  }
285188
}
M src/main/java/com/keenwrite/processors/r/RProcessor.java
1818
  private final InlineRProcessor mInlineRProcessor;
1919
20
  private boolean mReady;
21
2220
  public RProcessor( final ProcessorContext context ) {
2321
    final var irp = new InlineRProcessor( IDENTITY, context );
2422
    final var rvp = new RVariableProcessor( irp, context );
23
2524
    mProcessor = new ExecutorProcessor<>( rvp );
2625
    mInlineRProcessor = irp;
27
  }
28
29
  public void init() {
30
    mReady = mInlineRProcessor.init();
3126
  }
3227
3328
  public String apply( final String text ) {
29
    if( !mInlineRProcessor.isReady() ) {
30
      mInlineRProcessor.init();
31
    }
32
3433
    return mProcessor.apply( text );
34
  }
35
36
  /**
37
   * Called when the {@link InlineRProcessor} is instantiated, which triggers
38
   * a re-evaluation of all R expressions in the document. Without this, when
39
   * the document is first viewed, no R expressions are evaluated until the
40
   * user interacts with the document.
41
   */
42
  public void init() {
43
    mInlineRProcessor.init();
3544
  }
3645
3746
  public boolean isReady() {
38
    return mReady;
47
    return mInlineRProcessor.isReady();
3948
  }
4049
}
M src/main/java/com/keenwrite/processors/r/RVariableProcessor.java
22
package com.keenwrite.processors.r;
33
4
import com.keenwrite.preferences.Workspace;
5
import com.keenwrite.processors.DefinitionProcessor;
64
import com.keenwrite.processors.ProcessorContext;
7
import com.keenwrite.sigils.RSigilOperator;
8
import com.keenwrite.sigils.SigilOperator;
9
import com.keenwrite.sigils.YamlSigilOperator;
10
11
import java.util.HashMap;
12
import java.util.Map;
5
import com.keenwrite.processors.VariableProcessor;
6
import com.keenwrite.sigils.RKeyOperator;
137
14
import static com.keenwrite.preferences.WorkspaceKeys.*;
8
import java.util.function.UnaryOperator;
159
1610
/**
1711
 * Converts the keys of the resolved map from default form to R form, then
1812
 * performs a substitution on the text. The default R variable syntax is
19
 * {@code v$tree$leaf}.
13
 * <pre>v$tree$leaf</pre>.
2014
 */
21
public final class RVariableProcessor extends DefinitionProcessor {
22
23
  private final SigilOperator mSigilOperator;
24
15
public final class RVariableProcessor extends VariableProcessor {
2516
  public RVariableProcessor(
2617
    final InlineRProcessor irp, final ProcessorContext context ) {
2718
    super( irp, context );
28
    mSigilOperator = createSigilOperator( context.getWorkspace() );
2919
  }
3020
31
  /**
32
   * Returns the R-based version of the interpolated variable definitions.
33
   *
34
   * @return Variable names transmogrified from the default syntax to R syntax.
35
   */
3621
  @Override
37
  protected Map<String, String> getDefinitions() {
38
    return entoken( super.getDefinitions() );
22
  protected UnaryOperator<String> createKeyOperator(
23
    final ProcessorContext context ) {
24
    return new RKeyOperator();
3925
  }
40
41
  /**
42
   * Converts the given map from regular variables to R variables.
43
   *
44
   * @param map Map of variable names to values.
45
   * @return Map of R variables.
46
   */
47
  private Map<String, String> entoken( final Map<String, String> map ) {
48
    final var rMap = new HashMap<String, String>( map.size() );
4926
50
    for( final var entry : map.entrySet() ) {
51
      final var key = entry.getKey();
52
      rMap.put( mSigilOperator.entoken( key ), escape( map.get( key ) ) );
53
    }
27
  @Override
28
  protected String processValue( final String value ) {
29
    assert value != null;
5430
55
    return rMap;
31
    return escape( value );
5632
  }
5733
58
  private String escape( final String value ) {
34
  /**
35
   * In R, single quotes and double quotes are interchangeable. Using single
36
   * quotes is simpler to code.
37
   *
38
   * @param value The text to convert into a valid quoted R string.
39
   * @return The quoted value with embedded quotes escaped as necessary.
40
   */
41
  public static String escape( final String value ) {
5942
    return '\'' + escape( value, '\'', "\\'" ) + '\'';
6043
  }
...
6952
   */
7053
  @SuppressWarnings( "SameParameterValue" )
71
  private String escape(
54
  private static String escape(
7255
    final String haystack, final char needle, final String thread ) {
56
    assert haystack != null;
57
    assert thread != null;
58
7359
    int end = haystack.indexOf( needle );
7460
7561
    if( end < 0 ) {
7662
      return haystack;
7763
    }
7864
79
    final int length = haystack.length();
8065
    int start = 0;
8166
8267
    // Replace up to 32 occurrences before reallocating the internal buffer.
83
    final var sb = new StringBuilder( length + 32 );
68
    final var sb = new StringBuilder( haystack.length() + 32 );
8469
8570
    while( end >= 0 ) {
8671
      sb.append( haystack, start, end ).append( thread );
8772
      start = end + 1;
8873
      end = haystack.indexOf( needle, start );
8974
    }
9075
9176
    return sb.append( haystack.substring( start ) ).toString();
92
  }
93
94
  private SigilOperator createSigilOperator( final Workspace workspace ) {
95
    final var tokens = workspace.toSigils(
96
      KEY_R_DELIM_BEGAN, KEY_R_DELIM_ENDED );
97
    final var antecedent = createDefinitionOperator( workspace );
98
    return new RSigilOperator( tokens, antecedent );
99
  }
100
101
  private SigilOperator createDefinitionOperator( final Workspace workspace ) {
102
    final var sigils = workspace.toSigils(
103
      KEY_DEF_DELIM_BEGAN, KEY_DEF_DELIM_ENDED );
104
    return new YamlSigilOperator( sigils );
10577
  }
10678
}
M src/main/java/com/keenwrite/processors/text/TextReplacementFactory.java
2121
   */
2222
  public static TextReplacer getTextReplacer( final int length ) {
23
    // After about 1,500 characters, the StringUtils implementation is slower
24
    // than the Aho-Corsick algorithm implementation.
23
    // After about 1,500 characters, the Aho-Corsick algorithm is faster.
2524
    return length < 1500 ? APACHE : AHO_CORASICK;
2625
  }
2726
2827
  /**
2928
   * Convenience method to instantiate a suitable text replacer algorithm and
3029
   * perform a replacement using the given map. At this point, the values should
3130
   * be already dereferenced and ready to be substituted verbatim; any
3231
   * recursively defined values must have been interpolated previously.
3332
   *
34
   * @param text The text containing zero or more variables to replace.
35
   * @param map  The map of variables to their dereferenced values.
33
   * @param haystack The text containing zero or more variables to replace.
34
   * @param needles  The map of variables to their dereferenced values.
3635
   * @return The text with all variables replaced.
3736
   */
3837
  public static String replace(
39
      final String text, final Map<String, String> map ) {
40
    return getTextReplacer( text.length() ).replace( text, map );
38
    final String haystack, final Map<String, String> needles ) {
39
    return getTextReplacer( haystack.length() ).replace( haystack, needles );
4140
  }
4241
}
A src/main/java/com/keenwrite/sigils/PropertyKeyOperator.java
1
/* Copyright 2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.sigils;
3
4
/**
5
 * Responsible for defining sigils used within property files.
6
 */
7
public class PropertyKeyOperator extends SigilKeyOperator {
8
  public static final String BEGAN = "${";
9
  public static final String ENDED = "}";
10
11
  /**
12
   * Constructs a new {@link SigilKeyOperator} subclass with <code>${</code>
13
   * and <code>}</code> used for the beginning and ending sigils.
14
   */
15
  public PropertyKeyOperator() {
16
    super( BEGAN, ENDED );
17
  }
18
}
119
A src/main/java/com/keenwrite/sigils/RKeyOperator.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.sigils;
3
4
import java.util.function.UnaryOperator;
5
6
/**
7
 * Converts dot-separated variable names into names compatible with R. That is,
8
 * {@code variable.name.qualified} becomes {@code v$variable$name$qualified}.
9
 */
10
public final class RKeyOperator implements UnaryOperator<String> {
11
  private static final char KEY_SEPARATOR_DEF = '.';
12
  private static final char KEY_SEPARATOR_R = '$';
13
14
  /**
15
   * Constructs a new instance capable of converting dot-separated variable
16
   * names into R's dollar-symbol-separated names.
17
   */
18
  public RKeyOperator() {}
19
20
  /**
21
   * Transforms a definition key name into the expected format for an R
22
   * variable key name.
23
   * <p>
24
   * This algorithm is faster than {@link String#replace(char, char)}. Faster
25
   * still would be to cache the values, but that would mean managing the
26
   * cache when the user changes the beginning and ending of the R delimiters.
27
   * This code gives about a 2% performance boost when scrolling using
28
   * cursor keys. After the JIT warms up, this super-minor bottleneck vanishes.
29
   *
30
   * @param key The variable name to transform, neither blank nor {@code null}.
31
   * @return The transformed variable name.
32
   */
33
  @Override
34
  public String apply( final String key ) {
35
    assert key != null;
36
    assert key.length() > 0;
37
    assert !key.isBlank();
38
39
    final var rVarName = new StringBuilder( key.length() + 3 );
40
    rVarName.append( "v" );
41
    rVarName.append( KEY_SEPARATOR_R );
42
    rVarName.append( key );
43
44
    // The 3 is for v$ + first char, which cannot be a separator.
45
    for( int i = rVarName.length() - 1; i >= 3; i-- ) {
46
      if( rVarName.charAt( i ) == KEY_SEPARATOR_DEF ) {
47
        rVarName.setCharAt( i, KEY_SEPARATOR_R );
48
      }
49
    }
50
51
    return rVarName.toString();
52
  }
53
}
154
D src/main/java/com/keenwrite/sigils/RSigilOperator.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.sigils;
3
4
/**
5
 * Brackets variable names between {@link #PREFIX} and {@link #SUFFIX} sigils.
6
 */
7
public final class RSigilOperator extends SigilOperator {
8
  public static final String PREFIX = "`r#";
9
  public static final char SUFFIX = '`';
10
11
  private static final char KEY_SEPARATOR_DEF = '.';
12
  private static final char KEY_SEPARATOR_R = '$';
13
14
  /**
15
   * Definition variables are inserted into the document before R variables,
16
   * so this is required to reformat the definition variable suitable for R.
17
   */
18
  private final SigilOperator mAntecedent;
19
20
  /**
21
   * Constructs a new {@link RSigilOperator} capable of wrapping tokens around
22
   * variable names (keys).
23
   *
24
   * @param sigils     The starting and ending tokens.
25
   * @param antecedent The operator to use to undo any previous entokenizing.
26
   */
27
  public RSigilOperator( final Sigils sigils, final SigilOperator antecedent ) {
28
    super( sigils );
29
30
    mAntecedent = antecedent;
31
  }
32
33
  /**
34
   * Returns the given string with backticks prepended and appended. The
35
   *
36
   * @param key The string to adorn with R token delimiters.
37
   * @return PREFIX + delimiterBegan + variableName + delimiterEnded + SUFFIX.
38
   */
39
  @Override
40
  public String apply( final String key ) {
41
    assert key != null;
42
    return PREFIX + getBegan() + key + getEnded() + SUFFIX;
43
  }
44
45
  /**
46
   * Transforms a definition key (bracketed by token delimiters) into the
47
   * expected format for an R variable key name.
48
   * <p>
49
   * The algorithm to entoken a definition name is faster than
50
   * {@link String#replace(char, char)}. Faster still would be to cache the
51
   * values, but that would mean managing the cache when the user changes
52
   * the beginning and ending of the R delimiters. This code gives about a
53
   * 2% performance boost when scrolling using cursor keys. After the JIT
54
   * warms up, this super-minor bottleneck vanishes.
55
   * </p>
56
   *
57
   * @param key The variable name to transform, can be empty but not null.
58
   * @return The transformed variable name.
59
   */
60
  public String entoken( final String key ) {
61
    final var detokened = new StringBuilder( key.length() );
62
    detokened.append( "v$" );
63
    detokened.append( mAntecedent.detoken( key ) );
64
65
    // The 3 is for "v$X" where X cannot be a period.
66
    for( int i = detokened.length() - 1; i >= 3; i-- ) {
67
      if( detokened.charAt( i ) == KEY_SEPARATOR_DEF ) {
68
        detokened.setCharAt( i, KEY_SEPARATOR_R );
69
      }
70
    }
71
72
    return detokened.toString();
73
  }
74
}
751
A src/main/java/com/keenwrite/sigils/SigilKeyOperator.java
1
/* Copyright 2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.sigils;
3
4
import java.util.function.UnaryOperator;
5
import java.util.regex.Matcher;
6
import java.util.regex.Pattern;
7
8
import static java.lang.String.format;
9
import static java.util.regex.Pattern.compile;
10
import static java.util.regex.Pattern.quote;
11
12
/**
13
 * Responsible for bracketing definition keys with token delimiters.
14
 */
15
public class SigilKeyOperator implements UnaryOperator<String> {
16
  private final String mBegan;
17
  private final String mEnded;
18
  private final Pattern mPattern;
19
20
  public SigilKeyOperator( final String began, final String ended ) {
21
    assert began != null;
22
    assert ended != null;
23
24
    mBegan = began;
25
    mEnded = ended;
26
    mPattern = compile( format( "%s(.*?)%s", quote( began ), quote( ended ) ) );
27
  }
28
29
  @Override
30
  public String apply( final String key ) {
31
    assert key != null;
32
    assert !key.startsWith( mBegan );
33
    assert !key.endsWith( mEnded );
34
35
    return mBegan + key + mEnded;
36
  }
37
38
  public Matcher match( final String text ) {
39
    return mPattern.matcher( text );
40
  }
41
}
142
D src/main/java/com/keenwrite/sigils/SigilOperator.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.sigils;
3
4
import javafx.beans.property.SimpleStringProperty;
5
6
import java.util.function.UnaryOperator;
7
8
/**
9
 * Responsible for updating definition keys to use a machine-readable format
10
 * corresponding to the type of file being edited. This changes a definition
11
 * key name based on some criteria determined by the factory that creates
12
 * implementations of this interface.
13
 */
14
public class SigilOperator implements UnaryOperator<String> {
15
  private final Sigils mSigils;
16
17
  /**
18
   * Defines a new {@link SigilOperator} with the given sigils.
19
   *
20
   * @param began The sigil that denotes the start of a variable name.
21
   * @param ended The sigil that denotes the end of a variable name.
22
   */
23
  public SigilOperator( final String began, final String ended ) {
24
    this( new Sigils(
25
      new SimpleStringProperty( began ),
26
      new SimpleStringProperty( ended )
27
    ) );
28
  }
29
30
  SigilOperator( final Sigils sigils ) {
31
    mSigils = sigils;
32
  }
33
34
  /**
35
   * Returns the given {@link String} verbatim. Different implementations
36
   * can override to inject custom behaviours.
37
   *
38
   * @param key Returned verbatim.
39
   */
40
  @Override
41
  public String apply( final String key ) {
42
    return key;
43
  }
44
45
  /**
46
   * Wraps the given key in the began and ended tokens. This may perform any
47
   * preprocessing necessary to ensure the transformation happens.
48
   *
49
   * @param key The variable name to transform.
50
   * @return The given key with before/after sigils to delimit the key name.
51
   */
52
  public String entoken( final String key ) {
53
    assert key != null;
54
    return getBegan() + key + getEnded();
55
  }
56
57
  /**
58
   * Removes start and stop definition key delimiters from the given key. This
59
   * method does not check for delimiters, only that there are sufficient
60
   * characters to remove from either end of the given key.
61
   *
62
   * @param key The key adorned with start and stop tokens.
63
   * @return The given key with the delimiters removed.
64
   */
65
  public String detoken( final String key ) {
66
    return key;
67
  }
68
69
  public Sigils getSigils() {
70
    return mSigils;
71
  }
72
73
  String getBegan() {
74
    return mSigils.getBegan();
75
  }
76
77
  String getEnded() {
78
    return mSigils.getEnded();
79
  }
80
}
811
D src/main/java/com/keenwrite/sigils/Sigils.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.sigils;
3
4
import javafx.beans.property.StringProperty;
5
6
import java.util.AbstractMap.SimpleImmutableEntry;
7
8
/**
9
 * Convenience class for pairing a start and an end sigil together.
10
 */
11
public final class Sigils
12
  extends SimpleImmutableEntry<StringProperty, StringProperty> {
13
14
  /**
15
   * Associates a new key-value pair.
16
   *
17
   * @param began The starting sigil.
18
   * @param ended The ending sigil.
19
   */
20
  public Sigils( final StringProperty began, final StringProperty ended ) {
21
    super( began, ended );
22
  }
23
24
  /**
25
   * @return The opening sigil token.
26
   */
27
  public String getBegan() {
28
    return getKey().get();
29
  }
30
31
  /**
32
   * @return The closing sigil token, or the empty string if none set.
33
   */
34
  public String getEnded() {
35
    return getValue().get();
36
  }
37
}
381
D src/main/java/com/keenwrite/sigils/YamlSigilOperator.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.sigils;
3
4
/**
5
 * Responsible for bracketing definition keys with token delimiters.
6
 */
7
public final class YamlSigilOperator extends SigilOperator {
8
  public YamlSigilOperator( final Sigils sigils ) {
9
    super( sigils );
10
  }
11
12
  /**
13
   * Removes start and stop definition key delimiters from the given key.
14
   *
15
   * @param key The key that may have start and stop tokens.
16
   * @return The given key with the delimiters removed.
17
   */
18
  public String detoken( final String key ) {
19
    final var began = getBegan();
20
    final var ended = getEnded();
21
    final int bLength = began.length();
22
    final int eLength = ended.length();
23
    final var bIndex = key.indexOf( began );
24
    final var eIndex = key.indexOf( ended, bIndex );
25
    final var kLength = key.length();
26
27
    return key.substring(
28
      bIndex == -1 ? 0 : bLength, eIndex == -1 ? kLength : kLength - eLength );
29
  }
30
}
311
M src/main/java/com/keenwrite/spelling/impl/SymSpellSpeller.java
4242
   */
4343
  public static SpellChecker forLexicon( final String filename ) {
44
    assert filename != null;
45
    assert !filename.isBlank();
46
4447
    try {
4548
      final var lexicon = readLexicon( filename );
...
5255
5356
  private static SpellChecker forLexicon( final Map<String, Long> lexicon ) {
54
    assert lexicon != null && !lexicon.isEmpty();
57
    assert lexicon != null;
58
    assert !lexicon.isEmpty();
5559
5660
    try {
...
7377
   */
7478
  private SymSpellSpeller( final SymSpell symSpell ) {
79
    assert symSpell != null;
80
7581
    mSymSpell = symSpell;
7682
  }
...
8692
  public boolean inLexicon( final String lexeme ) {
8793
    assert lexeme != null;
88
    assert !lexeme.isBlank();
94
    assert !lexeme.isEmpty();
8995
9096
    final var words = lookup( lexeme, CLOSEST );
9197
    return !words.isEmpty() && lexeme.equals( words.get( 0 ).getSuggestion() );
9298
  }
9399
94100
  @Override
95101
  public List<String> suggestions( final String lexeme, int count ) {
102
    assert lexeme != null;
103
    assert !lexeme.isEmpty();
104
96105
    final List<String> result = new ArrayList<>( count );
97106
...
140149
  private static Map<String, Long> readLexicon( final String filename )
141150
    throws Exception {
151
    assert filename != null;
152
    assert !filename.isEmpty();
153
142154
    final var path = '/' + LEXICONS_DIRECTORY + '/' + filename;
143155
    final var map = new HashMap<String, Long>();
...
171183
   */
172184
  private boolean isWord( final String word ) {
185
    assert word != null;
186
173187
    return !word.isBlank() && isLetter( word.charAt( 0 ) );
174188
  }
...
183197
   */
184198
  private List<SuggestItem> lookup( final String lexeme, final Verbosity v ) {
199
    assert lexeme != null;
200
    assert v != null;
201
185202
    return mSymSpell.lookup( lexeme, v );
186203
  }
M src/main/java/com/keenwrite/typesetting/Typesetter.java
33
44
import com.keenwrite.io.SysFile;
5
import com.keenwrite.preferences.Workspace;
6
import com.keenwrite.util.BoundedCache;
7
8
import java.io.*;
9
import java.nio.file.NoSuchFileException;
10
import java.nio.file.Path;
11
import java.util.ArrayList;
12
import java.util.List;
13
import java.util.Map;
14
import java.util.Scanner;
15
import java.util.concurrent.Callable;
16
import java.util.regex.Pattern;
17
18
import static com.keenwrite.constants.Constants.DEFAULT_DIRECTORY;
19
import static com.keenwrite.events.StatusEvent.clue;
20
import static com.keenwrite.preferences.WorkspaceKeys.*;
21
import static java.lang.ProcessBuilder.Redirect.DISCARD;
22
import static java.lang.String.format;
23
import static java.lang.System.currentTimeMillis;
24
import static java.lang.System.getProperty;
25
import static java.nio.file.Files.*;
26
import static java.util.Arrays.asList;
27
import static java.util.concurrent.TimeUnit.*;
28
import static org.apache.commons.io.FilenameUtils.removeExtension;
29
30
/**
31
 * Responsible for invoking an executable to typeset text. This will
32
 * construct suitable command-line arguments to invoke the typesetting engine.
33
 */
34
public class Typesetter {
35
  private static final SysFile TYPESETTER = new SysFile( "mtxrun" );
36
37
  private final Workspace mWorkspace;
38
39
  /**
40
   * Creates a new {@link Typesetter} instance capable of configuring the
41
   * typesetter used to generate a typeset document.
42
   */
43
  public Typesetter( final Workspace workspace ) {
44
    mWorkspace = workspace;
45
  }
46
47
  public static boolean canRun() {
48
    return TYPESETTER.canRun();
49
  }
50
51
  /**
52
   * This will typeset the document using a new process. The return value only
53
   * indicates whether the typesetter exists, not whether the typesetting was
54
   * successful.
55
   *
56
   * @param inputPath  The input document to typeset.
57
   * @param outputPath Path to the finished typeset document.
58
   * @throws IOException                 If the process could not be started.
59
   * @throws InterruptedException        If the process was killed.
60
   * @throws TypesetterNotFoundException When no typesetter is along the PATH.
61
   */
62
  public void typeset( final Path inputPath, final Path outputPath )
63
    throws IOException, InterruptedException, TypesetterNotFoundException {
64
    if( TYPESETTER.canRun() ) {
65
      clue( "Main.status.typeset.began", outputPath );
66
      final var task = new TypesetTask( inputPath, outputPath );
67
      final var time = currentTimeMillis();
68
      final var success = task.typeset();
69
70
      clue( "Main.status.typeset.ended." + (success ? "success" : "failure"),
71
            outputPath, since( time )
72
      );
73
    }
74
    else {
75
      throw new TypesetterNotFoundException( TYPESETTER.toString() );
76
    }
77
  }
78
79
  /**
80
   * Calculates the time that has elapsed from the current time to the
81
   * given moment in time.
82
   *
83
   * @param start The starting time, which should be before the current time.
84
   * @return A human-readable formatted time.
85
   * @see #asElapsed(long)
86
   */
87
  private static String since( final long start ) {
88
    return asElapsed( currentTimeMillis() - start );
89
  }
90
91
  /**
92
   * Converts an elapsed time to a human-readable format (hours, minutes,
93
   * seconds, and milliseconds).
94
   *
95
   * @param elapsed An elapsed time, in milliseconds.
96
   * @return Human-readable elapsed time.
97
   */
98
  private static String asElapsed( final long elapsed ) {
99
    final var hours = MILLISECONDS.toHours( elapsed );
100
    final var eHours = elapsed - HOURS.toMillis( hours );
101
    final var minutes = MILLISECONDS.toMinutes( eHours );
102
    final var eMinutes = eHours - MINUTES.toMillis( minutes );
103
    final var seconds = MILLISECONDS.toSeconds( eMinutes );
104
    final var eSeconds = eMinutes - SECONDS.toMillis( seconds );
105
    final var milliseconds = MILLISECONDS.toMillis( eSeconds );
106
107
    return format( "%02d:%02d:%02d.%03d",
108
                   hours, minutes, seconds, milliseconds );
109
  }
110
111
  /**
112
   * Launches a task to typeset a document.
113
   */
114
  private class TypesetTask implements Callable<Boolean> {
115
    private final List<String> mArgs = new ArrayList<>();
116
    private final Path mInput;
117
    private final Path mOutput;
118
119
    /**
120
     * Working directory must be set because ConTeXt cannot write the
121
     * result to an arbitrary location.
122
     */
123
    private final Path mDirectory;
124
125
    private TypesetTask( final Path input, final Path output ) {
126
      assert input != null;
127
      assert output != null;
128
129
      final var parentDir = output.getParent();
130
      mInput = input;
131
      mOutput = output;
132
      mDirectory = parentDir == null ? DEFAULT_DIRECTORY : parentDir;
133
    }
134
135
    /**
136
     * Initializes ConTeXt, which means creating the cache directory if it
137
     * doesn't already exist. The theme entry point must be named 'main.tex'.
138
     *
139
     * @return {@code true} if the cache directory exists.
140
     */
141
    private boolean reinitialize() {
142
      final var filename = mOutput.getFileName();
143
      final var themes = getThemesPath();
144
      final var theme = getThemesSelection();
145
      final var cacheExists = !isEmpty( getCacheDir().toPath() );
146
147
      // Ensure invoking multiple times will load the correct arguments.
148
      mArgs.clear();
149
      mArgs.add( TYPESETTER.getName() );
150
151
      if( cacheExists ) {
152
        mArgs.add( "--autogenerate" );
153
        mArgs.add( "--script" );
154
        mArgs.add( "mtx-context" );
155
        mArgs.add( "--batchmode" );
156
        mArgs.add( "--nonstopmode" );
157
        mArgs.add( "--purgeall" );
158
        mArgs.add( "--path='" + Path.of( themes.toString(), theme ) + "'" );
159
        mArgs.add( "--environment='main'" );
160
        mArgs.add( "--result='" + filename + "'" );
161
        mArgs.add( mInput.toString() );
162
163
        final var sb = new StringBuilder( 128 );
164
        mArgs.forEach( arg -> sb.append( arg ).append( " " ) );
165
        clue( sb.toString() );
166
      }
167
      else {
168
        mArgs.add( "--generate" );
169
      }
170
171
      return cacheExists;
172
    }
173
174
    /**
175
     * Setting {@code TEXMFCACHE} when run on a fresh system fails on the first
176
     * try. If the cache directory doesn't exist, attempt to create it, then
177
     * call ConTeXt to generate the PDF. This is brittle because if the
178
     * directory is empty, or not populated with cached data, a false positive
179
     * will be returned, resulting in no PDF being created.
180
     *
181
     * @return {@code true} if the document was typeset successfully.
182
     * @throws IOException          If the process could not be started.
183
     * @throws InterruptedException If the process was killed.
184
     */
185
    private boolean typeset() throws IOException, InterruptedException {
186
      return reinitialize() ? call() : call() && reinitialize() && call();
187
    }
188
189
    @Override
190
    public Boolean call() throws IOException, InterruptedException {
191
      final var stdout = new BoundedCache<String, String>( 150 );
192
      final var builder = new ProcessBuilder( mArgs );
193
      builder.directory( mDirectory.toFile() );
194
      builder.environment().put( "TEXMFCACHE", getCacheDir().toString() );
195
196
      // Without redirecting (or draining) stderr, the command may not
197
      // terminate successfully.
198
      builder.redirectError( DISCARD );
199
200
      final var process = builder.start();
201
      final var stream = process.getInputStream();
202
203
      // Reading from stdout allows slurping page numbers while generating.
204
      final var listener = new PaginationListener( stream, stdout );
205
      listener.start();
206
207
      // Even though the process has completed, there may be incomplete I/O.
208
      process.waitFor();
209
210
      // Allow time for any incomplete I/O to take place.
211
      process.waitFor( 1, SECONDS );
212
213
      final var exit = process.exitValue();
214
      process.destroy();
215
216
      // If there was an error, the typesetter will leave behind log, pdf, and
217
      // error files.
218
      if( exit > 0 ) {
219
        final var xmlName = mInput.getFileName().toString();
220
        final var srcName = mOutput.getFileName().toString();
221
        final var logName = newExtension( xmlName, ".log" );
222
        final var errName = newExtension( xmlName, "-error.log" );
223
        final var pdfName = newExtension( xmlName, ".pdf" );
224
        final var tuaName = newExtension( xmlName, ".tua" );
225
        final var badName = newExtension( srcName, ".log" );
226
227
        log( badName );
228
        log( logName );
229
        log( errName );
230
        log( stdout.keySet().stream().toList() );
231
232
        // Users may opt to keep these files around for debugging purposes.
233
        if( autoclean() ) {
234
          deleteIfExists( logName );
235
          deleteIfExists( errName );
236
          deleteIfExists( pdfName );
237
          deleteIfExists( badName );
238
          deleteIfExists( tuaName );
239
        }
240
      }
241
242
      // Exit value for a successful invocation of the typesetter. This value
243
      // value is returned when creating the cache on the first run as well as
244
      // creating PDFs on subsequent runs (after the cache has been created).
245
      // Users don't care about exit codes, only whether the PDF was generated.
246
      return exit == 0;
247
    }
248
249
    private Path newExtension( final String baseName, final String ext ) {
250
      return mOutput.resolveSibling( removeExtension( baseName ) + ext );
251
    }
252
253
    /**
254
     * Fires a status message for each line in the given file. The file format
255
     * is somewhat machine-readable, but no effort beyond line splitting is
256
     * made to parse the text.
257
     *
258
     * @param path Path to the file containing error messages.
259
     */
260
    private void log( final Path path ) throws IOException {
261
      if( exists( path ) ) {
262
        log( readAllLines( path ) );
263
      }
264
    }
265
266
    private void log( final List<String> lines ) {
267
      final var splits = new ArrayList<String>( lines.size() * 2 );
268
269
      for( final var line : lines ) {
270
        splits.addAll( asList( line.split( "\\\\n" ) ) );
271
      }
272
273
      clue( splits );
274
    }
275
276
    /**
277
     * Returns the location of the cache directory.
278
     *
279
     * @return A fully qualified path to the location to store temporary
280
     * files between typesetting runs.
281
     */
282
    private java.io.File getCacheDir() {
283
      final var temp = getProperty( "java.io.tmpdir" );
284
      final var cache = Path.of( temp, "luatex-cache" );
285
      return cache.toFile();
286
    }
287
288
    /**
289
     * Answers whether the given directory is empty. The typesetting software
290
     * creates a non-empty directory by default. The return value from this
291
     * method is a proxy to answering whether the typesetter has been run for
292
     * the first time or not.
293
     *
294
     * @param path The directory to check for emptiness.
295
     * @return {@code true} if the directory is empty.
296
     */
297
    private boolean isEmpty( final Path path ) {
298
      try( final var stream = newDirectoryStream( path ) ) {
299
        return !stream.iterator().hasNext();
300
      } catch( final NoSuchFileException | FileNotFoundException ex ) {
301
        // A missing directory means it doesn't exist, ergo is empty.
302
        return true;
303
      } catch( final IOException ex ) {
304
        throw new RuntimeException( ex );
305
      }
306
    }
307
  }
308
309
  /**
310
   * Responsible for parsing the output from the typesetting engine and
311
   * updating the status bar to provide assurance that typesetting is
312
   * executing.
313
   *
314
   * <p>
315
   * Example lines written to standard output:
316
   * </p>
317
   * <pre>{@code
318
   * pages           > flushing realpage 15, userpage 15, subpage 15
319
   * pages           > flushing realpage 16, userpage 16, subpage 16
320
   * pages           > flushing realpage 1, userpage 1, subpage 1
321
   * pages           > flushing realpage 2, userpage 2, subpage 2
322
   * }</pre>
323
   * <p>
324
   * The lines are parsed; the first number is displayed in a status bar
325
   * message.
326
   * </p>
327
   */
328
  private static class PaginationListener extends Thread {
329
    private static final Pattern DIGITS = Pattern.compile( "[^\\d]+" );
330
331
    private final InputStream mInputStream;
332
333
    private final Map<String, String> mCache;
334
335
    public PaginationListener(
336
      final InputStream in, final Map<String, String> cache ) {
337
      mInputStream = in;
338
      mCache = cache;
339
    }
340
341
    @Override
342
    public void run() {
343
      try( final var reader = createReader( mInputStream ) ) {
344
        int pageCount = 1;
345
        int passCount = 1;
346
        int pageTotal = 0;
347
        String line;
348
349
        while( (line = reader.readLine()) != null ) {
350
          mCache.put( line, "" );
351
352
          if( line.startsWith( "pages" ) ) {
353
            // The bottleneck will be the typesetting engine writing to stdout,
354
            // not the parsing of stdout.
355
            final var scanner = new Scanner( line ).useDelimiter( DIGITS );
356
            final var digits = scanner.next();
357
            final var page = Integer.parseInt( digits );
358
359
            // If the page number is less than the previous page count, it
360
            // means that the typesetting engine has started another pass.
361
            if( page < pageCount ) {
362
              passCount++;
363
              pageTotal = pageCount;
364
            }
365
366
            pageCount = page;
367
368
            // Inform the user of pages being typeset.
369
            clue( "Main.status.typeset.page",
370
                  pageCount, pageTotal < 1 ? "?" : pageTotal, passCount
371
            );
372
          }
373
        }
374
      } catch( final IOException ex ) {
375
        clue( ex );
376
        throw new RuntimeException( ex );
377
      }
378
    }
379
380
    private BufferedReader createReader( final InputStream inputStream ) {
381
      return new BufferedReader( new InputStreamReader( inputStream ) );
382
    }
383
  }
384
385
  private File getThemesPath() {
386
    return mWorkspace.toFile( KEY_TYPESET_CONTEXT_THEMES_PATH );
387
  }
388
389
  private String getThemesSelection() {
390
    return mWorkspace.toString( KEY_TYPESET_CONTEXT_THEME_SELECTION );
391
  }
392
393
  /**
394
   * Answers whether logs and other files should be deleted upon error. The
395
   * log files are useful for debugging.
396
   *
397
   * @return {@code true} to delete generated files.
398
   */
399
  public boolean autoclean() {
400
    return mWorkspace.toBoolean( KEY_TYPESET_CONTEXT_CLEAN );
5
import com.keenwrite.collections.BoundedCache;
6
import com.keenwrite.util.GenericBuilder;
7
8
import java.io.*;
9
import java.nio.file.NoSuchFileException;
10
import java.nio.file.Path;
11
import java.util.ArrayList;
12
import java.util.List;
13
import java.util.Map;
14
import java.util.Scanner;
15
import java.util.concurrent.Callable;
16
import java.util.regex.Pattern;
17
18
import static com.keenwrite.constants.Constants.DEFAULT_DIRECTORY;
19
import static com.keenwrite.events.StatusEvent.clue;
20
import static java.lang.ProcessBuilder.Redirect.DISCARD;
21
import static java.lang.String.format;
22
import static java.lang.System.currentTimeMillis;
23
import static java.lang.System.getProperty;
24
import static java.nio.file.Files.*;
25
import static java.util.Arrays.asList;
26
import static java.util.concurrent.TimeUnit.*;
27
import static org.apache.commons.io.FilenameUtils.removeExtension;
28
29
/**
30
 * Responsible for invoking an executable to typeset text. This will
31
 * construct suitable command-line arguments to invoke the typesetting engine.
32
 */
33
public class Typesetter {
34
  private static final SysFile TYPESETTER = new SysFile( "mtxrun" );
35
36
  private final Mutator mMutator;
37
38
  public static GenericBuilder<Mutator, Typesetter> builder() {
39
    return GenericBuilder.of( Mutator::new, Typesetter::new );
40
  }
41
42
  public static final class Mutator {
43
    private Path mInputPath;
44
    private Path mOutputPath;
45
    private Path mThemePath;
46
    private String mThemeName;
47
    private boolean mAutoclean;
48
49
    /**
50
     * @param inputPath The input document to typeset.
51
     */
52
    public void setInputPath( final Path inputPath ) {
53
      mInputPath = inputPath;
54
    }
55
56
    /**
57
     * @param outputPath Path to the finished typeset document to create.
58
     */
59
    public void setOutputPath( final Path outputPath ) {
60
      mOutputPath = outputPath;
61
    }
62
63
    /**
64
     * @param themePath Fully qualified path to the theme directory.
65
     */
66
    public void setThemePath( final Path themePath ) {
67
      mThemePath = themePath;
68
    }
69
70
    /**
71
     * @param themePath Fully qualified path to the theme directory.
72
     */
73
    public void setThemePath( final File themePath ) {
74
      setThemePath( themePath.toPath() );
75
    }
76
77
    /**
78
     * @param themeName Name of theme to apply when generating the PDF file.
79
     */
80
    public void setThemeName( final String themeName ) {
81
      mThemeName = themeName;
82
    }
83
84
    /**
85
     * @param autoclean {@code true} to remove all temporary files after
86
     *                  typesetter produces a PDF file.
87
     */
88
    public void setAutoclean( final boolean autoclean ) {
89
      mAutoclean = autoclean;
90
    }
91
  }
92
93
  public static boolean canRun() {
94
    return TYPESETTER.canRun();
95
  }
96
97
  /**
98
   * Calculates the time that has elapsed from the current time to the
99
   * given moment in time.
100
   *
101
   * @param start The starting time, which should be before the current time.
102
   * @return A human-readable formatted time.
103
   * @see #asElapsed(long)
104
   */
105
  private static String since( final long start ) {
106
    return asElapsed( currentTimeMillis() - start );
107
  }
108
109
  /**
110
   * Converts an elapsed time to a human-readable format (hours, minutes,
111
   * seconds, and milliseconds).
112
   *
113
   * @param elapsed An elapsed time, in milliseconds.
114
   * @return Human-readable elapsed time.
115
   */
116
  private static String asElapsed( final long elapsed ) {
117
    final var hours = MILLISECONDS.toHours( elapsed );
118
    final var eHours = elapsed - HOURS.toMillis( hours );
119
    final var minutes = MILLISECONDS.toMinutes( eHours );
120
    final var eMinutes = eHours - MINUTES.toMillis( minutes );
121
    final var seconds = MILLISECONDS.toSeconds( eMinutes );
122
    final var eSeconds = eMinutes - SECONDS.toMillis( seconds );
123
    final var milliseconds = MILLISECONDS.toMillis( eSeconds );
124
125
    return format( "%02d:%02d:%02d.%03d",
126
                   hours, minutes, seconds, milliseconds );
127
  }
128
129
  /**
130
   * Launches a task to typeset a document.
131
   */
132
  private class TypesetTask implements Callable<Boolean> {
133
    private final List<String> mArgs = new ArrayList<>();
134
135
    /**
136
     * Working directory must be set because ConTeXt cannot write the
137
     * result to an arbitrary location.
138
     */
139
    private final Path mDirectory;
140
141
    private TypesetTask() {
142
      final var parentDir = getOutputPath().getParent();
143
      mDirectory = parentDir == null ? DEFAULT_DIRECTORY : parentDir;
144
    }
145
146
    /**
147
     * Initializes ConTeXt, which means creating the cache directory if it
148
     * doesn't already exist. The theme entry point must be named 'main.tex'.
149
     *
150
     * @return {@code true} if the cache directory exists.
151
     */
152
    private boolean reinitialize() {
153
      final var filename = getOutputPath().getFileName();
154
      final var themes = getThemePath();
155
      final var theme = getThemeName();
156
      final var cacheExists = !isEmpty( getCacheDir().toPath() );
157
158
      // Ensure invoking multiple times will load the correct arguments.
159
      mArgs.clear();
160
      mArgs.add( TYPESETTER.getName() );
161
162
      if( cacheExists ) {
163
        mArgs.add( "--autogenerate" );
164
        mArgs.add( "--script" );
165
        mArgs.add( "mtx-context" );
166
        mArgs.add( "--batchmode" );
167
        mArgs.add( "--nonstopmode" );
168
        mArgs.add( "--purgeall" );
169
        mArgs.add( "--path='" + Path.of( themes.toString(), theme ) + "'" );
170
        mArgs.add( "--environment='main'" );
171
        mArgs.add( "--result='" + filename + "'" );
172
        mArgs.add( getInputPath().toString() );
173
174
        final var sb = new StringBuilder( 128 );
175
        mArgs.forEach( arg -> sb.append( arg ).append( " " ) );
176
        clue( sb.toString() );
177
      }
178
      else {
179
        mArgs.add( "--generate" );
180
      }
181
182
      return cacheExists;
183
    }
184
185
    /**
186
     * Setting {@code TEXMFCACHE} when run on a fresh system fails on the first
187
     * try. If the cache directory doesn't exist, attempt to create it, then
188
     * call ConTeXt to generate the PDF. This is brittle because if the
189
     * directory is empty, or not populated with cached data, a false positive
190
     * will be returned, resulting in no PDF being created.
191
     *
192
     * @return {@code true} if the document was typeset successfully.
193
     * @throws IOException          If the process could not be started.
194
     * @throws InterruptedException If the process was killed.
195
     */
196
    private boolean typeset() throws IOException, InterruptedException {
197
      return reinitialize() ? call() : call() && reinitialize() && call();
198
    }
199
200
    @Override
201
    public Boolean call() throws IOException, InterruptedException {
202
      final var stdout = new BoundedCache<String, String>( 150 );
203
      final var builder = new ProcessBuilder( mArgs );
204
      builder.directory( mDirectory.toFile() );
205
      builder.environment().put( "TEXMFCACHE", getCacheDir().toString() );
206
207
      // Without redirecting (or draining) stderr, the command may not
208
      // terminate successfully.
209
      builder.redirectError( DISCARD );
210
211
      final var process = builder.start();
212
      final var stream = process.getInputStream();
213
214
      // Reading from stdout allows slurping page numbers while generating.
215
      final var listener = new PaginationListener( stream, stdout );
216
      listener.start();
217
218
      // Even though the process has completed, there may be incomplete I/O.
219
      process.waitFor();
220
221
      // Allow time for any incomplete I/O to take place.
222
      process.waitFor( 1, SECONDS );
223
224
      final var exit = process.exitValue();
225
      process.destroy();
226
227
      // If there was an error, the typesetter will leave behind log, pdf, and
228
      // error files.
229
      if( exit > 0 ) {
230
        final var xmlName = getInputPath().getFileName().toString();
231
        final var srcName = getOutputPath().getFileName().toString();
232
        final var logName = newExtension( xmlName, ".log" );
233
        final var errName = newExtension( xmlName, "-error.log" );
234
        final var pdfName = newExtension( xmlName, ".pdf" );
235
        final var tuaName = newExtension( xmlName, ".tua" );
236
        final var badName = newExtension( srcName, ".log" );
237
238
        log( badName );
239
        log( logName );
240
        log( errName );
241
        log( stdout.keySet().stream().toList() );
242
243
        // Users may opt to keep these files around for debugging purposes.
244
        if( autoclean() ) {
245
          deleteIfExists( logName );
246
          deleteIfExists( errName );
247
          deleteIfExists( pdfName );
248
          deleteIfExists( badName );
249
          deleteIfExists( tuaName );
250
        }
251
      }
252
253
      // Exit value for a successful invocation of the typesetter. This value
254
      // value is returned when creating the cache on the first run as well as
255
      // creating PDFs on subsequent runs (after the cache has been created).
256
      // Users don't care about exit codes, only whether the PDF was generated.
257
      return exit == 0;
258
    }
259
260
    private Path newExtension( final String baseName, final String ext ) {
261
      return getOutputPath().resolveSibling( removeExtension( baseName ) + ext );
262
    }
263
264
    /**
265
     * Fires a status message for each line in the given file. The file format
266
     * is somewhat machine-readable, but no effort beyond line splitting is
267
     * made to parse the text.
268
     *
269
     * @param path Path to the file containing error messages.
270
     */
271
    private void log( final Path path ) throws IOException {
272
      if( exists( path ) ) {
273
        log( readAllLines( path ) );
274
      }
275
    }
276
277
    private void log( final List<String> lines ) {
278
      final var splits = new ArrayList<String>( lines.size() * 2 );
279
280
      for( final var line : lines ) {
281
        splits.addAll( asList( line.split( "\\\\n" ) ) );
282
      }
283
284
      clue( splits );
285
    }
286
287
    /**
288
     * Returns the location of the cache directory.
289
     *
290
     * @return A fully qualified path to the location to store temporary
291
     * files between typesetting runs.
292
     */
293
    private java.io.File getCacheDir() {
294
      final var temp = getProperty( "java.io.tmpdir" );
295
      final var cache = Path.of( temp, "luatex-cache" );
296
      return cache.toFile();
297
    }
298
299
    /**
300
     * Answers whether the given directory is empty. The typesetting software
301
     * creates a non-empty directory by default. The return value from this
302
     * method is a proxy to answering whether the typesetter has been run for
303
     * the first time or not.
304
     *
305
     * @param path The directory to check for emptiness.
306
     * @return {@code true} if the directory is empty.
307
     */
308
    private boolean isEmpty( final Path path ) {
309
      try( final var stream = newDirectoryStream( path ) ) {
310
        return !stream.iterator().hasNext();
311
      } catch( final NoSuchFileException | FileNotFoundException ex ) {
312
        // A missing directory means it doesn't exist, ergo is empty.
313
        return true;
314
      } catch( final IOException ex ) {
315
        throw new RuntimeException( ex );
316
      }
317
    }
318
  }
319
320
  /**
321
   * Responsible for parsing the output from the typesetting engine and
322
   * updating the status bar to provide assurance that typesetting is
323
   * executing.
324
   *
325
   * <p>
326
   * Example lines written to standard output:
327
   * </p>
328
   * <pre>{@code
329
   * pages           > flushing realpage 15, userpage 15, subpage 15
330
   * pages           > flushing realpage 16, userpage 16, subpage 16
331
   * pages           > flushing realpage 1, userpage 1, subpage 1
332
   * pages           > flushing realpage 2, userpage 2, subpage 2
333
   * }</pre>
334
   * <p>
335
   * The lines are parsed; the first number is displayed in a status bar
336
   * message.
337
   * </p>
338
   */
339
  private static class PaginationListener extends Thread {
340
    private static final Pattern DIGITS = Pattern.compile( "[^\\d]+" );
341
342
    private final InputStream mInputStream;
343
344
    private final Map<String, String> mCache;
345
346
    public PaginationListener(
347
      final InputStream in, final Map<String, String> cache ) {
348
      mInputStream = in;
349
      mCache = cache;
350
    }
351
352
    @Override
353
    public void run() {
354
      try( final var reader = createReader( mInputStream ) ) {
355
        int pageCount = 1;
356
        int passCount = 1;
357
        int pageTotal = 0;
358
        String line;
359
360
        while( (line = reader.readLine()) != null ) {
361
          mCache.put( line, "" );
362
363
          if( line.startsWith( "pages" ) ) {
364
            // The bottleneck will be the typesetting engine writing to stdout,
365
            // not the parsing of stdout.
366
            final var scanner = new Scanner( line ).useDelimiter( DIGITS );
367
            final var digits = scanner.next();
368
            final var page = Integer.parseInt( digits );
369
370
            // If the page number is less than the previous page count, it
371
            // means that the typesetting engine has started another pass.
372
            if( page < pageCount ) {
373
              passCount++;
374
              pageTotal = pageCount;
375
            }
376
377
            pageCount = page;
378
379
            // Inform the user of pages being typeset.
380
            clue( "Main.status.typeset.page",
381
                  pageCount, pageTotal < 1 ? "?" : pageTotal, passCount
382
            );
383
          }
384
        }
385
      } catch( final IOException ex ) {
386
        clue( ex );
387
        throw new RuntimeException( ex );
388
      }
389
    }
390
391
    private BufferedReader createReader( final InputStream inputStream ) {
392
      return new BufferedReader( new InputStreamReader( inputStream ) );
393
    }
394
  }
395
396
  /**
397
   * Creates a new {@link Typesetter} instance capable of configuring the
398
   * typesetter used to generate a typeset document.
399
   */
400
  private Typesetter( final Mutator mutator ) {
401
    assert mutator != null;
402
403
    mMutator = mutator;
404
  }
405
406
  /**
407
   * This will typeset the document using a new process. The return value only
408
   * indicates whether the typesetter exists, not whether the typesetting was
409
   * successful.
410
   *
411
   * @throws IOException                 If the process could not be started.
412
   * @throws InterruptedException        If the process was killed.
413
   * @throws TypesetterNotFoundException When no typesetter is along the PATH.
414
   */
415
  public void typeset()
416
    throws IOException, InterruptedException, TypesetterNotFoundException {
417
    if( TYPESETTER.canRun() ) {
418
      final var outputPath = getOutputPath();
419
420
      clue( "Main.status.typeset.began", outputPath );
421
      final var task = new TypesetTask();
422
      final var time = currentTimeMillis();
423
      final var success = task.typeset();
424
425
      clue( "Main.status.typeset.ended." + (success ? "success" : "failure"),
426
            outputPath, since( time )
427
      );
428
    }
429
    else {
430
      throw new TypesetterNotFoundException( TYPESETTER.toString() );
431
    }
432
  }
433
434
  private Path getInputPath() {
435
    return mMutator.mInputPath;
436
  }
437
438
  private Path getOutputPath() {
439
    return mMutator.mOutputPath;
440
  }
441
442
  private Path getThemePath() {
443
    return mMutator.mThemePath;
444
  }
445
446
  private String getThemeName() {
447
    return mMutator.mThemeName;
448
  }
449
450
  /**
451
   * Answers whether logs and other files should be deleted upon error. The
452
   * log files are useful for debugging.
453
   *
454
   * @return {@code true} to delete generated files.
455
   */
456
  public boolean autoclean() {
457
    return mMutator.mAutoclean;
401458
  }
402459
}
M src/main/java/com/keenwrite/ui/actions/Action.java
2929
  private final List<MenuAction> mSubActions = new ArrayList<>();
3030
31
  /**
32
   * Provides a fluent interface around constructing actions so that duplication
33
   * can be avoided.
34
   */
35
  public static class Builder {
36
    private String mText;
37
    private String mAccelerator;
38
    private String mIcon;
39
    private EventHandler<ActionEvent> mHandler;
40
41
    /**
42
     * Sets the text, icon, and accelerator for a given action identifier.
43
     * See the messages properties file for details.
44
     *
45
     * @param id The identifier to look up in the properties file.
46
     * @return An instance of {@link Builder} that can be built into an
47
     * instance of {@link Action}.
48
     */
49
    public Builder setId( final String id ) {
50
      final var prefix = ACTION_PREFIX + id + ".";
51
      final var text = prefix + "text";
52
      final var icon = prefix + "icon";
53
      final var accelerator = prefix + "accelerator";
54
      final var builder = setText( text ).setIcon( icon );
55
56
      return Messages.containsKey( accelerator )
57
        ? builder.setAccelerator( Messages.get( accelerator ) )
58
        : builder;
59
    }
60
61
    /**
62
     * Sets the action text based on a resource bundle key.
63
     *
64
     * @param key The key to look up in the {@link Messages}.
65
     * @return The corresponding value, or the key name if none found.
66
     */
67
    private Builder setText( final String key ) {
68
      mText = Messages.get( key, key );
69
      return this;
70
    }
71
72
    private Builder setAccelerator( final String accelerator ) {
73
      mAccelerator = accelerator;
74
      return this;
75
    }
76
77
    private Builder setIcon( final String iconKey ) {
78
      assert iconKey != null;
79
80
      // If there's no icon associated with the icon key name, don't attempt
81
      // to create a graphic for the icon, because it won't exist.
82
      final var iconName = Messages.get( iconKey );
83
      mIcon = iconKey.equals( iconName ) ? "" : iconName;
84
85
      return this;
86
    }
87
88
    public Builder setHandler( final EventHandler<ActionEvent> handler ) {
89
      mHandler = handler;
90
      return this;
91
    }
92
93
    public Action build() {
94
      return new Action( mText, mAccelerator, mIcon, mHandler );
95
    }
96
  }
97
98
  /**
99
   * TODO: Reuse the {@link GenericBuilder}.
100
   *
101
   * @return The {@link Builder} for an instance of {@link Action}.
102
   */
103
  public static Builder builder() {
104
    return new Builder();
105
  }
106
107
  private static Button createIconButton( final String icon ) {
108
    return new Button( null, createGraphic( icon ) );
109
  }
110
31111
  public Action(
32112
    final String text,
...
80160
  @Override
81161
  public Button createToolBarNode() {
82
    final var button = createIconButton();
162
    final var button = createIconButton( mIcon );
83163
    var tooltip = mText;
84164
...
100180
101181
    return button;
102
  }
103
104
  private Button createIconButton() {
105
    return new Button( null, createGraphic( mIcon ) );
106182
  }
107183
...
116192
    mSubActions.addAll( List.of( action ) );
117193
    return this;
118
  }
119
120
  /**
121
   * TODO: Reuse the {@link GenericBuilder}.
122
   *
123
   * @return The {@link Builder} for an instance of {@link Action}.
124
   */
125
  public static Builder builder() {
126
    return new Builder();
127
  }
128
129
  /**
130
   * Provides a fluent interface around constructing actions so that duplication
131
   * can be avoided.
132
   */
133
  public static class Builder {
134
    private String mText;
135
    private String mAccelerator;
136
    private String mIcon;
137
    private EventHandler<ActionEvent> mHandler;
138
139
    /**
140
     * Sets the text, icon, and accelerator for a given action identifier.
141
     * See the messages properties file for details.
142
     *
143
     * @param id The identifier to look up in the properties file.
144
     * @return An instance of {@link Builder} that can be built into an
145
     * instance of {@link Action}.
146
     */
147
    public Builder setId( final String id ) {
148
      final var prefix = ACTION_PREFIX + id + ".";
149
      final var text = prefix + "text";
150
      final var icon = prefix + "icon";
151
      final var accelerator = prefix + "accelerator";
152
      final var builder = setText( text ).setIcon( icon );
153
154
      return Messages.containsKey( accelerator )
155
        ? builder.setAccelerator( Messages.get( accelerator ) )
156
        : builder;
157
    }
158
159
    /**
160
     * Sets the action text based on a resource bundle key.
161
     *
162
     * @param key The key to look up in the {@link Messages}.
163
     * @return The corresponding value, or the key name if none found.
164
     */
165
    private Builder setText( final String key ) {
166
      mText = Messages.get( key, key );
167
      return this;
168
    }
169
170
    private Builder setAccelerator( final String accelerator ) {
171
      mAccelerator = accelerator;
172
      return this;
173
    }
174
175
    private Builder setIcon( final String iconKey ) {
176
      assert iconKey != null;
177
178
      // If there's no icon associated with the icon key name, don't attempt
179
      // to create a graphic for the icon, because it won't exist.
180
      final var iconName = Messages.get( iconKey );
181
      mIcon = iconKey.equals( iconName ) ? "" : iconName;
182
183
      return this;
184
    }
185
186
    public Builder setHandler( final EventHandler<ActionEvent> handler ) {
187
      mHandler = handler;
188
      return this;
189
    }
190
191
    public Action build() {
192
      return new Action( mText, mAccelerator, mIcon, mHandler );
193
    }
194194
  }
195195
}
M src/main/java/com/keenwrite/ui/actions/GuiCommands.java
4444
import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG_NODE;
4545
import static com.keenwrite.events.StatusEvent.clue;
46
import static com.keenwrite.preferences.WorkspaceKeys.*;
47
import static com.keenwrite.processors.ProcessorFactory.createProcessors;
48
import static com.keenwrite.ui.explorer.FilePickerFactory.Options;
49
import static com.keenwrite.ui.explorer.FilePickerFactory.Options.*;
50
import static com.keenwrite.util.FileWalker.walk;
51
import static java.nio.file.Files.readString;
52
import static java.nio.file.Files.writeString;
53
import static java.util.concurrent.Executors.newFixedThreadPool;
54
import static javafx.application.Platform.runLater;
55
import static javafx.event.Event.fireEvent;
56
import static javafx.scene.control.Alert.AlertType.INFORMATION;
57
import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST;
58
import static org.apache.commons.io.FilenameUtils.getExtension;
59
60
/**
61
 * Responsible for abstracting how functionality is mapped to the application.
62
 * This allows users to customize accelerator keys and will provide pluggable
63
 * functionality so that different text markup languages can change documents
64
 * using their respective syntax.
65
 */
66
public final class GuiCommands {
67
  private static final ExecutorService sExecutor = newFixedThreadPool( 1 );
68
69
  private static final String STYLE_SEARCH = "search";
70
71
  /**
72
   * Sci-fi genres, which are can be longer than other genres, typically fall
73
   * below 150,000 words at 6 chars per word. This reduces re-allocations of
74
   * memory when concatenating files together when exporting novels.
75
   */
76
  private static final int DOCUMENT_LENGTH = 150_000 * 6;
77
78
  /**
79
   * When an action is executed, this is one of the recipients.
80
   */
81
  private final MainPane mMainPane;
82
83
  private final MainScene mMainScene;
84
85
  private final LogView mLogView;
86
87
  /**
88
   * Tracks finding text in the active document.
89
   */
90
  private final SearchModel mSearchModel;
91
92
  public GuiCommands( final MainScene scene, final MainPane pane ) {
93
    mMainScene = scene;
94
    mMainPane = pane;
95
    mLogView = new LogView();
96
    mSearchModel = new SearchModel();
97
    mSearchModel.matchOffsetProperty().addListener( ( c, o, n ) -> {
98
      final var editor = getActiveTextEditor();
99
100
      // Clear highlighted areas before highlighting a new region.
101
      if( o != null ) {
102
        editor.unstylize( STYLE_SEARCH );
103
      }
104
105
      if( n != null ) {
106
        editor.moveTo( n.getStart() );
107
        editor.stylize( n, STYLE_SEARCH );
108
      }
109
    } );
110
111
    // When the active text editor changes, update the haystack.
112
    mMainPane.activeTextEditorProperty().addListener(
113
      ( c, o, n ) -> mSearchModel.search( getActiveTextEditor().getText() )
114
    );
115
  }
116
117
  public void file_new() {
118
    getMainPane().newTextEditor();
119
  }
120
121
  public void file_open() {
122
    pickFiles( FILE_OPEN_MULTIPLE ).ifPresent( l -> getMainPane().open( l ) );
123
  }
124
125
  public void file_close() {
126
    getMainPane().close();
127
  }
128
129
  public void file_close_all() {
130
    getMainPane().closeAll();
131
  }
132
133
  public void file_save() {
134
    getMainPane().save();
135
  }
136
137
  public void file_save_as() {
138
    pickFiles( FILE_SAVE_AS ).ifPresent( l -> getMainPane().saveAs( l ) );
139
  }
140
141
  public void file_save_all() {
142
    getMainPane().saveAll();
143
  }
144
145
  /**
146
   * Converts the actively edited file in the given file format.
147
   *
148
   * @param format The destination file format.
149
   */
150
  private void file_export( final ExportFormat format ) {
151
    file_export( format, false );
152
  }
153
154
  /**
155
   * Converts one or more files into the given file format. If {@code dir}
156
   * is set to true, this will first append all files in the same directory
157
   * as the actively edited file.
158
   *
159
   * @param format The destination file format.
160
   * @param dir    Export all files in the actively edited file's directory.
161
   */
162
  private void file_export( final ExportFormat format, final boolean dir ) {
163
    final var main = getMainPane();
164
    final var editor = main.getActiveTextEditor();
165
    final var exported = getWorkspace().fileProperty( KEY_UI_RECENT_EXPORT );
166
    final var filename = format.toExportFilename( editor.getPath() );
167
    final var selection = pickFiles(
168
      Constants.PDF_DEFAULT.getName().equals( exported.get().getName() )
169
        ? filename
170
        : exported.get(), FILE_EXPORT
171
    );
172
173
    selection.ifPresent( ( files ) -> {
174
      editor.save();
175
176
      final var file = files.get( 0 );
177
      final var path = file.toPath();
178
      final var document = dir ? append( editor ) : editor.getText();
179
      final var context = main.createProcessorContext( path, format );
180
181
      final var task = new Task<Path>() {
182
        @Override
183
        protected Path call() throws Exception {
184
          final var chain = createProcessors( context );
185
          final var export = chain.apply( document );
186
187
          // Processors can export binary files. In such cases, processors
188
          // return null to prevent further processing.
189
          return export == null ? null : writeString( path, export );
190
        }
191
      };
192
193
      task.setOnSucceeded(
194
        e -> {
195
          // Remember the exported file name for next time.
196
          exported.setValue( file );
197
198
          final var result = task.getValue();
199
200
          // Binary formats must notify users of success independently.
201
          if( result != null ) {
202
            clue( "Main.status.export.success", result );
203
          }
204
        }
205
      );
206
207
      task.setOnFailed( e -> {
208
        final var ex = task.getException();
209
        clue( ex );
210
211
        if( ex instanceof TypeNotPresentException ) {
212
          fireExportFailedEvent();
213
        }
214
      } );
215
216
      sExecutor.execute( task );
217
    } );
218
  }
219
220
  /**
221
   * @param dir {@code true} means to export all files in the active file
222
   *            editor's directory; {@code false} means to export only the
223
   *            actively edited file.
224
   */
225
  private void file_export_pdf( final boolean dir ) {
226
    final var workspace = getWorkspace();
227
    final var themes = workspace.toFile( KEY_TYPESET_CONTEXT_THEMES_PATH );
228
    final var theme = workspace.stringProperty(
229
      KEY_TYPESET_CONTEXT_THEME_SELECTION );
230
231
    if( Typesetter.canRun() ) {
232
      // If the typesetter is installed, allow the user to select a theme. If
233
      // the themes aren't installed, a status message will appear.
234
      if( ThemePicker.choose( themes, theme ) ) {
235
        file_export( APPLICATION_PDF, dir );
236
      }
237
    }
238
    else {
239
      fireExportFailedEvent();
240
    }
241
  }
242
243
  public void file_export_pdf() {
244
    file_export_pdf( false );
245
  }
246
247
  public void file_export_pdf_dir() {
248
    file_export_pdf( true );
249
  }
250
251
  public void file_export_html_svg() {
252
    file_export( HTML_TEX_SVG );
253
  }
254
255
  public void file_export_html_tex() {
256
    file_export( HTML_TEX_DELIMITED );
257
  }
258
259
  public void file_export_xhtml_tex() {
260
    file_export( XHTML_TEX );
261
  }
262
263
  public void file_export_markdown() {
264
    file_export( MARKDOWN_PLAIN );
265
  }
266
267
  private void fireExportFailedEvent() {
268
    runLater( ExportFailedEvent::fire );
269
  }
270
271
  public void file_exit() {
272
    final var window = getWindow();
273
    fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) );
274
  }
275
276
  public void edit_undo() {
277
    getActiveTextEditor().undo();
278
  }
279
280
  public void edit_redo() {
281
    getActiveTextEditor().redo();
282
  }
283
284
  public void edit_cut() {
285
    getActiveTextEditor().cut();
286
  }
287
288
  public void edit_copy() {
289
    getActiveTextEditor().copy();
290
  }
291
292
  public void edit_paste() {
293
    getActiveTextEditor().paste();
294
  }
295
296
  public void edit_select_all() {
297
    getActiveTextEditor().selectAll();
298
  }
299
300
  public void edit_find() {
301
    final var nodes = getMainScene().getStatusBar().getLeftItems();
302
303
    if( nodes.isEmpty() ) {
304
      final var searchBar = new SearchBar();
305
306
      searchBar.matchIndexProperty().bind( mSearchModel.matchIndexProperty() );
307
      searchBar.matchCountProperty().bind( mSearchModel.matchCountProperty() );
308
309
      searchBar.setOnCancelAction( ( event ) -> {
310
        final var editor = getActiveTextEditor();
311
        nodes.remove( searchBar );
312
        editor.unstylize( STYLE_SEARCH );
313
        editor.getNode().requestFocus();
314
      } );
315
316
      searchBar.addInputListener( ( c, o, n ) -> {
317
        if( n != null && !n.isEmpty() ) {
318
          mSearchModel.search( n, getActiveTextEditor().getText() );
319
        }
320
      } );
321
322
      searchBar.setOnNextAction( ( event ) -> edit_find_next() );
323
      searchBar.setOnPrevAction( ( event ) -> edit_find_prev() );
324
325
      nodes.add( searchBar );
326
      searchBar.requestFocus();
327
    }
328
    else {
329
      nodes.clear();
330
    }
331
  }
332
333
  public void edit_find_next() {
334
    mSearchModel.advance();
335
  }
336
337
  public void edit_find_prev() {
338
    mSearchModel.retreat();
339
  }
340
341
  public void edit_preferences() {
342
    try {
343
      new PreferencesController( getWorkspace() ).show();
344
    } catch( final Exception ex ) {
345
      clue( ex );
346
    }
347
  }
348
349
  public void format_bold() {
350
    getActiveTextEditor().bold();
351
  }
352
353
  public void format_italic() {
354
    getActiveTextEditor().italic();
355
  }
356
357
  public void format_monospace() {
358
    getActiveTextEditor().monospace();
359
  }
360
361
  public void format_superscript() {
362
    getActiveTextEditor().superscript();
363
  }
364
365
  public void format_subscript() {
366
    getActiveTextEditor().subscript();
367
  }
368
369
  public void format_strikethrough() {
370
    getActiveTextEditor().strikethrough();
371
  }
372
373
  public void insert_blockquote() {
374
    getActiveTextEditor().blockquote();
375
  }
376
377
  public void insert_code() {
378
    getActiveTextEditor().code();
379
  }
380
381
  public void insert_fenced_code_block() {
382
    getActiveTextEditor().fencedCodeBlock();
383
  }
384
385
  public void insert_link() {
386
    insertObject( createLinkDialog() );
387
  }
388
389
  public void insert_image() {
390
    insertObject( createImageDialog() );
391
  }
392
393
  private void insertObject( final Dialog<String> dialog ) {
394
    final var textArea = getActiveTextEditor().getTextArea();
395
    dialog.showAndWait().ifPresent( textArea::replaceSelection );
396
  }
397
398
  private Dialog<String> createLinkDialog() {
399
    return new LinkDialog( getWindow(), createHyperlinkModel() );
400
  }
401
402
  private Dialog<String> createImageDialog() {
403
    final var path = getActiveTextEditor().getPath();
404
    final var parentDir = path.getParent();
405
    return new ImageDialog( getWindow(), parentDir );
406
  }
407
408
  /**
409
   * Returns one of: selected text, word under cursor, or parsed hyperlink from
410
   * the Markdown AST.
411
   *
412
   * @return An instance containing the link URL and display text.
413
   */
414
  private HyperlinkModel createHyperlinkModel() {
415
    final var context = getMainPane().createProcessorContext();
416
    final var editor = getActiveTextEditor();
417
    final var textArea = editor.getTextArea();
418
    final var selectedText = textArea.getSelectedText();
419
420
    // Convert current paragraph to Markdown nodes.
421
    final var mp = MarkdownProcessor.create( context );
422
    final var p = textArea.getCurrentParagraph();
423
    final var paragraph = textArea.getText( p );
424
    final var node = mp.toNode( paragraph );
425
    final var visitor = new LinkVisitor( textArea.getCaretColumn() );
426
    final var link = visitor.process( node );
427
428
    if( link != null ) {
429
      textArea.selectRange( p, link.getStartOffset(), p, link.getEndOffset() );
430
    }
431
432
    return createHyperlinkModel( link, selectedText );
433
  }
434
435
  private HyperlinkModel createHyperlinkModel(
436
    final Link link, final String selection ) {
437
438
    return link == null
439
      ? new HyperlinkModel( selection, "https://localhost" )
440
      : new HyperlinkModel( link );
441
  }
442
443
  public void insert_heading_1() {
444
    insert_heading( 1 );
445
  }
446
447
  public void insert_heading_2() {
448
    insert_heading( 2 );
449
  }
450
451
  public void insert_heading_3() {
452
    insert_heading( 3 );
453
  }
454
455
  private void insert_heading( final int level ) {
456
    getActiveTextEditor().heading( level );
457
  }
458
459
  public void insert_unordered_list() {
460
    getActiveTextEditor().unorderedList();
461
  }
462
463
  public void insert_ordered_list() {
464
    getActiveTextEditor().orderedList();
465
  }
466
467
  public void insert_horizontal_rule() {
468
    getActiveTextEditor().horizontalRule();
469
  }
470
471
  public void definition_create() {
472
    getActiveTextDefinition().createDefinition();
473
  }
474
475
  public void definition_rename() {
476
    getActiveTextDefinition().renameDefinition();
477
  }
478
479
  public void definition_delete() {
480
    getActiveTextDefinition().deleteDefinitions();
481
  }
482
483
  public void definition_autoinsert() {
484
    getMainPane().autoinsert();
485
  }
486
487
  public void view_refresh() {
488
    getMainPane().viewRefresh();
489
  }
490
491
  public void view_preview() {
492
    getMainPane().viewPreview();
493
  }
494
495
  public void view_outline() {
496
    getMainPane().viewOutline();
497
  }
498
499
  public void view_files() {getMainPane().viewFiles();}
500
501
  public void view_statistics() {
502
    getMainPane().viewStatistics();
503
  }
504
505
  public void view_menubar() {
506
    getMainScene().toggleMenuBar();
507
  }
508
509
  public void view_toolbar() {
510
    getMainScene().toggleToolBar();
511
  }
512
513
  public void view_statusbar() {
514
    getMainScene().toggleStatusBar();
515
  }
516
517
  public void view_log() {
518
    mLogView.view();
519
  }
520
521
  public void help_about() {
522
    final var alert = new Alert( INFORMATION );
523
    final var prefix = "Dialog.about.";
524
    alert.setTitle( get( prefix + "title", APP_TITLE ) );
525
    alert.setHeaderText( get( prefix + "header", APP_TITLE ) );
526
    alert.setContentText( get( prefix + "content", APP_YEAR, APP_VERSION ) );
527
    alert.setGraphic( ICON_DIALOG_NODE );
528
    alert.initOwner( getWindow() );
529
    alert.showAndWait();
530
  }
531
532
  /**
533
   * Concatenates all the files in the same directory as the given file into
534
   * a string. The extension is determined by the given file name pattern; the
535
   * order files are concatenated is based on their numeric sort order (this
536
   * avoids lexicographic sorting).
537
   * <p>
538
   * If the parent path to the file being edited in the text editor cannot
539
   * be found then this will return the editor's text, without iterating through
540
   * the parent directory. (Should never happen, but who knows?)
541
   * </p>
542
   * <p>
543
   * New lines are automatically appended to separate each file.
544
   * </p>
545
   *
546
   * @param editor The text editor containing
547
   * @return All files in the same directory as the file being edited
548
   * concatenated into a single string.
549
   */
550
  private String append( final TextEditor editor ) {
551
    final var pattern = editor.getPath();
552
    final var parent = pattern.getParent();
553
554
    // Short-circuit because nothing else can be done.
555
    if( parent == null ) {
556
      clue( "Main.status.export.concat.parent", pattern );
557
      return editor.getText();
558
    }
559
560
    final var filename = pattern.getFileName().toString();
561
    final var extension = getExtension( filename );
562
563
    if( extension.isBlank() ) {
564
      clue( "Main.status.export.concat.extension", filename );
565
      return editor.getText();
566
    }
567
568
    try {
569
      final var glob = "**/*." + extension;
570
      final ArrayList<Path> files = new ArrayList<>();
571
      walk( parent, glob, files::add );
572
      files.sort( new AlphanumComparator<>() );
573
574
      final var text = new StringBuilder( DOCUMENT_LENGTH );
575
576
      files.forEach( ( file ) -> {
577
        try {
578
          clue( "Main.status.export.concat", file );
579
          text.append( readString( file ) );
580
        } catch( final IOException ex ) {
581
          clue( "Main.status.export.concat.io", file );
582
        }
583
      } );
584
585
      return text.toString();
586
    } catch( final Throwable t ) {
587
      clue( t );
588
      return editor.getText();
589
    }
590
  }
591
592
  private Optional<List<File>> pickFiles( final Options... options ) {
593
    return createPicker( options ).choose();
594
  }
595
596
  private Optional<List<File>> pickFiles(
597
    final File filename, final Options... options ) {
598
    final var picker = createPicker( options );
599
    picker.setInitialFilename( filename );
600
    return picker.choose();
601
  }
602
603
  private FilePicker createPicker( final Options... options ) {
604
    final var factory = new FilePickerFactory( getWorkspace() );
605
    return factory.createModal( getWindow(), options );
606
  }
607
608
  private TextEditor getActiveTextEditor() {
609
    return getMainPane().getActiveTextEditor();
610
  }
611
612
  private TextDefinition getActiveTextDefinition() {
613
    return getMainPane().getActiveTextDefinition();
46
import static com.keenwrite.preferences.AppKeys.*;
47
import static com.keenwrite.processors.ProcessorFactory.createProcessors;
48
import static com.keenwrite.ui.explorer.FilePickerFactory.Options;
49
import static com.keenwrite.ui.explorer.FilePickerFactory.Options.*;
50
import static com.keenwrite.util.FileWalker.walk;
51
import static java.nio.file.Files.readString;
52
import static java.nio.file.Files.writeString;
53
import static java.util.concurrent.Executors.newFixedThreadPool;
54
import static javafx.application.Platform.runLater;
55
import static javafx.event.Event.fireEvent;
56
import static javafx.scene.control.Alert.AlertType.INFORMATION;
57
import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST;
58
import static org.apache.commons.io.FilenameUtils.getExtension;
59
60
/**
61
 * Responsible for abstracting how functionality is mapped to the application.
62
 * This allows users to customize accelerator keys and will provide pluggable
63
 * functionality so that different text markup languages can change documents
64
 * using their respective syntax.
65
 */
66
public final class GuiCommands {
67
  private static final ExecutorService sExecutor = newFixedThreadPool( 1 );
68
69
  private static final String STYLE_SEARCH = "search";
70
71
  /**
72
   * Sci-fi genres, which are can be longer than other genres, typically fall
73
   * below 150,000 words at 6 chars per word. This reduces re-allocations of
74
   * memory when concatenating files together when exporting novels.
75
   */
76
  private static final int DOCUMENT_LENGTH = 150_000 * 6;
77
78
  /**
79
   * When an action is executed, this is one of the recipients.
80
   */
81
  private final MainPane mMainPane;
82
83
  private final MainScene mMainScene;
84
85
  private final LogView mLogView;
86
87
  /**
88
   * Tracks finding text in the active document.
89
   */
90
  private final SearchModel mSearchModel;
91
92
  public GuiCommands( final MainScene scene, final MainPane pane ) {
93
    mMainScene = scene;
94
    mMainPane = pane;
95
    mLogView = new LogView();
96
    mSearchModel = new SearchModel();
97
    mSearchModel.matchOffsetProperty().addListener( ( c, o, n ) -> {
98
      final var editor = getActiveTextEditor();
99
100
      // Clear highlighted areas before highlighting a new region.
101
      if( o != null ) {
102
        editor.unstylize( STYLE_SEARCH );
103
      }
104
105
      if( n != null ) {
106
        editor.moveTo( n.getStart() );
107
        editor.stylize( n, STYLE_SEARCH );
108
      }
109
    } );
110
111
    // When the active text editor changes, update the haystack.
112
    mMainPane.textEditorProperty().addListener(
113
      ( c, o, n ) -> mSearchModel.search( getActiveTextEditor().getText() )
114
    );
115
  }
116
117
  public void file_new() {
118
    getMainPane().newTextEditor();
119
  }
120
121
  public void file_open() {
122
    pickFiles( FILE_OPEN_MULTIPLE ).ifPresent( l -> getMainPane().open( l ) );
123
  }
124
125
  public void file_close() {
126
    getMainPane().close();
127
  }
128
129
  public void file_close_all() {
130
    getMainPane().closeAll();
131
  }
132
133
  public void file_save() {
134
    getMainPane().save();
135
  }
136
137
  public void file_save_as() {
138
    pickFiles( FILE_SAVE_AS ).ifPresent( l -> getMainPane().saveAs( l ) );
139
  }
140
141
  public void file_save_all() {
142
    getMainPane().saveAll();
143
  }
144
145
  /**
146
   * Converts the actively edited file in the given file format.
147
   *
148
   * @param format The destination file format.
149
   */
150
  private void file_export( final ExportFormat format ) {
151
    file_export( format, false );
152
  }
153
154
  /**
155
   * Converts one or more files into the given file format. If {@code dir}
156
   * is set to true, this will first append all files in the same directory
157
   * as the actively edited file.
158
   *
159
   * @param format The destination file format.
160
   * @param dir    Export all files in the actively edited file's directory.
161
   */
162
  private void file_export( final ExportFormat format, final boolean dir ) {
163
    final var main = getMainPane();
164
    final var editor = main.getTextEditor();
165
    final var exported = getWorkspace().fileProperty( KEY_UI_RECENT_EXPORT );
166
    final var filename = format.toExportFilename( editor.getPath() );
167
    final var selection = pickFiles(
168
      Constants.PDF_DEFAULT.getName().equals( exported.get().getName() )
169
        ? filename
170
        : exported.get(), FILE_EXPORT
171
    );
172
173
    selection.ifPresent( ( files ) -> {
174
      editor.save();
175
176
      final var file = files.get( 0 );
177
      final var path = file.toPath();
178
      final var document = dir ? append( editor ) : editor.getText();
179
      final var context = main.createProcessorContext( path, format );
180
181
      final var task = new Task<Path>() {
182
        @Override
183
        protected Path call() throws Exception {
184
          final var chain = createProcessors( context );
185
          final var export = chain.apply( document );
186
187
          // Processors can export binary files. In such cases, processors
188
          // return null to prevent further processing.
189
          return export == null ? null : writeString( path, export );
190
        }
191
      };
192
193
      task.setOnSucceeded(
194
        e -> {
195
          // Remember the exported file name for next time.
196
          exported.setValue( file );
197
198
          final var result = task.getValue();
199
200
          // Binary formats must notify users of success independently.
201
          if( result != null ) {
202
            clue( "Main.status.export.success", result );
203
          }
204
        }
205
      );
206
207
      task.setOnFailed( e -> {
208
        final var ex = task.getException();
209
        clue( ex );
210
211
        if( ex instanceof TypeNotPresentException ) {
212
          fireExportFailedEvent();
213
        }
214
      } );
215
216
      sExecutor.execute( task );
217
    } );
218
  }
219
220
  /**
221
   * @param dir {@code true} means to export all files in the active file
222
   *            editor's directory; {@code false} means to export only the
223
   *            actively edited file.
224
   */
225
  private void file_export_pdf( final boolean dir ) {
226
    final var workspace = getWorkspace();
227
    final var themes = workspace.getFile( KEY_TYPESET_CONTEXT_THEMES_PATH );
228
    final var theme = workspace.stringProperty(
229
      KEY_TYPESET_CONTEXT_THEME_SELECTION );
230
231
    if( Typesetter.canRun() ) {
232
      // If the typesetter is installed, allow the user to select a theme. If
233
      // the themes aren't installed, a status message will appear.
234
      if( ThemePicker.choose( themes, theme ) ) {
235
        file_export( APPLICATION_PDF, dir );
236
      }
237
    }
238
    else {
239
      fireExportFailedEvent();
240
    }
241
  }
242
243
  public void file_export_pdf() {
244
    file_export_pdf( false );
245
  }
246
247
  public void file_export_pdf_dir() {
248
    file_export_pdf( true );
249
  }
250
251
  public void file_export_html_svg() {
252
    file_export( HTML_TEX_SVG );
253
  }
254
255
  public void file_export_html_tex() {
256
    file_export( HTML_TEX_DELIMITED );
257
  }
258
259
  public void file_export_xhtml_tex() {
260
    file_export( XHTML_TEX );
261
  }
262
263
  public void file_export_markdown() {
264
    file_export( MARKDOWN_PLAIN );
265
  }
266
267
  private void fireExportFailedEvent() {
268
    runLater( ExportFailedEvent::fire );
269
  }
270
271
  public void file_exit() {
272
    final var window = getWindow();
273
    fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) );
274
  }
275
276
  public void edit_undo() {
277
    getActiveTextEditor().undo();
278
  }
279
280
  public void edit_redo() {
281
    getActiveTextEditor().redo();
282
  }
283
284
  public void edit_cut() {
285
    getActiveTextEditor().cut();
286
  }
287
288
  public void edit_copy() {
289
    getActiveTextEditor().copy();
290
  }
291
292
  public void edit_paste() {
293
    getActiveTextEditor().paste();
294
  }
295
296
  public void edit_select_all() {
297
    getActiveTextEditor().selectAll();
298
  }
299
300
  public void edit_find() {
301
    final var nodes = getMainScene().getStatusBar().getLeftItems();
302
303
    if( nodes.isEmpty() ) {
304
      final var searchBar = new SearchBar();
305
306
      searchBar.matchIndexProperty().bind( mSearchModel.matchIndexProperty() );
307
      searchBar.matchCountProperty().bind( mSearchModel.matchCountProperty() );
308
309
      searchBar.setOnCancelAction( ( event ) -> {
310
        final var editor = getActiveTextEditor();
311
        nodes.remove( searchBar );
312
        editor.unstylize( STYLE_SEARCH );
313
        editor.getNode().requestFocus();
314
      } );
315
316
      searchBar.addInputListener( ( c, o, n ) -> {
317
        if( n != null && !n.isEmpty() ) {
318
          mSearchModel.search( n, getActiveTextEditor().getText() );
319
        }
320
      } );
321
322
      searchBar.setOnNextAction( ( event ) -> edit_find_next() );
323
      searchBar.setOnPrevAction( ( event ) -> edit_find_prev() );
324
325
      nodes.add( searchBar );
326
      searchBar.requestFocus();
327
    }
328
    else {
329
      nodes.clear();
330
    }
331
  }
332
333
  public void edit_find_next() {
334
    mSearchModel.advance();
335
  }
336
337
  public void edit_find_prev() {
338
    mSearchModel.retreat();
339
  }
340
341
  public void edit_preferences() {
342
    try {
343
      new PreferencesController( getWorkspace() ).show();
344
    } catch( final Exception ex ) {
345
      clue( ex );
346
    }
347
  }
348
349
  public void format_bold() {
350
    getActiveTextEditor().bold();
351
  }
352
353
  public void format_italic() {
354
    getActiveTextEditor().italic();
355
  }
356
357
  public void format_monospace() {
358
    getActiveTextEditor().monospace();
359
  }
360
361
  public void format_superscript() {
362
    getActiveTextEditor().superscript();
363
  }
364
365
  public void format_subscript() {
366
    getActiveTextEditor().subscript();
367
  }
368
369
  public void format_strikethrough() {
370
    getActiveTextEditor().strikethrough();
371
  }
372
373
  public void insert_blockquote() {
374
    getActiveTextEditor().blockquote();
375
  }
376
377
  public void insert_code() {
378
    getActiveTextEditor().code();
379
  }
380
381
  public void insert_fenced_code_block() {
382
    getActiveTextEditor().fencedCodeBlock();
383
  }
384
385
  public void insert_link() {
386
    insertObject( createLinkDialog() );
387
  }
388
389
  public void insert_image() {
390
    insertObject( createImageDialog() );
391
  }
392
393
  private void insertObject( final Dialog<String> dialog ) {
394
    final var textArea = getActiveTextEditor().getTextArea();
395
    dialog.showAndWait().ifPresent( textArea::replaceSelection );
396
  }
397
398
  private Dialog<String> createLinkDialog() {
399
    return new LinkDialog( getWindow(), createHyperlinkModel() );
400
  }
401
402
  private Dialog<String> createImageDialog() {
403
    final var path = getActiveTextEditor().getPath();
404
    final var parentDir = path.getParent();
405
    return new ImageDialog( getWindow(), parentDir );
406
  }
407
408
  /**
409
   * Returns one of: selected text, word under cursor, or parsed hyperlink from
410
   * the Markdown AST.
411
   *
412
   * @return An instance containing the link URL and display text.
413
   */
414
  private HyperlinkModel createHyperlinkModel() {
415
    final var context = getMainPane().createProcessorContext();
416
    final var editor = getActiveTextEditor();
417
    final var textArea = editor.getTextArea();
418
    final var selectedText = textArea.getSelectedText();
419
420
    // Convert current paragraph to Markdown nodes.
421
    final var mp = MarkdownProcessor.create( context );
422
    final var p = textArea.getCurrentParagraph();
423
    final var paragraph = textArea.getText( p );
424
    final var node = mp.toNode( paragraph );
425
    final var visitor = new LinkVisitor( textArea.getCaretColumn() );
426
    final var link = visitor.process( node );
427
428
    if( link != null ) {
429
      textArea.selectRange( p, link.getStartOffset(), p, link.getEndOffset() );
430
    }
431
432
    return createHyperlinkModel( link, selectedText );
433
  }
434
435
  private HyperlinkModel createHyperlinkModel(
436
    final Link link, final String selection ) {
437
438
    return link == null
439
      ? new HyperlinkModel( selection, "https://localhost" )
440
      : new HyperlinkModel( link );
441
  }
442
443
  public void insert_heading_1() {
444
    insert_heading( 1 );
445
  }
446
447
  public void insert_heading_2() {
448
    insert_heading( 2 );
449
  }
450
451
  public void insert_heading_3() {
452
    insert_heading( 3 );
453
  }
454
455
  private void insert_heading( final int level ) {
456
    getActiveTextEditor().heading( level );
457
  }
458
459
  public void insert_unordered_list() {
460
    getActiveTextEditor().unorderedList();
461
  }
462
463
  public void insert_ordered_list() {
464
    getActiveTextEditor().orderedList();
465
  }
466
467
  public void insert_horizontal_rule() {
468
    getActiveTextEditor().horizontalRule();
469
  }
470
471
  public void definition_create() {
472
    getActiveTextDefinition().createDefinition();
473
  }
474
475
  public void definition_rename() {
476
    getActiveTextDefinition().renameDefinition();
477
  }
478
479
  public void definition_delete() {
480
    getActiveTextDefinition().deleteDefinitions();
481
  }
482
483
  public void definition_autoinsert() {
484
    getMainPane().autoinsert();
485
  }
486
487
  public void view_refresh() {
488
    getMainPane().viewRefresh();
489
  }
490
491
  public void view_preview() {
492
    getMainPane().viewPreview();
493
  }
494
495
  public void view_outline() {
496
    getMainPane().viewOutline();
497
  }
498
499
  public void view_files() {getMainPane().viewFiles();}
500
501
  public void view_statistics() {
502
    getMainPane().viewStatistics();
503
  }
504
505
  public void view_menubar() {
506
    getMainScene().toggleMenuBar();
507
  }
508
509
  public void view_toolbar() {
510
    getMainScene().toggleToolBar();
511
  }
512
513
  public void view_statusbar() {
514
    getMainScene().toggleStatusBar();
515
  }
516
517
  public void view_log() {
518
    mLogView.view();
519
  }
520
521
  public void help_about() {
522
    final var alert = new Alert( INFORMATION );
523
    final var prefix = "Dialog.about.";
524
    alert.setTitle( get( prefix + "title", APP_TITLE ) );
525
    alert.setHeaderText( get( prefix + "header", APP_TITLE ) );
526
    alert.setContentText( get( prefix + "content", APP_YEAR, APP_VERSION ) );
527
    alert.setGraphic( ICON_DIALOG_NODE );
528
    alert.initOwner( getWindow() );
529
    alert.showAndWait();
530
  }
531
532
  /**
533
   * Concatenates all the files in the same directory as the given file into
534
   * a string. The extension is determined by the given file name pattern; the
535
   * order files are concatenated is based on their numeric sort order (this
536
   * avoids lexicographic sorting).
537
   * <p>
538
   * If the parent path to the file being edited in the text editor cannot
539
   * be found then this will return the editor's text, without iterating through
540
   * the parent directory. (Should never happen, but who knows?)
541
   * </p>
542
   * <p>
543
   * New lines are automatically appended to separate each file.
544
   * </p>
545
   *
546
   * @param editor The text editor containing
547
   * @return All files in the same directory as the file being edited
548
   * concatenated into a single string.
549
   */
550
  private String append( final TextEditor editor ) {
551
    final var pattern = editor.getPath();
552
    final var parent = pattern.getParent();
553
554
    // Short-circuit because nothing else can be done.
555
    if( parent == null ) {
556
      clue( "Main.status.export.concat.parent", pattern );
557
      return editor.getText();
558
    }
559
560
    final var filename = pattern.getFileName().toString();
561
    final var extension = getExtension( filename );
562
563
    if( extension.isBlank() ) {
564
      clue( "Main.status.export.concat.extension", filename );
565
      return editor.getText();
566
    }
567
568
    try {
569
      final var glob = "**/*." + extension;
570
      final ArrayList<Path> files = new ArrayList<>();
571
      walk( parent, glob, files::add );
572
      files.sort( new AlphanumComparator<>() );
573
574
      final var text = new StringBuilder( DOCUMENT_LENGTH );
575
576
      files.forEach( file -> {
577
        try {
578
          clue( "Main.status.export.concat", file );
579
          text.append( readString( file ) );
580
        } catch( final IOException ex ) {
581
          clue( "Main.status.export.concat.io", file );
582
        }
583
      } );
584
585
      return text.toString();
586
    } catch( final Throwable t ) {
587
      clue( t );
588
      return editor.getText();
589
    }
590
  }
591
592
  private Optional<List<File>> pickFiles( final Options... options ) {
593
    return createPicker( options ).choose();
594
  }
595
596
  private Optional<List<File>> pickFiles(
597
    final File filename, final Options... options ) {
598
    final var picker = createPicker( options );
599
    picker.setInitialFilename( filename );
600
    return picker.choose();
601
  }
602
603
  private FilePicker createPicker( final Options... options ) {
604
    final var factory = new FilePickerFactory( getWorkspace() );
605
    return factory.createModal( getWindow(), options );
606
  }
607
608
  private TextEditor getActiveTextEditor() {
609
    return getMainPane().getTextEditor();
610
  }
611
612
  private TextDefinition getActiveTextDefinition() {
613
    return getMainPane().getTextDefinition();
614614
  }
615615
A src/main/java/com/keenwrite/ui/common/CellEditor.java
1
package com.keenwrite.ui.common;
2
3
import javafx.beans.property.ObjectProperty;
4
import javafx.beans.property.Property;
5
import javafx.beans.property.SimpleStringProperty;
6
import javafx.beans.value.ChangeListener;
7
import javafx.beans.value.ObservableValue;
8
import javafx.event.EventHandler;
9
import javafx.scene.Node;
10
import javafx.scene.control.TableCell;
11
import javafx.scene.control.TextField;
12
import javafx.scene.control.TreeCell;
13
import javafx.scene.input.KeyEvent;
14
15
import java.util.function.Consumer;
16
17
import static javafx.application.Platform.runLater;
18
import static javafx.scene.input.KeyCode.ENTER;
19
import static javafx.scene.input.KeyCode.TAB;
20
import static javafx.scene.input.KeyEvent.KEY_RELEASED;
21
22
public class CellEditor {
23
  private FocusListener mFocusListener;
24
  private final KeyHandler mKeyHandler = new KeyHandler();
25
  private final Property<String> mInputText = new SimpleStringProperty();
26
  private final Consumer<String> mConsumer;
27
28
  /**
29
   * Responsible for accepting the text when users press the Enter or Tab key.
30
   */
31
  private class KeyHandler implements EventHandler<KeyEvent> {
32
    @Override
33
    public void handle( final KeyEvent event ) {
34
      if( event.getCode() == ENTER || event.getCode() == TAB ) {
35
        commitEdit();
36
        event.consume();
37
      }
38
    }
39
  }
40
41
  /**
42
   * Responsible for committing edits when focus is lost. This will also
43
   * deselect the input field when focus is gained so that typing text won't
44
   * overwrite the entire existing text.
45
   */
46
  private class FocusListener implements ChangeListener<Boolean> {
47
    private final TextField mInput;
48
49
    private FocusListener( final TextField input ) {
50
      mInput = input;
51
    }
52
53
    @Override
54
    public void changed(
55
      final ObservableValue<? extends Boolean> c,
56
      final Boolean endedFocus, final Boolean beganFocus ) {
57
58
      if( beganFocus ) {
59
        runLater( mInput::deselect );
60
      }
61
      else if( endedFocus ) {
62
        commitEdit();
63
      }
64
    }
65
  }
66
67
  /**
68
   * Generalized cell editor suitable for use with {@link TableCell} or
69
   * {@link TreeCell} instances.
70
   *
71
   * @param consumer        Converts the field input text to the required
72
   *                        data type.
73
   * @param graphicProperty Defines the graphical user input field.
74
   */
75
  public CellEditor(
76
    final Consumer<String> consumer,
77
    final ObjectProperty<Node> graphicProperty ) {
78
    assert consumer != null;
79
    mConsumer = consumer;
80
81
    init( graphicProperty );
82
  }
83
84
  private void init( final ObjectProperty<Node> graphicProperty ) {
85
    // When the text field is added as the graphics context, we hook into
86
    // the changed value to get a handle on the text field. From there it is
87
    // possible to add change the keyboard and focus behaviours.
88
    graphicProperty.addListener( ( c, o, n ) -> {
89
      if( o instanceof TextField ) {
90
        o.removeEventHandler( KEY_RELEASED, mKeyHandler );
91
        o.focusedProperty().removeListener( mFocusListener );
92
      }
93
94
      if( n instanceof final TextField input ) {
95
        n.addEventFilter( KEY_RELEASED, mKeyHandler );
96
        mInputText.bind( input.textProperty() );
97
        mFocusListener = new FocusListener( input );
98
        n.focusedProperty().addListener( mFocusListener );
99
      }
100
    } );
101
  }
102
103
  private void commitEdit() {
104
    mConsumer.accept( mInputText.getValue() );
105
  }
106
}
1107
M src/main/java/com/keenwrite/ui/explorer/FilePickerFactory.java
2222
import static com.keenwrite.constants.Constants.USER_DIRECTORY;
2323
import static com.keenwrite.events.StatusEvent.clue;
24
import static com.keenwrite.preferences.WorkspaceKeys.KEY_UI_RECENT_DIR;
24
import static com.keenwrite.preferences.AppKeys.KEY_UI_RECENT_DIR;
2525
import static java.nio.file.FileSystems.getDefault;
2626
import static java.util.Optional.ofNullable;
M src/main/java/com/keenwrite/ui/fonts/IconFactory.java
4949
5050
  /**
51
   * Prevent instantiation. Use the {@link #createGraphic(String)} method to
52
   * create an icon for display.
53
   */
54
  private IconFactory() {}
55
56
  /**
5751
   * Create a {@link Node} representation for the given icon name.
5852
   *
...
190184
    return createGraphic( valueOf( icon.toUpperCase() ) );
191185
  }
186
187
  /**
188
   * Prevent instantiation. Use the {@link #createGraphic(String)} method to
189
   * create an icon for display.
190
   */
191
  private IconFactory() {}
192192
}
193193
M src/main/java/com/keenwrite/ui/heuristics/DocumentStatistics.java
1818
import static com.keenwrite.events.Bus.register;
1919
import static com.keenwrite.events.StatusEvent.clue;
20
import static com.keenwrite.preferences.WorkspaceKeys.KEY_LANGUAGE_LOCALE;
21
import static com.keenwrite.preferences.WorkspaceKeys.KEY_UI_FONT_EDITOR_NAME;
20
import static com.keenwrite.preferences.AppKeys.KEY_LANGUAGE_LOCALE;
21
import static com.keenwrite.preferences.AppKeys.KEY_UI_FONT_EDITOR_NAME;
2222
import static com.keenwrite.ui.heuristics.DocumentStatistics.StatEntry;
2323
import static java.lang.String.format;
A src/main/java/com/keenwrite/ui/table/AltTableCell.java
1
package com.keenwrite.ui.table;
2
3
import com.keenwrite.ui.common.CellEditor;
4
import javafx.scene.control.cell.TextFieldTableCell;
5
import javafx.util.StringConverter;
6
7
public class AltTableCell<S, T> extends TextFieldTableCell<S, T> {
8
  public AltTableCell( final StringConverter<T> converter ) {
9
    super( converter );
10
11
    assert converter != null;
12
13
    new CellEditor(
14
      input -> commitEdit( getConverter().fromString( input ) ),
15
      graphicProperty()
16
    );
17
  }
18
}
119
M src/main/java/com/keenwrite/ui/tree/AltTreeCell.java
22
package com.keenwrite.ui.tree;
33
4
import javafx.beans.property.Property;
5
import javafx.beans.property.SimpleStringProperty;
6
import javafx.beans.value.ChangeListener;
7
import javafx.beans.value.ObservableValue;
8
import javafx.event.EventHandler;
9
import javafx.scene.control.TextField;
4
import com.keenwrite.ui.common.CellEditor;
105
import javafx.scene.control.cell.TextFieldTreeCell;
11
import javafx.scene.input.KeyEvent;
126
import javafx.util.StringConverter;
13
14
import static javafx.application.Platform.runLater;
15
import static javafx.scene.input.KeyCode.ENTER;
16
import static javafx.scene.input.KeyCode.TAB;
17
import static javafx.scene.input.KeyEvent.KEY_RELEASED;
187
198
/**
209
 * Responsible for enhancing the existing cell behaviour with fairly common
2110
 * functionality, including commit on focus loss and Enter to commit.
2211
 *
2312
 * @param <T> The type of data stored by the tree.
2413
 */
2514
public class AltTreeCell<T> extends TextFieldTreeCell<T> {
26
  private final KeyHandler mKeyHandler = new KeyHandler();
27
  private final Property<String> mInputText = new SimpleStringProperty();
28
  private FocusListener mFocusListener;
29
3015
  public AltTreeCell( final StringConverter<T> converter ) {
3116
    super( converter );
32
    assert converter != null;
33
34
    // When the text field is added as the graphics context, we hook into
35
    // the changed value to get a handle on the text field. From there it is
36
    // possible to add change the keyboard and focus behaviours.
37
    graphicProperty().addListener( ( c, o, n ) -> {
38
      if( o instanceof TextField ) {
39
        o.removeEventHandler( KEY_RELEASED, mKeyHandler );
40
        o.focusedProperty().removeListener( mFocusListener );
41
      }
42
43
      if( n instanceof final TextField input ) {
44
        n.addEventFilter( KEY_RELEASED, mKeyHandler );
45
        mInputText.bind( input.textProperty() );
46
        mFocusListener = new FocusListener( input );
47
        n.focusedProperty().addListener( mFocusListener );
48
      }
49
    } );
50
  }
51
52
  private void commitEdit() {
53
    commitEdit( getConverter().fromString( mInputText.getValue() ) );
54
  }
55
56
  /**
57
   * Responsible for accepting the text when users press the Enter or Tab key.
58
   */
59
  private class KeyHandler implements EventHandler<KeyEvent> {
60
    @Override
61
    public void handle( final KeyEvent event ) {
62
      if( event.getCode() == ENTER || event.getCode() == TAB ) {
63
        commitEdit();
64
        event.consume();
65
      }
66
    }
67
  }
68
69
  /**
70
   * Responsible for committing edits when focus is lost. This will also
71
   * deselect the input field when focus is gained so that typing text won't
72
   * overwrite the entire existing text.
73
   */
74
  private class FocusListener implements ChangeListener<Boolean> {
75
    private final TextField mInput;
76
77
    private FocusListener( final TextField input ) {
78
      mInput = input;
79
    }
8017
81
    @Override
82
    public void changed(
83
      final ObservableValue<? extends Boolean> c,
84
      final Boolean endedFocus, final Boolean beganFocus ) {
18
    assert converter != null;
8519
86
      if( beganFocus ) {
87
        runLater( mInput::deselect );
88
      }
89
      else if( endedFocus ) {
90
        commitEdit();
91
      }
92
    }
20
    new CellEditor(
21
      input -> commitEdit( getConverter().fromString( input ) ),
22
      graphicProperty()
23
    );
9324
  }
9425
}
D src/main/java/com/keenwrite/ui/tree/AltTreeCellFactory.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.ui.tree;
3
4
import javafx.scene.control.TreeCell;
5
import javafx.scene.control.TreeView;
6
import javafx.util.Callback;
7
import javafx.util.StringConverter;
8
9
/**
10
 * Responsible for creating new {@link TreeCell} instances.
11
 * <p>
12
 * TODO: #22 -- Upon refactoring variable functionality, re-instate drag & drop.
13
 * </p>
14
 *
15
 * @param <T> The data type stored in the tree.
16
 */
17
public class AltTreeCellFactory<T>
18
  implements Callback<TreeView<T>, TreeCell<T>> {
19
  private final StringConverter<T> mConverter;
20
21
  public AltTreeCellFactory( final StringConverter<T> converter ) {
22
    mConverter = converter;
23
  }
24
25
  @Override
26
  public TreeCell<T> call( final TreeView<T> treeView ) {
27
    return new AltTreeCell<>( mConverter );
28
  }
29
}
301
M src/main/java/com/keenwrite/ui/tree/AltTreeView.java
1919
    super( root );
2020
21
    assert root != null;
22
    assert converter != null;
23
2124
    setEditable( true );
22
    setCellFactory( new AltTreeCellFactory<>( converter ) );
25
    setCellFactory( treeView -> new AltTreeCell<>( converter ) );
2326
    setShowRoot( false );
2427
25
    // When focus is lost, clear the selected item only when not editing.
28
    // When focus is lost while not editing, deselect all items.
2629
    focusedProperty().addListener( ( c, o, n ) -> {
2730
      if( o && getEditingItem() == null ) {
D src/main/java/com/keenwrite/util/BoundedCache.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.util;
3
4
import java.util.LinkedHashMap;
5
import java.util.Map;
6
7
/**
8
 * A map that removes the oldest entry once its capacity (cache size) has
9
 * been reached.
10
 *
11
 * @param <K> The type of key mapped to a value.
12
 * @param <V> The type of value mapped to a key.
13
 */
14
public final class BoundedCache<K, V> extends LinkedHashMap<K, V> {
15
  private final int mCacheSize;
16
17
  /**
18
   * Constructs a new instance having a finite size.
19
   *
20
   * @param cacheSize The maximum number of entries.
21
   */
22
  public BoundedCache( final int cacheSize ) {
23
    mCacheSize = cacheSize;
24
  }
25
26
  @Override
27
  protected boolean removeEldestEntry( final Map.Entry<K, V> eldest ) {
28
    return size() > mCacheSize;
29
  }
30
}
311
M src/main/java/com/keenwrite/util/CyclicIterator.java
134134
135135
    // Ensure the invariant holds.
136
    assert 0 <= result && result < size || size == 0 && result <= 0;
136
    assert 0 <= result && result < size || size == 0;
137137
138138
    return result;
M src/main/java/com/keenwrite/util/GenericBuilder.java
22
package com.keenwrite.util;
33
4
import java.util.ArrayList;
4
import java.util.LinkedList;
55
import java.util.List;
66
import java.util.function.BiConsumer;
...
1515
 * See <a href="https://stackoverflow.com/a/31754787/59087">source</a> for
1616
 * details.
17
 * </p>
1817
 *
1918
 * @param <MT> The mutable definition for the type of object to build.
...
3534
   * Adds a modifier to call when building an instance.
3635
   */
37
  private final List<Consumer<MT>> mModifiers = new ArrayList<>();
36
  private final List<Consumer<MT>> mModifiers = new LinkedList<>();
37
38
  /**
39
   * Starting point for building an instance of a particular class.
40
   *
41
   * @param supplier Returns the instance to build.
42
   * @param <MT>     The type of class to build.
43
   * @return A new {@link GenericBuilder} capable of populating data for an
44
   * instance of the class provided by the {@link Supplier}.
45
   */
46
  public static <MT, IT> GenericBuilder<MT, IT> of(
47
    final Supplier<MT> supplier, final Function<MT, IT> immutable ) {
48
    return new GenericBuilder<>( supplier, immutable );
49
  }
3850
3951
  /**
4052
   * Constructs a new builder instance that is capable of populating values for
4153
   * any type of object.
4254
   *
4355
   * @param mutator Provides methods to use for setting object properties.
4456
   */
4557
  protected GenericBuilder(
46
      final Supplier<MT> mutator, final Function<MT, IT> immutable ) {
58
    final Supplier<MT> mutator, final Function<MT, IT> immutable ) {
4759
    assert mutator != null;
4860
    assert immutable != null;
4961
5062
    mMutable = mutator;
5163
    mImmutable = immutable;
52
  }
53
54
  /**
55
   * Starting point for building an instance of a particular class.
56
   *
57
   * @param supplier Returns the instance to build.
58
   * @param <MT>     The type of class to build.
59
   * @return A new {@link GenericBuilder} capable of populating data for an
60
   * instance of the class provided by the {@link Supplier}.
61
   */
62
  public static <MT, IT> GenericBuilder<MT, IT> of(
63
      final Supplier<MT> supplier, final Function<MT, IT> immutable ) {
64
    return new GenericBuilder<>( supplier, immutable );
6564
  }
6665
...
7473
   */
7574
  public <V> GenericBuilder<MT, IT> with(
76
      final BiConsumer<MT, V> consumer, final V value ) {
75
    final BiConsumer<MT, V> consumer, final V value ) {
76
    assert consumer != null;
77
7778
    mModifiers.add( instance -> consumer.accept( instance, value ) );
79
7880
    return this;
7981
  }
...
8688
  public IT build() {
8789
    final var value = mMutable.get();
90
8891
    mModifiers.forEach( modifier -> modifier.accept( value ) );
8992
    mModifiers.clear();
93
9094
    return mImmutable.apply( value );
9195
  }
D src/main/java/com/keenwrite/util/InterpolatingMap.java
1
package com.keenwrite.util;
2
3
import com.keenwrite.sigils.SigilOperator;
4
import com.keenwrite.sigils.Sigils;
5
6
import java.util.HashMap;
7
import java.util.Map;
8
import java.util.concurrent.ConcurrentHashMap;
9
import java.util.regex.Pattern;
10
11
import static java.lang.String.format;
12
import static java.util.regex.Pattern.compile;
13
import static java.util.regex.Pattern.quote;
14
15
public class InterpolatingMap extends ConcurrentHashMap<String, String> {
16
  private static final int GROUP_DELIMITED = 1;
17
18
  /**
19
   * Used to override the default initial capacity in {@link HashMap}.
20
   */
21
  private static final int INITIAL_CAPACITY = 1 << 8;
22
23
  public InterpolatingMap() {
24
    super( INITIAL_CAPACITY );
25
  }
26
27
  /**
28
   * Interpolates all values in the map that reference other values by way
29
   * of key names. Performs a non-greedy match of key names delimited by
30
   * definition tokens. This operation modifies the map directly.
31
   *
32
   * @param operator Contains the opening and closing sigils that mark
33
   *                 where variable names begin and end.
34
   * @return {@code this}
35
   */
36
  public Map<String, String> interpolate( final SigilOperator operator ) {
37
    sigilize( operator );
38
    interpolate( operator.getSigils() );
39
    return this;
40
  }
41
42
  /**
43
   * Wraps each key in this map with the starting and ending sigils provided
44
   * by the given {@link SigilOperator}. This operation modifies the map
45
   * directly.
46
   *
47
   * @param operator Container for starting and ending sigils.
48
   */
49
  private void sigilize( final SigilOperator operator ) {
50
    forEach( ( k, v ) -> put( operator.entoken( k ), v ) );
51
  }
52
53
  /**
54
   * Interpolates all values in the map that reference other values by way
55
   * of key names. Performs a non-greedy match of key names delimited by
56
   * definition tokens. This operation modifies the map directly.
57
   *
58
   * @param sigils Contains the opening and closing sigils that mark
59
   *               where variable names begin and end.
60
   */
61
  private void interpolate( final Sigils sigils ) {
62
    final var pattern = compile(
63
      format(
64
        "(%s.*?%s)", quote( sigils.getBegan() ), quote( sigils.getEnded() )
65
      )
66
    );
67
68
    replaceAll( ( k, v ) -> resolve( v, pattern ) );
69
  }
70
71
  /**
72
   * Given a value with zero or more key references, this will resolve all
73
   * the values, recursively. If a key cannot be de-referenced, the value will
74
   * contain the key name.
75
   *
76
   * @param value   Value containing zero or more key references.
77
   * @param pattern The regular expression pattern to match variable key names.
78
   * @return The given value with all embedded key references interpolated.
79
   */
80
  private String resolve( String value, final Pattern pattern ) {
81
    final var matcher = pattern.matcher( value );
82
83
    while( matcher.find() ) {
84
      final var keyName = matcher.group( GROUP_DELIMITED );
85
      final var mapValue = get( keyName );
86
      final var keyValue = mapValue == null
87
        ? keyName
88
        : resolve( mapValue, pattern );
89
90
      value = value.replace( keyName, keyValue );
91
    }
92
93
    return value;
94
  }
95
}
961
M src/main/resources/com/keenwrite/messages.properties
1010
1111
workspace.document=Document
12
workspace.document.title=Title Name
13
workspace.document.title.desc=Full document title, or variable reference (e.g., '{{'book.title'}}').
14
workspace.document.title.title=Title
15
workspace.document.author=Author Name
16
workspace.document.author.desc=Full name of primary author, or variable reference (e.g., '{{'book.author'}}').
17
workspace.document.author.title=Name
18
workspace.document.byline=Byline
19
workspace.document.byline.desc=Author name, pen name, byline, pseudonym, or variable reference.
20
workspace.document.byline.title=Name
21
workspace.document.address=Address
22
workspace.document.address.desc=Author mailing address, or variable reference.
23
workspace.document.address.title=Address
24
workspace.document.phone=Phone
25
workspace.document.phone.desc=Author phone number, or variable reference.
26
workspace.document.phone.title=Number
27
workspace.document.email=Email
28
workspace.document.email.desc=Author email address, or variable reference.
29
workspace.document.email.title=Email
30
workspace.document.keywords=Keywords
31
workspace.document.keywords.desc=Comma-separated words relating to subject matter, or variable reference.
32
workspace.document.keywords.title=Words
33
workspace.document.copyright=Copyright
34
workspace.document.copyright.desc=Continuous years of publication, or variable reference.
35
workspace.document.copyright.title=Year(s)
36
workspace.document.date=Publish Date
37
workspace.document.date.desc=Date and time document was published, or variable reference.
38
workspace.document.date.title=Timestamp
12
13
workspace.document.meta=Document Metadata
14
workspace.document.meta.desc=Keys must be alphabetic, values may use variables (e.g., '{{'book.title'}}').
15
workspace.document.meta.title=Pairs
3916
4017
workspace.editor=Editor
...
6037
workspace.r.script.desc=Script runs prior to executing R statements within the document.
6138
workspace.r.dir=Working Directory
62
workspace.r.dir.desc=Value assigned to {0}application.r.working.directory{1} and usable in the startup script.
39
workspace.r.dir.desc=Value assigned to v$application$r$working$directory and usable in the startup script.
6340
workspace.r.dir.title=Directory
6441
workspace.r.delimiter.began=Delimiter Prefix
M src/test/java/com/keenwrite/definition/TreeViewTest.java
77
import com.keenwrite.preferences.Workspace;
88
import com.keenwrite.preview.HtmlPreview;
9
import com.keenwrite.sigils.Sigils;
10
import com.keenwrite.sigils.YamlSigilOperator;
119
import com.panemu.tiwulfx.control.dock.DetachableTabPane;
1210
import javafx.application.Application;
1311
import javafx.beans.property.SimpleObjectProperty;
14
import javafx.beans.property.SimpleStringProperty;
1512
import javafx.event.Event;
1613
import javafx.event.EventHandler;
...
2421
import org.testfx.framework.junit5.Start;
2522
26
import static com.keenwrite.constants.Constants.DEF_DELIM_BEGAN_DEFAULT;
27
import static com.keenwrite.constants.Constants.DEF_DELIM_ENDED_DEFAULT;
2823
import static com.keenwrite.util.FontLoader.initFonts;
2924
...
5247
    final var workspace = new Workspace();
5348
    final var mainPane = new SplitPane();
54
55
    final var began = new SimpleStringProperty( DEF_DELIM_BEGAN_DEFAULT );
56
    final var ended = new SimpleStringProperty( DEF_DELIM_ENDED_DEFAULT );
57
    final var sigils = new Sigils( began, ended );
58
    final var operator = new YamlSigilOperator( sigils );
5949
    final var transformer = new YamlTreeTransformer();
60
    final var editor = new DefinitionEditor( transformer, operator );
50
    final var editor = new DefinitionEditor( transformer );
6151
6252
    final var tabPane1 = new DetachableTabPane();
6353
    tabPane1.addTab( "Editor", editor );
6454
6555
    final var tabPane2 = new DetachableTabPane();
66
    final var tab21 = tabPane2.addTab( "Picker", new ColorPicker() );
67
    final var tab22 = tabPane2.addTab( "Editor",
68
                                       new MarkdownEditor( workspace ) );
56
    final var tab21 =
57
      tabPane2.addTab( "Picker", new ColorPicker() );
58
    final var tab22 =
59
      tabPane2.addTab( "Editor", new MarkdownEditor( workspace ) );
6960
    tab21.setTooltip( new Tooltip( "Colour Picker" ) );
7061
    tab22.setTooltip( new Tooltip( "Text Editor" ) );
7162
7263
    final var tabPane3 = new DetachableTabPane();
7364
    tabPane3.addTab( "Preview", new HtmlPreview( workspace ) );
7465
7566
    editor.addTreeChangeHandler( mTreeHandler );
7667
7768
    mainPane.getItems().addAll( tabPane1, tabPane2, tabPane3 );
78
79
    final var scene = new Scene( mainPane );
80
    stage.setScene( scene );
8169
70
    stage.setScene( new Scene( mainPane ) );
8271
    stage.show();
8372
  }
A src/test/java/com/keenwrite/preferences/KeyTest.java
1
package com.keenwrite.preferences;
2
3
import org.junit.jupiter.api.Test;
4
5
import static com.keenwrite.preferences.Key.key;
6
import static org.junit.jupiter.api.Assertions.assertEquals;
7
8
/**
9
 * Test that {@link Key} hierarchies can be transformed into alternate data
10
 * models.
11
 */
12
class KeyTest {
13
  @Test
14
  public void test_String_ParentHierarchy_DotNotation() {
15
    final var keyRoot = key( "root" );
16
    final var keyMeta = key( keyRoot, "meta" );
17
    final var keyDate = key( keyMeta, "date" );
18
19
    final var expected = "root.meta.date";
20
    final var actual = keyDate.toString();
21
22
    assertEquals( expected, actual );
23
  }
24
}
125
M src/test/java/com/keenwrite/processors/markdown/ImageLinkExtensionTest.java
55
import com.keenwrite.Caret;
66
import com.keenwrite.preferences.Workspace;
7
import com.keenwrite.preview.HtmlPreview;
87
import com.keenwrite.processors.Processor;
98
import com.keenwrite.processors.ProcessorContext;
109
import com.keenwrite.processors.markdown.extensions.ImageLinkExtension;
1110
import com.vladsch.flexmark.html.HtmlRenderer;
1211
import com.vladsch.flexmark.parser.Parser;
13
import javafx.beans.property.SimpleObjectProperty;
1412
import javafx.stage.Stage;
1513
import org.junit.jupiter.api.Test;
...
2725
import java.util.Map;
2826
29
import static com.keenwrite.ExportFormat.NONE;
27
import static com.keenwrite.ExportFormat.XHTML_TEX;
3028
import static com.keenwrite.constants.Constants.DOCUMENT_DEFAULT;
3129
import static java.lang.String.format;
...
4341
public class ImageLinkExtensionTest {
4442
  private static final Workspace sWorkspace = new Workspace(
45
    getResource( "workspace.xml" ) );
43
    getResourceFile( "workspace.xml" ) );
4644
4745
  private static final Map<String, String> IMAGES = new HashMap<>();
...
7674
    addUri( "https://" + URI_WEB );
7775
  }
78
79
  private HtmlPreview mPreview;
8076
8177
  @Start
8278
  @SuppressWarnings( "unused" )
8379
  private void start( final Stage stage ) {
84
    mPreview = new HtmlPreview( sWorkspace );
8580
  }
8681
...
144139
   * Creates a new {@link ProcessorContext} for the given file name path.
145140
   *
146
   * @param documentPath Fully qualified path to the file name.
141
   * @param inputPath Fully qualified path to the file name.
147142
   * @return A context used for creating new {@link Processor} instances.
148143
   */
149
  private ProcessorContext createProcessorContext( final Path documentPath ) {
150
    return new ProcessorContext(
151
      mPreview,
152
      new SimpleObjectProperty<>(),
153
      documentPath,
154
      null,
155
      NONE,
156
      sWorkspace,
157
      Caret.builder().build()
158
    );
144
  private ProcessorContext createProcessorContext( final Path inputPath ) {
145
    return ProcessorContext
146
      .builder()
147
      .with( ProcessorContext.Mutator::setInputPath, inputPath )
148
      .with( ProcessorContext.Mutator::setExportFormat, XHTML_TEX )
149
      .with( ProcessorContext.Mutator::setWorkspace, sWorkspace )
150
      .with( ProcessorContext.Mutator::setCaret, () -> Caret.builder().build() )
151
      .build();
159152
  }
160153
...
180173
  private static String getResource( final String path ) {
181174
    return toUri( path ).toString();
175
  }
176
177
  private static File getResourceFile( final String path ) {
178
    return new File( getResource( path ) );
182179
  }
183180
}
A src/test/java/com/keenwrite/sigils/RKeyOperatorTest.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.sigils;
3
4
import org.junit.jupiter.api.Test;
5
6
import static org.junit.jupiter.api.Assertions.assertEquals;
7
8
/**
9
 * Responsible for simulating R variable injection.
10
 */
11
class RKeyOperatorTest {
12
13
  /**
14
   * Test that a key name becomes an R variable.
15
   */
16
  @Test
17
  void test_Process_KeyName_Processed() {
18
    final var mOperator = new RKeyOperator();
19
    final var expected = "v$a$b$c$d";
20
    final var actual = mOperator.apply( "a.b.c.d" );
21
22
    assertEquals( expected, actual );
23
  }
24
}
125
D src/test/java/com/keenwrite/sigils/RSigilOperatorTest.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.sigils;
3
4
import javafx.beans.property.SimpleStringProperty;
5
import javafx.beans.property.StringProperty;
6
import org.junit.jupiter.api.Test;
7
8
import static org.junit.jupiter.api.Assertions.assertEquals;
9
10
/**
11
 * Responsible for simulating R variable injection.
12
 */
13
class RSigilOperatorTest {
14
15
  private final SigilOperator mOperator = createRSigilOperator();
16
17
  /**
18
   * Test that a key name becomes an R variable.
19
   */
20
  @Test
21
  void test_Entoken_KeyName_Tokenized() {
22
    final var expected = "v$a$b$c$d";
23
    final var actual = mOperator.entoken( "{{a.b.c.d}}" );
24
    assertEquals( expected, actual );
25
  }
26
27
  /**
28
   * Test that a key name becomes a viable R expression.
29
   */
30
  @Test
31
  void test_Apply_KeyName_Expression() {
32
    final var expected = "`r#x(v$a$b$c$d)`";
33
    final var actual = mOperator.apply( "v$a$b$c$d" );
34
    assertEquals( expected, actual );
35
  }
36
37
  private StringProperty createSigil( final String token ) {
38
    return new SimpleStringProperty( token );
39
  }
40
41
  private Sigils createRSigils() {
42
    return createSigils( "x(", ")" );
43
  }
44
45
  private Sigils createYamlSigils() {
46
    return createSigils( "{{", "}}" );
47
  }
48
49
  private Sigils createSigils( final String began, final String ended ) {
50
    return new Sigils( createSigil( began ), createSigil( ended ) );
51
  }
52
53
  private YamlSigilOperator createYamlSigilOperator() {
54
    return new YamlSigilOperator( createYamlSigils() );
55
  }
56
57
  private RSigilOperator createRSigilOperator() {
58
    return new RSigilOperator( createRSigils(), createYamlSigilOperator() );
59
  }
60
}
611
M src/test/java/com/keenwrite/tex/TeXRasterizationTest.java
9292
    final var expectedSvg = g.toString();
9393
    final var bytes = expectedSvg.getBytes();
94
    final var doc = parse( new ByteArrayInputStream( bytes ) );
95
    final var actualSvg = toSvg( doc.getDocumentElement() );
9694
97
    verifyImage( rasterizeString( actualSvg ) );
95
    try( final var in = new ByteArrayInputStream( bytes ) ) {
96
      final var doc = parse( in );
97
      final var actualSvg = toSvg( doc.getDocumentElement() );
98
99
      verifyImage( rasterizeString( actualSvg ) );
100
    }
98101
  }
99102
A src/test/java/com/keenwrite/util/CircularQueueTest.java
1
package com.keenwrite.util;
2
3
import com.keenwrite.collections.CircularQueue;
4
import org.junit.jupiter.api.Test;
5
6
import static org.junit.jupiter.api.Assertions.assertEquals;
7
8
/**
9
 * Tests the {@link CircularQueue} class.
10
 */
11
public class CircularQueueTest {
12
13
  /**
14
   * Exercises the circularity aspect of the {@link CircularQueue}.
15
   * Confirms that the elements added can be subsequently overwritten.
16
   * This also checks that peek and remove functionality work as expected.
17
   */
18
  @Test
19
  public void test_Add_ExceedMaxCapacity_FirstElementOverwritten() {
20
    final var CAPACITY = 5;
21
    final var OVERWRITE = 17;
22
    final var ELEMENTS = CAPACITY + OVERWRITE;
23
    final var queue = createQueue( CAPACITY, ELEMENTS );
24
25
    assertEquals( CAPACITY, queue.size() );
26
27
    for( int i = 0; i < CAPACITY; i++ ) {
28
      final var expected =
29
        ELEMENTS - ((((OVERWRITE - CAPACITY - 1) - i) % CAPACITY) + 1);
30
31
      assertEquals( expected, queue.peek() );
32
      assertEquals( expected, queue.remove() );
33
    }
34
  }
35
36
  /**
37
   * Tests iterating over all elements in the {@link CircularQueue}.
38
   */
39
  @Test
40
  public void test_Iterate_FullQueue_AllElementsNavigated() {
41
    final var CAPACITY = 101;
42
    final var queue = createQueue( CAPACITY, CAPACITY );
43
    int actualCount = 0;
44
45
    for( final var ignored : queue ) {
46
      actualCount++;
47
    }
48
49
    assertEquals( CAPACITY, actualCount );
50
  }
51
52
  /**
53
   * Tests iterating over {@link CircularQueue} where some elements,
54
   * starting at an arbitrary offset, have been removed.
55
   */
56
  @Test
57
  public void test_Iterate_PartialQueue_AllElementsNavigated() {
58
    final var CAPACITY = 31;
59
    final var OVERWRITE = CAPACITY / 2;
60
    final var queue = createQueue( CAPACITY, CAPACITY + OVERWRITE );
61
    var actualCount = 0;
62
63
    for( int i = 0; i < OVERWRITE; i++ ) {
64
      queue.remove();
65
    }
66
67
    for( final var ignored : queue ) {
68
      actualCount++;
69
    }
70
71
    assertEquals( CAPACITY - OVERWRITE, actualCount );
72
  }
73
74
  /**
75
   * Creates a new, pre-populated {@link CircularQueue} instance.
76
   *
77
   * @param capacity The maximum number of elements before overwriting.
78
   * @param count    The number of elements to pre-populate the queue.
79
   * @return A new {@link CircularQueue} pre-populated with ascending,
80
   * consecutive values.
81
   */
82
  private static CircularQueue<Integer> createQueue(
83
    final int capacity, final int count ) {
84
    final var queue = new CircularQueue<Integer>( capacity );
85
86
    for( int i = 0; i < count; i++ ) {
87
      queue.add( i );
88
    }
89
90
    return queue;
91
  }
92
}
193