Dave Jarvis' Repositories

git clone https://repo.autonoma.ca/repo/keenwrite.git
M build.gradle
188188
test {
189189
  useJUnitPlatform()
190
191
  testLogging {
192
    exceptionFormat = 'full'
193
  }
190194
}
191195
M docs/diagram.yaml
33
  classes:
44
    processors:
5
      markdown: "MarkdownProcessor"
5
      markdown: MarkdownProcessor
66
      variable:
7
        definition: "DefinitionProcessor"
8
      preview: "PreviewProcessor"
7
        definition: DefinitionProcessor
8
      preview: PreviewProcessor
99
  palette:
1010
    primary:
11
      light: "#51a9cf"
12
      dark: "#126d95"
11
      light: '#51a9cf'
12
      dark: '#126d95'
1313
    secondary:
14
      light: "#ec706a"
15
      dark: "#7e252f"
14
      light: '#ec706a'
15
      dark: '#7e252f'
1616
    accent:
17
      light: "#76A786"
18
      dark: "#385742"
17
      light: '#76A786'
18
      dark: '#385742'
1919
    grayscale:
20
      light: "#bac2c5"
21
      dark: "#394343"
20
      light: '#bac2c5'
21
      dark: '#394343'
2222
  graph:
2323
    label:
2424
      chain:
25
        next: "successor"
25
        next: successor
2626
M src/main/java/com/keenwrite/Bootstrap.java
1414
 * </p>
1515
 */
16
public class Bootstrap {
16
public final class Bootstrap {
1717
  private static final Properties BOOTSTRAP = new Properties();
1818
M src/main/java/com/keenwrite/Caret.java
183183
   * within the text. Typically this will include the current line number,
184184
   * the number of lines, and the character offset into the text.
185
   * <p>
186
   * If the {@link Caret} has not been properly built, this will return a
187
   * string for the status bar having all values set to zero. This can happen
188
   * during unit testing, but should not happen any other time.
189
   * </p>
185190
   *
186191
   * @return A string to present to an end user.
187192
   */
188193
  @Override
189194
  public String toString() {
190
    return get( STATUS_BAR_LINE,
191
                getParagraph() + 1,
192
                getParagraphCount(),
193
                getTextOffset() + 1 );
195
    try {
196
      return get( STATUS_BAR_LINE,
197
                  getParagraph() + 1,
198
                  getParagraphCount(),
199
                  getTextOffset() + 1 );
200
    } catch( final NullPointerException ex ) {
201
      return get( STATUS_BAR_LINE, 0, 0, 0 );
202
    }
194203
  }
195204
}
M src/main/java/com/keenwrite/Constants.java
2222
 * Defines application-wide default values.
2323
 */
24
public class Constants {
24
public final class Constants {
2525
2626
  /**
M src/main/java/com/keenwrite/DefinitionNameInjector.java
4747
          }
4848
          else {
49
            editor.replaceText( indexes, operator.entoken( leaf.toPath() ) );
49
            final var entokened = operator.entoken( leaf.toPath() );
50
            editor.replaceText( indexes, operator.apply( entokened ) );
5051
            definitions.expand( leaf );
5152
          }
...
6364
   * @param word Match the word by: exact, beginning, containment, or other.
6465
   */
65
  @SuppressWarnings("ConstantConditions")
66
  @SuppressWarnings( "ConstantConditions" )
6667
  private static DefinitionTreeItem<String> findLeaf(
6768
    final TextDefinition definition, final String word ) {
M src/main/java/com/keenwrite/ExportFormat.java
1313
1414
  /**
15
   * For HTML exports, encode TeX as SVG.
15
   * For HTML exports, encode TeX as SVG. Treat image links relatively.
1616
   */
1717
  HTML_TEX_SVG( ".html" ),
1818
1919
  /**
2020
   * For HTML exports, encode TeX using {@code $} delimiters, suitable for
2121
   * rendering by an external TeX typesetting engine (or online with KaTeX).
22
   * Treat image links relatively.
2223
   */
2324
  HTML_TEX_DELIMITED( ".html" ),
2425
2526
  /**
2627
   * Indicates that the processors should export to a Markdown format.
28
   * Treat image links relatively.
2729
   */
2830
  MARKDOWN_PLAIN( ".out.md" ),
2931
3032
  /**
3133
   * Indicates no special export format is to be created. No extension is
32
   * applicable.
34
   * applicable. Image links must use absolute directories.
3335
   */
3436
  NONE( "" );
M src/main/java/com/keenwrite/Launcher.java
1818
 * </p>
1919
 */
20
public class Launcher {
20
public final class Launcher {
2121
  /**
2222
   * Delegates to the application entry point.
M src/main/java/com/keenwrite/MainPane.java
761761
762762
  public ProcessorContext createProcessorContext() {
763
    return createProcessorContext( NONE );
764
  }
765
766
  public ProcessorContext createProcessorContext( final ExportFormat format ) {
763767
    final var editor = getActiveTextEditor();
764
    return createProcessorContext( editor.getPath(), editor.getCaret() );
768
    return createProcessorContext(
769
      editor.getPath(), editor.getCaret(), format );
765770
  }
766771
...
774779
   */
775780
  private ProcessorContext createProcessorContext(
776
    final Path path, final Caret caret ) {
781
    final Path path, final Caret caret, final ExportFormat format ) {
777782
    return new ProcessorContext(
778
      mHtmlPreview, mResolvedMap, path, caret, NONE, mWorkspace
783
      mHtmlPreview, mResolvedMap, path, caret, format, mWorkspace
779784
    );
780785
  }
...
800805
    final var editor = new MarkdownEditor( file, getWorkspace() );
801806
    final var caret = editor.getCaret();
802
    final var context = createProcessorContext( path, caret );
807
    final var context = createProcessorContext( path, caret, NONE );
803808
804809
    mProcessors.computeIfAbsent( editor, p -> createProcessors( context ) );
M src/main/java/com/keenwrite/MainScene.java
2020
 * Responsible for creating the bar scene: menu bar, tool bar, and status bar.
2121
 */
22
public class MainScene {
22
public final class MainScene {
2323
  private final Scene mScene;
2424
  private final Node mMenuBar;
M src/main/java/com/keenwrite/Messages.java
1616
 * properties using a <code>${var}</code> syntax.
1717
 */
18
public class Messages {
18
public final class Messages {
1919
2020
  private static final ResourceBundle RESOURCE_BUNDLE =
M src/main/java/com/keenwrite/StatusNotifier.java
1515
 * class can go away.
1616
 */
17
public class StatusNotifier {
17
public final class StatusNotifier {
1818
  private static final String OK = get( STATUS_BAR_OK, "OK" );
1919
M src/main/java/com/keenwrite/editors/definition/DefinitionEditor.java
9999
   * Constructs a definition pane with a given tree view root.
100100
   *
101
   * @param file The file to
101
   * @param file The file of definitions to maintain through the UI.
102102
   */
103103
  public DefinitionEditor(
M src/main/java/com/keenwrite/editors/definition/DefinitionTabSceneFactory.java
2222
 * @author White Magic Software, Ltd.
2323
 */
24
public class DefinitionTabSceneFactory {
24
public final class DefinitionTabSceneFactory {
2525
2626
  private final Consumer<Tab> mTabSelectionConsumer;
M src/main/java/com/keenwrite/editors/definition/TreeCellFactory.java
2222
 * and respond to drag and drop functionality.
2323
 */
24
public class TreeCellFactory
24
public final class TreeCellFactory
2525
    implements Callback<TreeView<String>, TreeCell<String>> {
2626
  private static final String STYLE_CLASS_DROP_TARGET = "drop-target";
M src/main/java/com/keenwrite/editors/definition/TreeItemMapper.java
3232
 * </p>
3333
 */
34
public class TreeItemMapper {
34
public final class TreeItemMapper {
3535
  /**
3636
   * Separates definition keys (e.g., the dots in {@code $root.node.var$}).
M src/main/java/com/keenwrite/editors/definition/TreeTransformer.java
44
import javafx.scene.control.TreeItem;
55
6
import java.util.function.Consumer;
7
import java.util.function.Function;
8
96
/**
107
 * Responsible for converting an object hierarchy into a {@link TreeItem}
D src/main/java/com/keenwrite/editors/definition/yaml/YamlParser.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.editors.definition.yaml;
3
4
import com.fasterxml.jackson.databind.JsonNode;
5
import com.fasterxml.jackson.databind.ObjectMapper;
6
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
7
8
import java.util.function.Function;
9
10
/**
11
 * Responsible for reading a YAML document into an object hierarchy.
12
 */
13
class YamlParser implements Function<String, JsonNode> {
14
15
  /**
16
   * Creates a new instance that can parse the contents of a YAML
17
   * document.
18
   */
19
  YamlParser() {
20
  }
21
22
  @Override
23
  public JsonNode apply( final String yaml ) {
24
    try {
25
      return new ObjectMapper( new YAMLFactory() ).readTree( yaml );
26
    } catch( final Exception ex ) {
27
      // Ensure that a document root node exists.
28
      return new ObjectMapper().createObjectNode();
29
    }
30
  }
31
}
321
M src/main/java/com/keenwrite/editors/definition/yaml/YamlTreeTransformer.java
33
44
import com.fasterxml.jackson.databind.JsonNode;
5
import com.fasterxml.jackson.databind.ObjectMapper;
56
import com.fasterxml.jackson.databind.node.ObjectNode;
7
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
68
import com.fasterxml.jackson.dataformat.yaml.YAMLMapper;
79
import com.keenwrite.editors.definition.DefinitionTreeItem;
810
import com.keenwrite.editors.definition.TreeTransformer;
911
import javafx.scene.control.TreeItem;
1012
import javafx.scene.control.TreeView;
1113
1214
import java.util.Map.Entry;
15
16
import static com.fasterxml.jackson.dataformat.yaml.YAMLGenerator.Feature.MINIMIZE_QUOTES;
17
import static com.fasterxml.jackson.dataformat.yaml.YAMLGenerator.Feature.SPLIT_LINES;
1318
1419
/**
1520
 * Transforms a JsonNode hierarchy into a tree that can be displayed in a user
1621
 * interface and vice-versa.
1722
 */
1823
public final class YamlTreeTransformer implements TreeTransformer {
24
  private static final YAMLFactory sFactory;
25
  private static final YAMLMapper sMapper;
26
27
  static {
28
    sFactory = new YAMLFactory();
29
    sFactory.configure( MINIMIZE_QUOTES, true );
30
    sFactory.configure( SPLIT_LINES, false );
31
    sMapper = new YAMLMapper( sFactory );
32
  }
1933
2034
  /**
...
2842
  public String transform( final TreeItem<String> treeItem ) {
2943
    try {
30
      final YAMLMapper mapper = new YAMLMapper();
31
      final ObjectNode root = mapper.createObjectNode();
44
      final var root = sMapper.createObjectNode();
3245
3346
      // Iterate over the root item's children. The root item is used by the
3447
      // application to ensure definitions can always be added to a tree, as
3548
      // such it is not meant to be exported, only its children.
36
      for( final TreeItem<String> child : treeItem.getChildren() ) {
49
      for( final var child : treeItem.getChildren() ) {
3750
        transform( child, root );
3851
      }
3952
40
      return mapper.writeValueAsString( root );
53
      return sMapper.writeValueAsString( root );
4154
    } catch( final Exception ex ) {
4255
      throw new RuntimeException( ex );
56
    }
57
  }
58
59
  /**
60
   * Converts a YAML document to a {@link TreeItem} based on the document
61
   * keys.
62
   *
63
   * @param document The YAML document to convert to a hierarchy of
64
   *                 {@link TreeItem} instances.
65
   * @throws StackOverflowError If infinite recursion is encountered.
66
   */
67
  @Override
68
  public TreeItem<String> transform( final String document ) {
69
    final var jsonNode = toJson( document );
70
    final var rootItem = createTreeItem( "root" );
71
72
    transform( jsonNode, rootItem );
73
74
    return rootItem;
75
  }
76
77
  private JsonNode toJson( final String yaml ) {
78
    try {
79
      return new ObjectMapper( sFactory ).readTree( yaml );
80
    } catch( final Exception ex ) {
81
      // Ensure that a document root node exists.
82
      return new ObjectMapper().createObjectNode();
4383
    }
4484
  }
...
69109
      }
70110
    }
71
  }
72
73
  /**
74
   * Converts a YAML document to a {@link TreeItem} based on the document
75
   * keys.
76
   *
77
   * @param document The YAML document to convert to a hierarchy of
78
   *                 {@link TreeItem} instances.
79
   * @throws StackOverflowError If infinite recursion is encountered.
80
   */
81
  @Override
82
  public TreeItem<String> transform( final String document ) {
83
    final var parser = new YamlParser();
84
    final var jsonNode = parser.apply( document );
85
    final var rootItem = createTreeItem( "root" );
86
87
    transform( jsonNode, rootItem );
88
89
    return rootItem;
90111
  }
91112
...
110131
   */
111132
  private void transform(
112
      final Entry<String, JsonNode> node, final TreeItem<String> item ) {
133
    final Entry<String, JsonNode> node, final TreeItem<String> item ) {
113134
    final var leafNode = node.getValue();
114135
    final var key = node.getKey();
M src/main/java/com/keenwrite/editors/markdown/HyperlinkModel.java
77
 * Represents the model for a hyperlink: text, url, and title.
88
 */
9
public class HyperlinkModel {
9
public final class HyperlinkModel {
1010
1111
  private String text;
M src/main/java/com/keenwrite/editors/markdown/LinkVisitor.java
3737
 * can edit the link within a dialog.
3838
 */
39
public class LinkVisitor {
39
public final class LinkVisitor {
4040
4141
  private NodeVisitor mVisitor;
M src/main/java/com/keenwrite/editors/markdown/MarkdownEditor.java
5454
 * Responsible for editing Markdown documents.
5555
 */
56
public class MarkdownEditor extends BorderPane implements TextEditor {
56
public final class MarkdownEditor extends BorderPane implements TextEditor {
5757
  /**
5858
   * Regular expression that matches the type of markup block. This is used
M src/main/java/com/keenwrite/exceptions/MissingFileException.java
1010
 * This avoids duplicating the error message prefix.
1111
 */
12
public class MissingFileException extends FileNotFoundException {
12
public final class MissingFileException extends FileNotFoundException {
1313
  /**
1414
   * Constructs a new {@link MissingFileException} using the given path.
M src/main/java/com/keenwrite/io/HttpMediaType.java
1919
 * an HTTP request.
2020
 */
21
public class HttpMediaType {
21
public final class HttpMediaType {
2222
2323
  private final static HttpClient HTTP_CLIENT = HttpClient
M src/main/java/com/keenwrite/predicates/PredicateFactory.java
1313
 * of string comparisons, including basic strings and file name strings.
1414
 */
15
public class PredicateFactory {
15
public final class PredicateFactory {
1616
  /**
1717
   * Creates an instance of {@link Predicate} that matches a globbed file
M src/main/java/com/keenwrite/preferences/FileProperty.java
66
import java.io.File;
77
8
public class FileProperty extends SimpleObjectProperty<File> {
8
public final class FileProperty extends SimpleObjectProperty<File> {
99
  public FileProperty( final File file ) {
1010
    super( file );
M src/main/java/com/keenwrite/preferences/LocaleProperty.java
1414
import static java.util.Locale.forLanguageTag;
1515
16
public class LocaleProperty extends SimpleObjectProperty<String> {
16
public final class LocaleProperty extends SimpleObjectProperty<String> {
1717
1818
  /**
M src/main/java/com/keenwrite/preferences/LocaleScripts.java
1414
 * using the same format.
1515
 */
16
public class LocaleScripts {
16
public final class LocaleScripts {
1717
  /**
1818
   * ISO 15924 alpha-4 script code to represent Latin scripts.
M src/main/java/com/keenwrite/preferences/PreferencesController.java
3535
 */
3636
@SuppressWarnings( "SameParameterValue" )
37
public class PreferencesController {
37
public final class PreferencesController {
3838
3939
  private final Workspace mWorkspace;
M src/main/java/com/keenwrite/preferences/Workspace.java
6060
 * </dl>
6161
 */
62
public class Workspace {
63
  private static final Key KEY_ROOT = key( "workspace" );
64
65
  public static final Key KEY_META = key( KEY_ROOT, "meta" );
66
  public static final Key KEY_META_NAME = key( KEY_META, "name" );
67
  public static final Key KEY_META_VERSION = key( KEY_META, "version" );
68
69
  public static final Key KEY_R = key( KEY_ROOT, "r" );
70
  public static final Key KEY_R_SCRIPT = key( KEY_R, "script" );
71
  public static final Key KEY_R_DIR = key( KEY_R, "dir" );
72
  public static final Key KEY_R_DELIM = key( KEY_R, "delimiter" );
73
  public static final Key KEY_R_DELIM_BEGAN = key( KEY_R_DELIM, "began" );
74
  public static final Key KEY_R_DELIM_ENDED = key( KEY_R_DELIM, "ended" );
75
76
  public static final Key KEY_IMAGES = key( KEY_ROOT, "images" );
77
  public static final Key KEY_IMAGES_DIR = key( KEY_IMAGES, "dir" );
78
  public static final Key KEY_IMAGES_ORDER = key( KEY_IMAGES, "order" );
79
80
  public static final Key KEY_DEF = key( KEY_ROOT, "definition" );
81
  public static final Key KEY_DEF_PATH = key( KEY_DEF, "path" );
82
  public static final Key KEY_DEF_DELIM = key( KEY_DEF, "delimiter" );
83
  public static final Key KEY_DEF_DELIM_BEGAN = key( KEY_DEF_DELIM, "began" );
84
  public static final Key KEY_DEF_DELIM_ENDED = key( KEY_DEF_DELIM, "ended" );
85
86
  //@formatter:off
87
  public static final Key KEY_UI = key( KEY_ROOT, "ui" );
88
89
  public static final Key KEY_UI_RECENT = key( KEY_UI, "recent" );
90
  public static final Key KEY_UI_RECENT_DIR = key( KEY_UI_RECENT, "dir" );
91
  public static final Key KEY_UI_RECENT_DOCUMENT = key( KEY_UI_RECENT,"document" );
92
  public static final Key KEY_UI_RECENT_DEFINITION = key( KEY_UI_RECENT, "definition" );
93
94
  public static final Key KEY_UI_FILES = key( KEY_UI, "files" );
95
  public static final Key KEY_UI_FILES_PATH = key( KEY_UI_FILES, "path" );
96
97
  public static final Key KEY_UI_FONT = key( KEY_UI, "font" );
98
  public static final Key KEY_UI_FONT_EDITOR = key( KEY_UI_FONT, "editor" );
99
  public static final Key KEY_UI_FONT_EDITOR_NAME = key( KEY_UI_FONT_EDITOR, "name" );
100
  public static final Key KEY_UI_FONT_EDITOR_SIZE = key( KEY_UI_FONT_EDITOR, "size" );
101
  public static final Key KEY_UI_FONT_PREVIEW = key( KEY_UI_FONT, "preview" );
102
  public static final Key KEY_UI_FONT_PREVIEW_NAME = key( KEY_UI_FONT_PREVIEW, "name" );
103
  public static final Key KEY_UI_FONT_PREVIEW_SIZE = key( KEY_UI_FONT_PREVIEW, "size" );
104
  public static final Key KEY_UI_FONT_PREVIEW_MONO = key( KEY_UI_FONT_PREVIEW, "mono" );
105
  public static final Key KEY_UI_FONT_PREVIEW_MONO_NAME = key( KEY_UI_FONT_PREVIEW_MONO, "name" );
106
  public static final Key KEY_UI_FONT_PREVIEW_MONO_SIZE = key( KEY_UI_FONT_PREVIEW_MONO, "size" );
107
108
  public static final Key KEY_LANGUAGE = key( KEY_ROOT, "language" );
109
  public static final Key KEY_LANG_LOCALE = key( KEY_LANGUAGE, "locale" );
110
111
  public static final Key KEY_UI_WINDOW = key( KEY_UI, "window" );
112
  public static final Key KEY_UI_WINDOW_X = key( KEY_UI_WINDOW, "x" );
113
  public static final Key KEY_UI_WINDOW_Y = key( KEY_UI_WINDOW, "y" );
114
  public static final Key KEY_UI_WINDOW_W = key( KEY_UI_WINDOW, "width" );
115
  public static final Key KEY_UI_WINDOW_H = key( KEY_UI_WINDOW, "height" );
116
  public static final Key KEY_UI_WINDOW_MAX = key( KEY_UI_WINDOW, "maximized" );
117
  public static final Key KEY_UI_WINDOW_FULL = key( KEY_UI_WINDOW, "full" );
118
119
  private final Map<Key, Property<?>> VALUES = Map.ofEntries(
120
    entry( KEY_META_VERSION, asStringProperty( getVersion() ) ),
121
    entry( KEY_META_NAME, asStringProperty( "default" ) ),
122
    
123
    entry( KEY_R_SCRIPT, asStringProperty( "" ) ),
124
    entry( KEY_R_DIR, asFileProperty( USER_DIRECTORY ) ),
125
    entry( KEY_R_DELIM_BEGAN, asStringProperty( R_DELIM_BEGAN_DEFAULT ) ),
126
    entry( KEY_R_DELIM_ENDED, asStringProperty( R_DELIM_ENDED_DEFAULT ) ),
127
    
128
    entry( KEY_IMAGES_DIR, asFileProperty( USER_DIRECTORY ) ),
129
    entry( KEY_IMAGES_ORDER, asStringProperty( PERSIST_IMAGES_DEFAULT ) ),
130
    
131
    entry( KEY_DEF_PATH, asFileProperty( DEFINITION_DEFAULT ) ),
132
    entry( KEY_DEF_DELIM_BEGAN, asStringProperty( DEF_DELIM_BEGAN_DEFAULT ) ),
133
    entry( KEY_DEF_DELIM_ENDED, asStringProperty( DEF_DELIM_ENDED_DEFAULT ) ),
134
    
135
    entry( KEY_UI_RECENT_DIR, asFileProperty( USER_DIRECTORY ) ),
136
    entry( KEY_UI_RECENT_DOCUMENT, asFileProperty( DOCUMENT_DEFAULT ) ),
137
    entry( KEY_UI_RECENT_DEFINITION, asFileProperty( DEFINITION_DEFAULT ) ),
138
    
139
    entry( KEY_LANG_LOCALE, asLocaleProperty( LOCALE_DEFAULT ) ),
140
    entry( KEY_UI_FONT_EDITOR_NAME, asStringProperty( FONT_NAME_EDITOR_DEFAULT ) ),
141
    entry( KEY_UI_FONT_EDITOR_SIZE, asDoubleProperty( FONT_SIZE_EDITOR_DEFAULT ) ),
142
    entry( KEY_UI_FONT_PREVIEW_NAME, asStringProperty( FONT_NAME_PREVIEW_DEFAULT ) ),
143
    entry( KEY_UI_FONT_PREVIEW_SIZE, asDoubleProperty( FONT_SIZE_PREVIEW_DEFAULT ) ),
144
    entry( KEY_UI_FONT_PREVIEW_MONO_NAME, asStringProperty( FONT_NAME_PREVIEW_MONO_NAME_DEFAULT ) ),
145
    entry( KEY_UI_FONT_PREVIEW_MONO_SIZE, asDoubleProperty( FONT_SIZE_PREVIEW_MONO_SIZE_DEFAULT ) ),
146
147
    entry( KEY_UI_WINDOW_X, asDoubleProperty( WINDOW_X_DEFAULT ) ),
148
    entry( KEY_UI_WINDOW_Y, asDoubleProperty( WINDOW_Y_DEFAULT ) ),
149
    entry( KEY_UI_WINDOW_W, asDoubleProperty( WINDOW_W_DEFAULT ) ),
150
    entry( KEY_UI_WINDOW_H, asDoubleProperty( WINDOW_H_DEFAULT ) ),
151
    entry( KEY_UI_WINDOW_MAX, asBooleanProperty() ),
152
    entry( KEY_UI_WINDOW_FULL, asBooleanProperty() )
153
  );
154
  //@formatter:on
155
156
  private StringProperty asStringProperty( final String defaultValue ) {
157
    return new SimpleStringProperty( defaultValue );
158
  }
159
160
  private DoubleProperty asDoubleProperty( final double defaultValue ) {
161
    return new SimpleDoubleProperty( defaultValue );
162
  }
163
164
  private BooleanProperty asBooleanProperty() {
165
    return new SimpleBooleanProperty();
166
  }
167
168
  private FileProperty asFileProperty( final File defaultValue ) {
169
    return new FileProperty( defaultValue );
170
  }
171
172
  @SuppressWarnings( "SameParameterValue" )
173
  private LocaleProperty asLocaleProperty( final Locale defaultValue ) {
174
    return new LocaleProperty( defaultValue );
175
  }
176
177
  /**
178
   * Helps instantiate {@link Property} instances for XML configuration items.
179
   */
180
  private static final Map<Class<?>, Function<String, Object>> UNMARSHALL =
181
    Map.of(
182
      LocaleProperty.class, LocaleProperty::parseLocale,
183
      SimpleBooleanProperty.class, Boolean::parseBoolean,
184
      SimpleDoubleProperty.class, Double::parseDouble,
185
      SimpleFloatProperty.class, Float::parseFloat,
186
      FileProperty.class, File::new
187
    );
188
189
  private static final Map<Class<?>, Function<String, Object>> MARSHALL =
190
    Map.of(
191
      LocaleProperty.class, LocaleProperty::toLanguageTag
192
    );
193
194
  private final Map<Key, SetProperty<?>> SETS = Map.ofEntries(
195
    entry(
196
      KEY_UI_FILES_PATH,
197
      new SimpleSetProperty<>( observableSet( new HashSet<>() ) )
198
    )
199
  );
200
201
  /**
202
   * Creates a new {@link Workspace} that will attempt to load a configuration
203
   * file. If the configuration file cannot be loaded, the workspace settings
204
   * will return default values. This allows unit tests to provide an instance
205
   * of {@link Workspace} when necessary without encountering failures.
206
   */
207
  public Workspace() {
208
    load();
209
  }
210
211
  /**
212
   * Creates an instance of {@link ObservableList} that is based on a
213
   * modifiable observable array list for the given items.
214
   *
215
   * @param items The items to wrap in an observable list.
216
   * @param <E>   The type of items to add to the list.
217
   * @return An observable property that can have its contents modified.
218
   */
219
  public static <E> ObservableList<E> listProperty( final Set<E> items ) {
220
    return new SimpleListProperty<>( observableArrayList( items ) );
221
  }
222
223
  /**
224
   * Returns a value that represents a setting in the application that the user
225
   * may configure, either directly or indirectly.
226
   *
227
   * @param key The reference to the users' preference stored in deference
228
   *            of app reëntrance.
229
   * @return An observable property to be persisted.
230
   */
231
  @SuppressWarnings( "unchecked" )
232
  public <T, U extends Property<T>> U valuesProperty( final Key key ) {
233
    // The type that goes into the map must come out.
234
    return (U) VALUES.get( key );
235
  }
236
237
  /**
238
   * Returns a list of values that represent a setting in the application that
239
   * the user may configure, either directly or indirectly. The property
240
   * returned is backed by a mutable {@link Set}.
241
   *
242
   * @param key The {@link Key} associated with a preference value.
243
   * @return An observable property to be persisted.
244
   */
245
  @SuppressWarnings( "unchecked" )
246
  public <T> SetProperty<T> setsProperty( final Key key ) {
247
    // The type that goes into the map must come out.
248
    return (SetProperty<T>) SETS.get( key );
249
  }
250
251
  /**
252
   * Returns the {@link Boolean} preference value associated with the given
253
   * {@link Key}. The caller must be sure that the given {@link Key} is
254
   * associated with a value that matches the return type.
255
   *
256
   * @param key The {@link Key} associated with a preference value.
257
   * @return The value associated with the given {@link Key}.
258
   */
259
  public boolean toBoolean( final Key key ) {
260
    return (Boolean) valuesProperty( key ).getValue();
261
  }
262
263
  /**
264
   * Returns the {@link Double} preference value associated with the given
265
   * {@link Key}. The caller must be sure that the given {@link Key} is
266
   * associated with a value that matches the return type.
267
   *
268
   * @param key The {@link Key} associated with a preference value.
269
   * @return The value associated with the given {@link Key}.
270
   */
271
  public double toDouble( final Key key ) {
272
    return (Double) valuesProperty( key ).getValue();
273
  }
274
275
  public File toFile( final Key key ) {
276
    return fileProperty( key ).get();
277
  }
278
279
  public String toString( final Key key ) {
280
    return stringProperty( key ).get();
281
  }
282
283
  public Tokens toTokens( final Key began, final Key ended ) {
284
    return new Tokens( stringProperty( began ), stringProperty( ended ) );
285
  }
286
287
  @SuppressWarnings( "SameParameterValue" )
288
  public DoubleProperty doubleProperty( final Key key ) {
289
    return valuesProperty( key );
290
  }
291
292
  /**
293
   * Returns the {@link File} {@link Property} associated with the given
294
   * {@link Key} from the internal list of preference values. The caller
295
   * must be sure that the given {@link Key} is associated with a {@link File}
296
   * {@link Property}.
297
   *
298
   * @param key The {@link Key} associated with a preference value.
299
   * @return The value associated with the given {@link Key}.
300
   */
301
  public ObjectProperty<File> fileProperty( final Key key ) {
302
    return valuesProperty( key );
303
  }
304
305
  public LocaleProperty localeProperty( final Key key ) {
306
    return valuesProperty( key );
307
  }
308
309
  /**
310
   * Returns the language locale setting for the {@link #KEY_LANG_LOCALE} key.
311
   *
312
   * @return The user's current locale setting.
313
   */
314
  public Locale getLocale() {
315
    return localeProperty( KEY_LANG_LOCALE ).toLocale();
316
  }
317
318
  public StringProperty stringProperty( final Key key ) {
319
    return valuesProperty( key );
320
  }
321
322
  public void loadValueKeys( final Consumer<Key> consumer ) {
323
    VALUES.keySet().forEach( consumer );
324
  }
325
326
  public void loadSetKeys( final Consumer<Key> consumer ) {
327
    SETS.keySet().forEach( consumer );
328
  }
329
330
  /**
331
   * Calls the given consumer for all single-value keys. For lists, see
332
   * {@link #saveSets(BiConsumer)}.
333
   *
334
   * @param consumer Called to accept each preference key value.
335
   */
336
  public void saveValues( final BiConsumer<Key, Property<?>> consumer ) {
337
    VALUES.forEach( consumer );
338
  }
339
340
  /**
341
   * Calls the given consumer for all multi-value keys. For single items, see
342
   * {@link #saveValues(BiConsumer)}. Callers are responsible for iterating
343
   * over the list of items retrieved through this method.
344
   *
345
   * @param consumer Called to accept each preference key list.
346
   */
347
  public void saveSets( final BiConsumer<Key, SetProperty<?>> consumer ) {
348
    SETS.forEach( consumer );
349
  }
350
351
  /**
352
   * Delegates to {@link #listen(Key, ReadOnlyProperty, BooleanSupplier)},
353
   * providing a value of {@code true} for the {@link BooleanSupplier} to
354
   * indicate the property changes always take effect.
355
   *
356
   * @param key      The value to bind to the internal key property.
357
   * @param property The external property value that sets the internal value.
358
   */
359
  public <T> void listen( final Key key, final ReadOnlyProperty<T> property ) {
360
    listen( key, property, () -> true );
361
  }
362
363
  /**
364
   * Binds a read-only property to a value in the preferences. This allows
365
   * user interface properties to change and the preferences will be
366
   * synchronized automatically.
367
   * <p>
368
   * This calls {@link Platform#runLater(Runnable)} to ensure that all pending
369
   * application window states are finished before assessing whether property
370
   * changes should be applied. Without this, exiting the application while the
371
   * window is maximized would persist the window's maximum dimensions,
372
   * preventing restoration to its prior, non-maximum size.
373
   * </p>
374
   *
375
   * @param key      The value to bind to the internal key property.
376
   * @param property The external property value that sets the internal value.
377
   * @param enabled  Indicates whether property changes should be applied.
378
   */
379
  public <T> void listen(
380
    final Key key,
381
    final ReadOnlyProperty<T> property,
382
    final BooleanSupplier enabled ) {
383
    property.addListener(
384
      ( c, o, n ) -> runLater( () -> {
385
        if( enabled.getAsBoolean() ) {
386
          valuesProperty( key ).setValue( n );
387
        }
388
      } )
389
    );
390
  }
391
392
  /**
393
   * Saves the current workspace.
394
   */
395
  public void save() {
396
    try {
397
      final var config = new XMLConfiguration();
398
399
      // The root config key can only be set for an empty configuration file.
400
      config.setRootElementName( APP_TITLE_LOWERCASE );
401
      valuesProperty( KEY_META_VERSION ).setValue( getVersion() );
402
403
      saveValues( ( key, property ) ->
404
                    config.setProperty( key.toString(), marshall( property ) )
405
      );
406
407
      saveSets( ( key, set ) -> {
408
        final var keyName = key.toString();
409
        set.forEach( ( value ) -> config.addProperty( keyName, value ) );
410
      } );
411
      new FileHandler( config ).save( FILE_PREFERENCES );
412
    } catch( final Exception ex ) {
413
      clue( ex );
414
    }
415
  }
416
417
  /**
418
   * Attempts to load the {@link Constants#FILE_PREFERENCES} configuration file.
419
   * If not found, this will fall back to an empty configuration file, leaving
420
   * the application to fill in default values.
421
   */
422
  private void load() {
423
    try {
424
      final var config = new Configurations().xml( FILE_PREFERENCES );
62
public final class Workspace {
63
  private static final Key KEY_ROOT = key( "workspace" );
64
65
  public static final Key KEY_META = key( KEY_ROOT, "meta" );
66
  public static final Key KEY_META_NAME = key( KEY_META, "name" );
67
  public static final Key KEY_META_VERSION = key( KEY_META, "version" );
68
69
  public static final Key KEY_R = key( KEY_ROOT, "r" );
70
  public static final Key KEY_R_SCRIPT = key( KEY_R, "script" );
71
  public static final Key KEY_R_DIR = key( KEY_R, "dir" );
72
  public static final Key KEY_R_DELIM = key( KEY_R, "delimiter" );
73
  public static final Key KEY_R_DELIM_BEGAN = key( KEY_R_DELIM, "began" );
74
  public static final Key KEY_R_DELIM_ENDED = key( KEY_R_DELIM, "ended" );
75
76
  public static final Key KEY_IMAGES = key( KEY_ROOT, "images" );
77
  public static final Key KEY_IMAGES_DIR = key( KEY_IMAGES, "dir" );
78
  public static final Key KEY_IMAGES_ORDER = key( KEY_IMAGES, "order" );
79
80
  public static final Key KEY_DEF = key( KEY_ROOT, "definition" );
81
  public static final Key KEY_DEF_PATH = key( KEY_DEF, "path" );
82
  public static final Key KEY_DEF_DELIM = key( KEY_DEF, "delimiter" );
83
  public static final Key KEY_DEF_DELIM_BEGAN = key( KEY_DEF_DELIM, "began" );
84
  public static final Key KEY_DEF_DELIM_ENDED = key( KEY_DEF_DELIM, "ended" );
85
86
  //@formatter:off
87
  public static final Key KEY_UI = key( KEY_ROOT, "ui" );
88
89
  public static final Key KEY_UI_RECENT = key( KEY_UI, "recent" );
90
  public static final Key KEY_UI_RECENT_DIR = key( KEY_UI_RECENT, "dir" );
91
  public static final Key KEY_UI_RECENT_DOCUMENT = key( KEY_UI_RECENT,"document" );
92
  public static final Key KEY_UI_RECENT_DEFINITION = key( KEY_UI_RECENT, "definition" );
93
94
  public static final Key KEY_UI_FILES = key( KEY_UI, "files" );
95
  public static final Key KEY_UI_FILES_PATH = key( KEY_UI_FILES, "path" );
96
97
  public static final Key KEY_UI_FONT = key( KEY_UI, "font" );
98
  public static final Key KEY_UI_FONT_EDITOR = key( KEY_UI_FONT, "editor" );
99
  public static final Key KEY_UI_FONT_EDITOR_NAME = key( KEY_UI_FONT_EDITOR, "name" );
100
  public static final Key KEY_UI_FONT_EDITOR_SIZE = key( KEY_UI_FONT_EDITOR, "size" );
101
  public static final Key KEY_UI_FONT_PREVIEW = key( KEY_UI_FONT, "preview" );
102
  public static final Key KEY_UI_FONT_PREVIEW_NAME = key( KEY_UI_FONT_PREVIEW, "name" );
103
  public static final Key KEY_UI_FONT_PREVIEW_SIZE = key( KEY_UI_FONT_PREVIEW, "size" );
104
  public static final Key KEY_UI_FONT_PREVIEW_MONO = key( KEY_UI_FONT_PREVIEW, "mono" );
105
  public static final Key KEY_UI_FONT_PREVIEW_MONO_NAME = key( KEY_UI_FONT_PREVIEW_MONO, "name" );
106
  public static final Key KEY_UI_FONT_PREVIEW_MONO_SIZE = key( KEY_UI_FONT_PREVIEW_MONO, "size" );
107
108
  public static final Key KEY_LANGUAGE = key( KEY_ROOT, "language" );
109
  public static final Key KEY_LANG_LOCALE = key( KEY_LANGUAGE, "locale" );
110
111
  public static final Key KEY_UI_WINDOW = key( KEY_UI, "window" );
112
  public static final Key KEY_UI_WINDOW_X = key( KEY_UI_WINDOW, "x" );
113
  public static final Key KEY_UI_WINDOW_Y = key( KEY_UI_WINDOW, "y" );
114
  public static final Key KEY_UI_WINDOW_W = key( KEY_UI_WINDOW, "width" );
115
  public static final Key KEY_UI_WINDOW_H = key( KEY_UI_WINDOW, "height" );
116
  public static final Key KEY_UI_WINDOW_MAX = key( KEY_UI_WINDOW, "maximized" );
117
  public static final Key KEY_UI_WINDOW_FULL = key( KEY_UI_WINDOW, "full" );
118
119
  private final Map<Key, Property<?>> VALUES = Map.ofEntries(
120
    entry( KEY_META_VERSION, asStringProperty( getVersion() ) ),
121
    entry( KEY_META_NAME, asStringProperty( "default" ) ),
122
    
123
    entry( KEY_R_SCRIPT, asStringProperty( "" ) ),
124
    entry( KEY_R_DIR, asFileProperty( USER_DIRECTORY ) ),
125
    entry( KEY_R_DELIM_BEGAN, asStringProperty( R_DELIM_BEGAN_DEFAULT ) ),
126
    entry( KEY_R_DELIM_ENDED, asStringProperty( R_DELIM_ENDED_DEFAULT ) ),
127
    
128
    entry( KEY_IMAGES_DIR, asFileProperty( USER_DIRECTORY ) ),
129
    entry( KEY_IMAGES_ORDER, asStringProperty( PERSIST_IMAGES_DEFAULT ) ),
130
    
131
    entry( KEY_DEF_PATH, asFileProperty( DEFINITION_DEFAULT ) ),
132
    entry( KEY_DEF_DELIM_BEGAN, asStringProperty( DEF_DELIM_BEGAN_DEFAULT ) ),
133
    entry( KEY_DEF_DELIM_ENDED, asStringProperty( DEF_DELIM_ENDED_DEFAULT ) ),
134
    
135
    entry( KEY_UI_RECENT_DIR, asFileProperty( USER_DIRECTORY ) ),
136
    entry( KEY_UI_RECENT_DOCUMENT, asFileProperty( DOCUMENT_DEFAULT ) ),
137
    entry( KEY_UI_RECENT_DEFINITION, asFileProperty( DEFINITION_DEFAULT ) ),
138
    
139
    entry( KEY_LANG_LOCALE, asLocaleProperty( LOCALE_DEFAULT ) ),
140
    entry( KEY_UI_FONT_EDITOR_NAME, asStringProperty( FONT_NAME_EDITOR_DEFAULT ) ),
141
    entry( KEY_UI_FONT_EDITOR_SIZE, asDoubleProperty( FONT_SIZE_EDITOR_DEFAULT ) ),
142
    entry( KEY_UI_FONT_PREVIEW_NAME, asStringProperty( FONT_NAME_PREVIEW_DEFAULT ) ),
143
    entry( KEY_UI_FONT_PREVIEW_SIZE, asDoubleProperty( FONT_SIZE_PREVIEW_DEFAULT ) ),
144
    entry( KEY_UI_FONT_PREVIEW_MONO_NAME, asStringProperty( FONT_NAME_PREVIEW_MONO_NAME_DEFAULT ) ),
145
    entry( KEY_UI_FONT_PREVIEW_MONO_SIZE, asDoubleProperty( FONT_SIZE_PREVIEW_MONO_SIZE_DEFAULT ) ),
146
147
    entry( KEY_UI_WINDOW_X, asDoubleProperty( WINDOW_X_DEFAULT ) ),
148
    entry( KEY_UI_WINDOW_Y, asDoubleProperty( WINDOW_Y_DEFAULT ) ),
149
    entry( KEY_UI_WINDOW_W, asDoubleProperty( WINDOW_W_DEFAULT ) ),
150
    entry( KEY_UI_WINDOW_H, asDoubleProperty( WINDOW_H_DEFAULT ) ),
151
    entry( KEY_UI_WINDOW_MAX, asBooleanProperty() ),
152
    entry( KEY_UI_WINDOW_FULL, asBooleanProperty() )
153
  );
154
  //@formatter:on
155
156
  private StringProperty asStringProperty( final String defaultValue ) {
157
    return new SimpleStringProperty( defaultValue );
158
  }
159
160
  private DoubleProperty asDoubleProperty( final double defaultValue ) {
161
    return new SimpleDoubleProperty( defaultValue );
162
  }
163
164
  private BooleanProperty asBooleanProperty() {
165
    return new SimpleBooleanProperty();
166
  }
167
168
  private FileProperty asFileProperty( final File defaultValue ) {
169
    return new FileProperty( defaultValue );
170
  }
171
172
  @SuppressWarnings( "SameParameterValue" )
173
  private LocaleProperty asLocaleProperty( final Locale defaultValue ) {
174
    return new LocaleProperty( defaultValue );
175
  }
176
177
  /**
178
   * Helps instantiate {@link Property} instances for XML configuration items.
179
   */
180
  private static final Map<Class<?>, Function<String, Object>> UNMARSHALL =
181
    Map.of(
182
      LocaleProperty.class, LocaleProperty::parseLocale,
183
      SimpleBooleanProperty.class, Boolean::parseBoolean,
184
      SimpleDoubleProperty.class, Double::parseDouble,
185
      SimpleFloatProperty.class, Float::parseFloat,
186
      FileProperty.class, File::new
187
    );
188
189
  private static final Map<Class<?>, Function<String, Object>> MARSHALL =
190
    Map.of(
191
      LocaleProperty.class, LocaleProperty::toLanguageTag
192
    );
193
194
  private final Map<Key, SetProperty<?>> SETS = Map.ofEntries(
195
    entry(
196
      KEY_UI_FILES_PATH,
197
      new SimpleSetProperty<>( observableSet( new HashSet<>() ) )
198
    )
199
  );
200
201
  /**
202
   * Creates a new {@link Workspace} that will attempt to load a configuration
203
   * file. If the configuration file cannot be loaded, the workspace settings
204
   * will return default values. This allows unit tests to provide an instance
205
   * of {@link Workspace} when necessary without encountering failures.
206
   */
207
  public Workspace() {
208
    load( FILE_PREFERENCES );
209
  }
210
211
  /**
212
   * Creates a new {@link Workspace} that will attempt to load the given
213
   * configuration file.
214
   *
215
   * @param filename The file to load.
216
   */
217
  public Workspace( final String filename ) {
218
    load( filename );
219
  }
220
221
  /**
222
   * Creates an instance of {@link ObservableList} that is based on a
223
   * modifiable observable array list for the given items.
224
   *
225
   * @param items The items to wrap in an observable list.
226
   * @param <E>   The type of items to add to the list.
227
   * @return An observable property that can have its contents modified.
228
   */
229
  public static <E> ObservableList<E> listProperty( final Set<E> items ) {
230
    return new SimpleListProperty<>( observableArrayList( items ) );
231
  }
232
233
  /**
234
   * Returns a value that represents a setting in the application that the user
235
   * may configure, either directly or indirectly.
236
   *
237
   * @param key The reference to the users' preference stored in deference
238
   *            of app reëntrance.
239
   * @return An observable property to be persisted.
240
   */
241
  @SuppressWarnings( "unchecked" )
242
  public <T, U extends Property<T>> U valuesProperty( final Key key ) {
243
    // The type that goes into the map must come out.
244
    return (U) VALUES.get( key );
245
  }
246
247
  /**
248
   * Returns a list of values that represent a setting in the application that
249
   * the user may configure, either directly or indirectly. The property
250
   * returned is backed by a mutable {@link Set}.
251
   *
252
   * @param key The {@link Key} associated with a preference value.
253
   * @return An observable property to be persisted.
254
   */
255
  @SuppressWarnings( "unchecked" )
256
  public <T> SetProperty<T> setsProperty( final Key key ) {
257
    // The type that goes into the map must come out.
258
    return (SetProperty<T>) SETS.get( key );
259
  }
260
261
  /**
262
   * Returns the {@link Boolean} preference value associated with the given
263
   * {@link Key}. The caller must be sure that the given {@link Key} is
264
   * associated with a value that matches the return type.
265
   *
266
   * @param key The {@link Key} associated with a preference value.
267
   * @return The value associated with the given {@link Key}.
268
   */
269
  public boolean toBoolean( final Key key ) {
270
    return (Boolean) valuesProperty( key ).getValue();
271
  }
272
273
  /**
274
   * Returns the {@link Double} preference value associated with the given
275
   * {@link Key}. The caller must be sure that the given {@link Key} is
276
   * associated with a value that matches the return type.
277
   *
278
   * @param key The {@link Key} associated with a preference value.
279
   * @return The value associated with the given {@link Key}.
280
   */
281
  public double toDouble( final Key key ) {
282
    return (Double) valuesProperty( key ).getValue();
283
  }
284
285
  public File toFile( final Key key ) {
286
    return fileProperty( key ).get();
287
  }
288
289
  public String toString( final Key key ) {
290
    return stringProperty( key ).get();
291
  }
292
293
  public Tokens toTokens( final Key began, final Key ended ) {
294
    return new Tokens( stringProperty( began ), stringProperty( ended ) );
295
  }
296
297
  @SuppressWarnings( "SameParameterValue" )
298
  public DoubleProperty doubleProperty( final Key key ) {
299
    return valuesProperty( key );
300
  }
301
302
  /**
303
   * Returns the {@link File} {@link Property} associated with the given
304
   * {@link Key} from the internal list of preference values. The caller
305
   * must be sure that the given {@link Key} is associated with a {@link File}
306
   * {@link Property}.
307
   *
308
   * @param key The {@link Key} associated with a preference value.
309
   * @return The value associated with the given {@link Key}.
310
   */
311
  public ObjectProperty<File> fileProperty( final Key key ) {
312
    return valuesProperty( key );
313
  }
314
315
  public LocaleProperty localeProperty( final Key key ) {
316
    return valuesProperty( key );
317
  }
318
319
  /**
320
   * Returns the language locale setting for the {@link #KEY_LANG_LOCALE} key.
321
   *
322
   * @return The user's current locale setting.
323
   */
324
  public Locale getLocale() {
325
    return localeProperty( KEY_LANG_LOCALE ).toLocale();
326
  }
327
328
  public StringProperty stringProperty( final Key key ) {
329
    return valuesProperty( key );
330
  }
331
332
  public void loadValueKeys( final Consumer<Key> consumer ) {
333
    VALUES.keySet().forEach( consumer );
334
  }
335
336
  public void loadSetKeys( final Consumer<Key> consumer ) {
337
    SETS.keySet().forEach( consumer );
338
  }
339
340
  /**
341
   * Calls the given consumer for all single-value keys. For lists, see
342
   * {@link #saveSets(BiConsumer)}.
343
   *
344
   * @param consumer Called to accept each preference key value.
345
   */
346
  public void saveValues( final BiConsumer<Key, Property<?>> consumer ) {
347
    VALUES.forEach( consumer );
348
  }
349
350
  /**
351
   * Calls the given consumer for all multi-value keys. For single items, see
352
   * {@link #saveValues(BiConsumer)}. Callers are responsible for iterating
353
   * over the list of items retrieved through this method.
354
   *
355
   * @param consumer Called to accept each preference key list.
356
   */
357
  public void saveSets( final BiConsumer<Key, SetProperty<?>> consumer ) {
358
    SETS.forEach( consumer );
359
  }
360
361
  /**
362
   * Delegates to {@link #listen(Key, ReadOnlyProperty, BooleanSupplier)},
363
   * providing a value of {@code true} for the {@link BooleanSupplier} to
364
   * indicate the property changes always take effect.
365
   *
366
   * @param key      The value to bind to the internal key property.
367
   * @param property The external property value that sets the internal value.
368
   */
369
  public <T> void listen( final Key key, final ReadOnlyProperty<T> property ) {
370
    listen( key, property, () -> true );
371
  }
372
373
  /**
374
   * Binds a read-only property to a value in the preferences. This allows
375
   * user interface properties to change and the preferences will be
376
   * synchronized automatically.
377
   * <p>
378
   * This calls {@link Platform#runLater(Runnable)} to ensure that all pending
379
   * application window states are finished before assessing whether property
380
   * changes should be applied. Without this, exiting the application while the
381
   * window is maximized would persist the window's maximum dimensions,
382
   * preventing restoration to its prior, non-maximum size.
383
   * </p>
384
   *
385
   * @param key      The value to bind to the internal key property.
386
   * @param property The external property value that sets the internal value.
387
   * @param enabled  Indicates whether property changes should be applied.
388
   */
389
  public <T> void listen(
390
    final Key key,
391
    final ReadOnlyProperty<T> property,
392
    final BooleanSupplier enabled ) {
393
    property.addListener(
394
      ( c, o, n ) -> runLater( () -> {
395
        if( enabled.getAsBoolean() ) {
396
          valuesProperty( key ).setValue( n );
397
        }
398
      } )
399
    );
400
  }
401
402
  /**
403
   * Saves the current workspace.
404
   */
405
  public void save() {
406
    try {
407
      final var config = new XMLConfiguration();
408
409
      // The root config key can only be set for an empty configuration file.
410
      config.setRootElementName( APP_TITLE_LOWERCASE );
411
      valuesProperty( KEY_META_VERSION ).setValue( getVersion() );
412
413
      saveValues( ( key, property ) ->
414
                    config.setProperty( key.toString(), marshall( property ) )
415
      );
416
417
      saveSets( ( key, set ) -> {
418
        final var keyName = key.toString();
419
        set.forEach( ( value ) -> config.addProperty( keyName, value ) );
420
      } );
421
      new FileHandler( config ).save( FILE_PREFERENCES );
422
    } catch( final Exception ex ) {
423
      clue( ex );
424
    }
425
  }
426
427
  /**
428
   * Attempts to load the {@link Constants#FILE_PREFERENCES} configuration file.
429
   * If not found, this will fall back to an empty configuration file, leaving
430
   * the application to fill in default values.
431
   *
432
   * @param filename The file containing user preferences to load.
433
   */
434
  private void load( final String filename ) {
435
    try {
436
      final var config = new Configurations().xml( filename );
425437
426438
      loadValueKeys( ( key ) -> {
M src/main/java/com/keenwrite/preferences/XmlStorageHandler.java
1616
 * </p>
1717
 */
18
public class XmlStorageHandler implements StorageHandler {
18
public final class XmlStorageHandler implements StorageHandler {
1919
  @Override
2020
  public void saveSelectedCategory( final String breadcrumb ) { }
M src/main/java/com/keenwrite/preview/ChainedReplacedElementFactory.java
4141
 * the HTML document prior to displaying it.
4242
 */
43
public class ChainedReplacedElementFactory extends ReplacedElementAdapter {
43
public final class ChainedReplacedElementFactory extends ReplacedElementAdapter {
4444
  /**
4545
   * Retain insertion order so that client classes can control the order that
M src/main/java/com/keenwrite/preview/DomConverter.java
2323
 * builders, and implementations.
2424
 */
25
class DomConverter extends W3CDom {
25
final class DomConverter extends W3CDom {
2626
  /**
2727
   * Retain insertion order using an instance of {@link LinkedHashMap} so
M src/main/java/com/keenwrite/preview/HtmlPanel.java
2828
 * Responsible for configuring FlyingSaucer's {@link XHTMLPanel}.
2929
 */
30
public class HtmlPanel extends XHTMLPanel {
30
public final class HtmlPanel extends XHTMLPanel {
3131
3232
  /**
M src/main/java/com/keenwrite/preview/MathRenderer.java
1313
 * Responsible for rendering formulas as scalable vector graphics (SVG).
1414
 */
15
public class MathRenderer {
15
public final class MathRenderer {
1616
1717
  /**
M src/main/java/com/keenwrite/preview/RenderingSettings.java
1313
 */
1414
@SuppressWarnings("rawtypes")
15
public class RenderingSettings {
15
public final class RenderingSettings {
1616
1717
  /**
M src/main/java/com/keenwrite/preview/SvgRasterizer.java
3636
 * Responsible for converting SVG images into rasterized PNG images.
3737
 */
38
public class SvgRasterizer {
38
public final class SvgRasterizer {
3939
  private static final SAXSVGDocumentFactory FACTORY_DOM =
4040
      new SAXSVGDocumentFactory( getXMLParserClassName() );
M src/main/java/com/keenwrite/preview/SvgReplacedElementFactory.java
1414
import java.awt.image.BufferedImage;
1515
import java.net.URI;
16
import java.nio.file.Paths;
16
import java.nio.file.Path;
1717
1818
import static com.keenwrite.StatusNotifier.clue;
...
2828
 * a document to transform them into rasterized versions.
2929
 */
30
public class SvgReplacedElementFactory extends ReplacedElementAdapter {
30
public final class SvgReplacedElementFactory extends ReplacedElementAdapter {
3131
3232
  public static final String HTML_IMAGE = "img";
...
6969
          else if( isSvg( MediaType.valueFrom( source ) ) ) {
7070
            // Attempt to rasterize based on file name.
71
            final var base = new URI( getBaseUri( e ) ).getPath();
72
            uri = Paths.get( base, source ).toUri();
71
            final var path = Path.of( new URI( source ).getPath() );
72
73
            if( path.isAbsolute() ) {
74
              uri = path.toUri();
75
            }
76
            else {
77
              final var base = new URI( getBaseUri( e ) ).getPath();
78
              uri = Path.of( base, source ).toUri();
79
            }
7380
          }
7481
M src/main/java/com/keenwrite/processors/HtmlPreviewProcessor.java
1111
 * chain.
1212
 */
13
public class HtmlPreviewProcessor extends ExecutorProcessor<String> {
13
public final class HtmlPreviewProcessor extends ExecutorProcessor<String> {
1414
1515
  /**
M src/main/java/com/keenwrite/processors/IdentityProcessor.java
66
 * end of a processing chain when no more processing is required.
77
 */
8
public class IdentityProcessor extends ExecutorProcessor<String> {
8
public final class IdentityProcessor extends ExecutorProcessor<String> {
99
  public static final IdentityProcessor IDENTITY = new IdentityProcessor();
1010
D src/main/java/com/keenwrite/processors/InlineRProcessor.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.processors;
3
4
import com.keenwrite.preferences.Workspace;
5
import com.keenwrite.processors.markdown.extensions.r.ROutputProcessor;
6
import com.keenwrite.util.BoundedCache;
7
import javafx.beans.property.Property;
8
9
import javax.script.ScriptEngine;
10
import javax.script.ScriptEngineManager;
11
import java.io.File;
12
import java.nio.file.Path;
13
import java.util.Map;
14
import java.util.concurrent.atomic.AtomicBoolean;
15
16
import static com.keenwrite.Constants.STATUS_PARSE_ERROR;
17
import static com.keenwrite.Messages.get;
18
import static com.keenwrite.StatusNotifier.clue;
19
import static com.keenwrite.preferences.Workspace.*;
20
import static com.keenwrite.processors.text.TextReplacementFactory.replace;
21
import static com.keenwrite.sigils.RSigilOperator.PREFIX;
22
import static com.keenwrite.sigils.RSigilOperator.SUFFIX;
23
import static java.lang.Math.max;
24
import static java.lang.Math.min;
25
26
/**
27
 * Transforms a document containing R statements into Markdown.
28
 */
29
public final class InlineRProcessor extends DefinitionProcessor {
30
  private final Processor<String> mPostProcessor = new ROutputProcessor();
31
32
  /**
33
   * Where to put document inline evaluated R expressions, constrained to
34
   * avoid running out of memory.
35
   */
36
  private final Map<String, String> mEvalCache =
37
    new BoundedCache<>( 512 );
38
39
  private static final ScriptEngine ENGINE =
40
    (new ScriptEngineManager()).getEngineByName( "Renjin" );
41
42
  private static final int PREFIX_LENGTH = PREFIX.length();
43
44
  private final AtomicBoolean mDirty = new AtomicBoolean( false );
45
46
  private final Workspace mWorkspace;
47
48
  /**
49
   * Constructs a processor capable of evaluating R statements.
50
   *
51
   * @param successor Subsequent link in the processing chain.
52
   * @param context   Contains resolved definitions map.
53
   */
54
  public InlineRProcessor(
55
    final Processor<String> successor,
56
    final ProcessorContext context ) {
57
    super( successor, context );
58
59
    mWorkspace = context.getWorkspace();
60
61
    bootstrapScriptProperty().addListener(
62
      ( __, oldScript, newScript ) -> setDirty( true ) );
63
    workingDirectoryProperty().addListener(
64
      ( __, oldScript, newScript ) -> setDirty( true ) );
65
66
    // TODO: Watch the "R" property keys in the workspace, directly.
67
68
    // If the user saves the preferences, make sure that any R-related settings
69
    // changes are applied.
70
//    getWorkspace().addSaveEventHandler( ( handler ) -> {
71
//      if( isDirty() ) {
72
//        init();
73
//        setDirty( false );
74
//      }
75
//    } );
76
77
    init();
78
  }
79
80
  /**
81
   * Initialises the R code so that R can find imported libraries. Note that
82
   * any existing R functionality will not be overwritten if this method is
83
   * called multiple times.
84
   *
85
   * @return {@code true} if initialization completed and all variables were
86
   * replaced; {@code false} if any variables remain.
87
   */
88
  public boolean init() {
89
    final var bootstrap = getBootstrapScript();
90
91
    if( !bootstrap.isBlank() ) {
92
      final var wd = getWorkingDirectory();
93
      final var dir = wd.toString().replace( '\\', '/' );
94
      final var map = getDefinitions();
95
      final var defBegan = mWorkspace.toString( KEY_DEF_DELIM_BEGAN );
96
      final var defEnded = mWorkspace.toString( KEY_DEF_DELIM_ENDED );
97
98
      map.put( defBegan + "application.r.working.directory" + defEnded, dir );
99
100
      final var replaced = replace( bootstrap, map );
101
      final var bIndex = replaced.indexOf( defBegan );
102
103
      //
104
      if( bIndex >= 0 ) {
105
        var eIndex = replaced.indexOf( defEnded );
106
        eIndex = (eIndex == -1) ? replaced.length() - 1 : max( bIndex, eIndex );
107
108
        final var def = replaced.substring( bIndex, eIndex );
109
        clue( "Main.status.error.bootstrap.eval", def );
110
111
        return false;
112
      }
113
      else {
114
        eval( replaced );
115
      }
116
    }
117
118
    return true;
119
  }
120
121
  /**
122
   * Empties the cache.
123
   */
124
  public void clear() {
125
    mEvalCache.clear();
126
  }
127
128
  /**
129
   * Sets the dirty flag to indicate that the bootstrap script or working
130
   * directory has been modified. Upon saving the preferences, if this flag
131
   * is true, then {@link #init()} will be called to reload the R environment.
132
   *
133
   * @param dirty Set to true to reload changes upon closing preferences.
134
   */
135
  private void setDirty( final boolean dirty ) {
136
    mDirty.set( dirty );
137
  }
138
139
  /**
140
   * Answers whether R-related settings have been modified.
141
   *
142
   * @return {@code true} when the settings have changed.
143
   */
144
  private boolean isDirty() {
145
    return mDirty.get();
146
  }
147
148
  /**
149
   * Evaluates all R statements in the source document and inserts the
150
   * calculated value into the generated document.
151
   *
152
   * @param text The document text that includes variables that should be
153
   *             replaced with values when rendered as HTML.
154
   * @return The generated document with output from all R statements
155
   * substituted with value returned from their execution.
156
   */
157
  @Override
158
  public String apply( final String text ) {
159
    final int length = text.length();
160
161
    // The * 2 is a wild guess at the ratio of R statements to the length
162
    // of text produced by those statements.
163
    final StringBuilder sb = new StringBuilder( length * 2 );
164
165
    int prevIndex = 0;
166
    int currIndex = text.indexOf( PREFIX );
167
168
    while( currIndex >= 0 ) {
169
      // Copy everything up to, but not including, the opening token.
170
      sb.append( text, prevIndex, currIndex );
171
172
      // Jump to the start of the R statement.
173
      prevIndex = currIndex + PREFIX_LENGTH;
174
175
      // Find the closing token, without indexing past the text boundary.
176
      currIndex = text.indexOf( SUFFIX, min( currIndex + 1, length ) );
177
178
      // Only evaluate inline R statements that have end delimiters.
179
      if( currIndex > 1 ) {
180
        // Extract the inline R statement to be evaluated.
181
        final var r = text.substring( prevIndex, currIndex );
182
183
        // Pass the R statement into the R engine for evaluation.
184
        try {
185
          // Append the string representation of the result into the text.
186
          sb.append( evalCached( r ) );
187
        } catch( final Exception ex ) {
188
          // Inform the user that there was a problem.
189
          clue( STATUS_PARSE_ERROR, ex.getMessage(), currIndex );
190
191
          // If the string couldn't be parsed using R, append the statement
192
          // that failed to parse, instead of its evaluated value.
193
          sb.append( PREFIX ).append( r ).append( SUFFIX );
194
        }
195
196
        // Retain the R statement's ending position in the text.
197
        prevIndex = currIndex + 1;
198
      }
199
200
      // Find the start of the next inline R statement.
201
      currIndex = text.indexOf( PREFIX, min( currIndex + 1, length ) );
202
    }
203
204
    // Copy from the previous index to the end of the string.
205
    return sb.append( text.substring( min( prevIndex, length ) ) ).toString();
206
  }
207
208
  /**
209
   * Look up an R expression from the cache then return the resulting object.
210
   * If the R expression hasn't been cached, it'll first be evaluated.
211
   *
212
   * @param r The expression to evaluate.
213
   * @return The object resulting from the evaluation.
214
   */
215
  private String evalCached( final String r ) {
216
    return mEvalCache.computeIfAbsent( r, __ -> evalHtml( r ) );
217
  }
218
219
  /**
220
   * Converts the given string to HTML, trimming new lines, and inlining
221
   * the text if it is a paragraph. Otherwise, the resulting HTML is most likely
222
   * complex (e.g., a Markdown table) and should be rendered as its HTML
223
   * equivalent.
224
   *
225
   * @param r The R expression to evaluate then convert to HTML.
226
   * @return The result from the R expression as an HTML element.
227
   */
228
  private String evalHtml( final String r ) {
229
    return mPostProcessor.apply( eval( r ) );
230
  }
231
232
  /**
233
   * Evaluate an R expression and return the resulting object.
234
   *
235
   * @param r The expression to evaluate.
236
   * @return The object resulting from the evaluation.
237
   */
238
  private String eval( final String r ) {
239
    try {
240
      return ENGINE.eval( r ).toString();
241
    } catch( final Exception ex ) {
242
      final var expr = r.substring( 0, min( r.length(), 50 ) );
243
      clue( get( "Main.status.error.r", expr, ex.getMessage() ), ex );
244
      return "";
245
    }
246
  }
247
248
  /**
249
   * Return the given path if not {@code null}, otherwise return the path to
250
   * the user's directory.
251
   *
252
   * @return A non-null path.
253
   */
254
  private Path getWorkingDirectory() {
255
    return workingDirectoryProperty().getValue().toPath();
256
  }
257
258
  private Property<File> workingDirectoryProperty() {
259
    return getWorkspace().fileProperty( KEY_R_DIR );
260
  }
261
262
  /**
263
   * Loads the R init script from the application's persisted preferences.
264
   *
265
   * @return A non-null string, possibly empty.
266
   */
267
  private String getBootstrapScript() {
268
    return bootstrapScriptProperty().getValue();
269
  }
270
271
  private Property<String> bootstrapScriptProperty() {
272
    return getWorkspace().valuesProperty( KEY_R_SCRIPT );
273
  }
274
275
  private Workspace getWorkspace() {
276
    return mWorkspace;
277
  }
278
}
2791
M src/main/java/com/keenwrite/processors/PreformattedProcessor.java
77
 * element.
88
 */
9
public class PreformattedProcessor extends ExecutorProcessor<String> {
9
public final class PreformattedProcessor extends ExecutorProcessor<String> {
1010
1111
  /**
M src/main/java/com/keenwrite/processors/ProcessorContext.java
1818
 * Provides a context for configuring a chain of {@link Processor} instances.
1919
 */
20
public class ProcessorContext {
20
public final class ProcessorContext {
2121
  private final HtmlPreview mHtmlPreview;
2222
  private final Map<String, String> mResolvedMap;
23
  private final Path mPath;
23
  private final Path mDocumentPath;
2424
  private final Caret mCaret;
2525
  private final ExportFormat mExportFormat;
...
3434
   * @param htmlPreview  Where to display the final (HTML) output.
3535
   * @param resolvedMap  Fully expanded interpolated strings.
36
   * @param path         Path to the document to process.
36
   * @param documentPath         Path to the document to process.
3737
   * @param caret        Location of the caret in the edited document, which is
3838
   *                     used to synchronize the scrollbars.
3939
   * @param exportFormat Indicate configuration options for export format.
4040
   */
4141
  public ProcessorContext(
4242
    final HtmlPreview htmlPreview,
4343
    final Map<String, String> resolvedMap,
44
    final Path path,
44
    final Path documentPath,
4545
    final Caret caret,
4646
    final ExportFormat exportFormat,
4747
    final Workspace workspace ) {
4848
    assert htmlPreview != null;
4949
    assert resolvedMap != null;
50
    assert path != null;
50
    assert documentPath != null;
5151
    assert caret != null;
5252
    assert exportFormat != null;
5353
    assert workspace != null;
5454
5555
    mHtmlPreview = htmlPreview;
5656
    mResolvedMap = resolvedMap;
57
    mPath = path;
57
    mDocumentPath = documentPath;
5858
    mCaret = caret;
5959
    mExportFormat = exportFormat;
...
106106
   */
107107
  public Path getBaseDir() {
108
    final var path = getPath().toAbsolutePath().getParent();
108
    final var path = getDocumentPath().toAbsolutePath().getParent();
109109
    return path == null ? DEFAULT_DIRECTORY : path;
110110
  }
111111
112
  public Path getPath() {
113
    return mPath;
112
  public Path getDocumentPath() {
113
    return mDocumentPath;
114114
  }
115115
116116
  FileType getFileType() {
117
    return lookup( getPath() );
117
    return lookup( getDocumentPath() );
118118
  }
119119
M src/main/java/com/keenwrite/processors/ProcessorFactory.java
1212
 * interpolating, and rendering known file types.
1313
 */
14
public class ProcessorFactory extends AbstractFileFactory {
14
public final class ProcessorFactory extends AbstractFileFactory {
1515
1616
  private final ProcessorContext mContext;
...
106106
  private Processor<String> createRProcessor(
107107
    final Processor<String> successor ) {
108
    final var irp = new InlineRProcessor( successor, getProcessorContext() );
109
    final var rvp = new RVariableProcessor( irp, getProcessorContext() );
110
    return MarkdownProcessor.create( rvp, getProcessorContext() );
108
    return MarkdownProcessor.create( successor, getProcessorContext() );
111109
  }
112110
D src/main/java/com/keenwrite/processors/RVariableProcessor.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.processors;
3
4
import com.keenwrite.preferences.Workspace;
5
import com.keenwrite.sigils.RSigilOperator;
6
import com.keenwrite.sigils.SigilOperator;
7
import com.keenwrite.sigils.YamlSigilOperator;
8
9
import java.util.HashMap;
10
import java.util.Map;
11
12
import static com.keenwrite.preferences.Workspace.*;
13
14
/**
15
 * Converts the keys of the resolved map from default form to R form, then
16
 * performs a substitution on the text. The default R variable syntax is
17
 * {@code v$tree$leaf}.
18
 */
19
public class RVariableProcessor extends DefinitionProcessor {
20
21
  private final SigilOperator mSigilOperator;
22
23
  public RVariableProcessor(
24
    final InlineRProcessor irp, final ProcessorContext context ) {
25
    super( irp, context );
26
    mSigilOperator = createSigilOperator( context.getWorkspace() );
27
  }
28
29
  /**
30
   * Returns the R-based version of the interpolated variable definitions.
31
   *
32
   * @return Variable names transmogrified from the default syntax to R syntax.
33
   */
34
  @Override
35
  protected Map<String, String> getDefinitions() {
36
    return entoken( super.getDefinitions() );
37
  }
38
39
  /**
40
   * Converts the given map from regular variables to R variables.
41
   *
42
   * @param map Map of variable names to values.
43
   * @return Map of R variables.
44
   */
45
  private Map<String, String> entoken( final Map<String, String> map ) {
46
    final var rMap = new HashMap<String, String>( map.size() );
47
48
    for( final var entry : map.entrySet() ) {
49
      final var key = entry.getKey();
50
      rMap.put( mSigilOperator.entoken( key ), escape( map.get( key ) ) );
51
    }
52
53
    return rMap;
54
  }
55
56
  private String escape( final String value ) {
57
    return '\'' + escape( value, '\'', "\\'" ) + '\'';
58
  }
59
60
  /**
61
   * TODO: Make generic method for replacing text.
62
   *
63
   * @param haystack Search this string for the needle, must not be null.
64
   * @param needle   The character to find in the haystack.
65
   * @param thread   Replace the needle with this text, if the needle is found.
66
   * @return The haystack with the all instances of needle replaced with thread.
67
   */
68
  @SuppressWarnings("SameParameterValue")
69
  private String escape(
70
    final String haystack, final char needle, final String thread ) {
71
    int end = haystack.indexOf( needle );
72
73
    if( end < 0 ) {
74
      return haystack;
75
    }
76
77
    final int length = haystack.length();
78
    int start = 0;
79
80
    // Replace up to 32 occurrences before the string reallocates its buffer.
81
    final var sb = new StringBuilder( length + 32 );
82
83
    while( end >= 0 ) {
84
      sb.append( haystack, start, end ).append( thread );
85
      start = end + 1;
86
      end = haystack.indexOf( needle, start );
87
    }
88
89
    return sb.append( haystack.substring( start ) ).toString();
90
  }
91
92
  private SigilOperator createSigilOperator( final Workspace workspace ) {
93
    final var tokens = workspace.toTokens(
94
      KEY_R_DELIM_BEGAN, KEY_R_DELIM_ENDED );
95
    final var antecedent = createDefinitionOperator( workspace );
96
    return new RSigilOperator( tokens, antecedent );
97
  }
98
99
  private SigilOperator createDefinitionOperator(
100
    final Workspace workspace ) {
101
    final var tokens = workspace.toTokens(
102
      KEY_DEF_DELIM_BEGAN, KEY_DEF_DELIM_ENDED );
103
    return new YamlSigilOperator( tokens );
104
  }
105
}
1061
M src/main/java/com/keenwrite/processors/XmlProcessor.java
3333
 * </p>
3434
 */
35
public class XmlProcessor extends ExecutorProcessor<String>
35
public final class XmlProcessor extends ExecutorProcessor<String>
3636
    implements ErrorListener {
3737
...
5959
      final ProcessorContext context ) {
6060
    super( successor );
61
    mPath = context.getPath();
61
    mPath = context.getDocumentPath();
6262
6363
    // Bubble problems up to the user interface, rather than standard error.
A src/main/java/com/keenwrite/processors/markdown/BaseMarkdownProcessor.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.processors.markdown;
3
4
import com.keenwrite.processors.ExecutorProcessor;
5
import com.keenwrite.processors.Processor;
6
import com.keenwrite.processors.ProcessorContext;
7
import com.keenwrite.processors.markdown.extensions.r.RExtension;
8
import com.vladsch.flexmark.ext.definition.DefinitionExtension;
9
import com.vladsch.flexmark.ext.gfm.strikethrough.StrikethroughSubscriptExtension;
10
import com.vladsch.flexmark.ext.superscript.SuperscriptExtension;
11
import com.vladsch.flexmark.ext.tables.TablesExtension;
12
import com.vladsch.flexmark.ext.typographic.TypographicExtension;
13
import com.vladsch.flexmark.html.HtmlRenderer;
14
import com.vladsch.flexmark.parser.Parser;
15
import com.vladsch.flexmark.util.ast.IParse;
16
import com.vladsch.flexmark.util.ast.IRender;
17
import com.vladsch.flexmark.util.ast.Node;
18
import com.vladsch.flexmark.util.misc.Extension;
19
20
import java.util.ArrayList;
21
import java.util.List;
22
23
/**
24
 * Responsible for parsing and rendering Markdown into HTML. This is required
25
 * to break a circular dependency between the {@link MarkdownProcessor} and
26
 * {@link RExtension}.
27
 */
28
public class BaseMarkdownProcessor extends ExecutorProcessor<String> {
29
30
  private final IParse mParser;
31
  private final IRender mRenderer;
32
33
  public BaseMarkdownProcessor(
34
    final Processor<String> successor, final ProcessorContext context ) {
35
    super( successor );
36
37
    final var extensions = new ArrayList<Extension>();
38
    init( extensions, context );
39
40
    mParser = Parser.builder().extensions( extensions ).build();
41
    mRenderer = HtmlRenderer.builder().extensions( extensions ).build();
42
  }
43
44
  /**
45
   * Instantiates a number of extensions to be applied when parsing. These
46
   * are typically typographic extensions that convert characters into
47
   * HTML entities.
48
   *
49
   * @param extensions A {@link List} of {@link Extension} instances that
50
   *                   change the {@link Parser}'s behaviour.
51
   * @param context    The context that subclasses use to configure custom
52
   *                   extension behaviour.
53
   */
54
  void init(
55
    final List<Extension> extensions, final ProcessorContext context ) {
56
    extensions.add( DefinitionExtension.create() );
57
    extensions.add( StrikethroughSubscriptExtension.create() );
58
    extensions.add( SuperscriptExtension.create() );
59
    extensions.add( TablesExtension.create() );
60
    extensions.add( TypographicExtension.create() );
61
  }
62
63
  /**
64
   * Converts the given Markdown string into HTML, without the doctype, html,
65
   * head, and body tags.
66
   *
67
   * @param markdown The string to convert from Markdown to HTML.
68
   * @return The HTML representation of the Markdown document.
69
   */
70
  @Override
71
  public String apply( final String markdown ) {
72
    return toHtml( parse( markdown ) );
73
  }
74
75
  /**
76
   * Returns the AST in the form of a node for the given Markdown document. This
77
   * can be used, for example, to determine if a hyperlink exists inside of a
78
   * paragraph.
79
   *
80
   * @param markdown The Markdown to convert into an AST.
81
   * @return The Markdown AST for the given text (usually a paragraph).
82
   */
83
  public Node toNode( final String markdown ) {
84
    return parse( markdown );
85
  }
86
87
  /**
88
   * Returns the result of converting the given AST into an HTML string.
89
   *
90
   * @param node The AST {@link Node} to convert to an HTML string.
91
   * @return The given {@link Node} as an HTML string.
92
   */
93
  public String toHtml( final Node node ) {
94
    return getRenderer().render( node );
95
  }
96
97
  /**
98
   * Helper method to create an AST given some Markdown.
99
   *
100
   * @param markdown The Markdown to parse.
101
   * @return The root node of the Markdown tree.
102
   */
103
  private Node parse( final String markdown ) {
104
    return getParser().parse( markdown );
105
  }
106
107
  /**
108
   * Creates the Markdown document processor.
109
   *
110
   * @return An instance of {@link IParse} for building abstract syntax trees.
111
   */
112
  private IParse getParser() {
113
    return mParser;
114
  }
115
116
  private IRender getRenderer() {
117
    return mRenderer;
118
  }
119
}
1120
M src/main/java/com/keenwrite/processors/markdown/MarkdownProcessor.java
33
44
import com.keenwrite.io.MediaType;
5
import com.keenwrite.processors.ExecutorProcessor;
65
import com.keenwrite.processors.Processor;
76
import com.keenwrite.processors.ProcessorContext;
87
import com.keenwrite.processors.markdown.extensions.FencedBlockExtension;
98
import com.keenwrite.processors.markdown.extensions.ImageLinkExtension;
109
import com.keenwrite.processors.markdown.extensions.caret.CaretExtension;
1110
import com.keenwrite.processors.markdown.extensions.r.RExtension;
1211
import com.keenwrite.processors.markdown.extensions.tex.TeXExtension;
13
import com.vladsch.flexmark.ext.definition.DefinitionExtension;
14
import com.vladsch.flexmark.ext.gfm.strikethrough.StrikethroughSubscriptExtension;
15
import com.vladsch.flexmark.ext.superscript.SuperscriptExtension;
16
import com.vladsch.flexmark.ext.tables.TablesExtension;
17
import com.vladsch.flexmark.ext.typographic.TypographicExtension;
18
import com.vladsch.flexmark.html.HtmlRenderer;
19
import com.vladsch.flexmark.parser.Parser;
20
import com.vladsch.flexmark.util.ast.IParse;
21
import com.vladsch.flexmark.util.ast.IRender;
22
import com.vladsch.flexmark.util.ast.Node;
12
import com.keenwrite.processors.r.RProcessor;
2313
import com.vladsch.flexmark.util.misc.Extension;
2414
25
import java.util.ArrayList;
2615
import java.util.List;
2716
2817
import static com.keenwrite.io.MediaType.TEXT_R_MARKDOWN;
2918
import static com.keenwrite.io.MediaType.TEXT_R_XML;
3019
import static com.keenwrite.processors.IdentityProcessor.IDENTITY;
3120
3221
/**
3322
 * Responsible for parsing a Markdown document and rendering it as HTML.
3423
 */
35
public class MarkdownProcessor extends ExecutorProcessor<String> {
36
37
  private static final List<Extension> DEFAULT_EXTENSIONS =
38
    createDefaultExtensions();
39
40
  private final IParse mParser;
41
  private final IRender mRenderer;
24
public final class MarkdownProcessor extends BaseMarkdownProcessor {
4225
4326
  private MarkdownProcessor(
44
    final Processor<String> successor,
45
    final List<Extension> extensions ) {
46
    super( successor );
47
48
    mParser = Parser.builder().extensions( extensions ).build();
49
    mRenderer = HtmlRenderer.builder().extensions( extensions ).build();
27
    final Processor<String> successor, final ProcessorContext context ) {
28
    super( successor, context );
5029
  }
5130
5231
  public static MarkdownProcessor create( final ProcessorContext context ) {
5332
    return create( IDENTITY, context );
5433
  }
5534
5635
  public static MarkdownProcessor create(
5736
    final Processor<String> successor, final ProcessorContext context ) {
58
    final var extensions = createExtensions( context );
59
    return new MarkdownProcessor( successor, extensions );
60
  }
61
62
  private static List<Extension> createEmptyExtensions() {
63
    return new ArrayList<>();
64
  }
65
66
  /**
67
   * Instantiates a number of extensions to be applied when parsing. These
68
   * are typically typographic extensions that convert characters into
69
   * HTML entities.
70
   *
71
   * @return A {@link List} of {@link Extension} instances that
72
   * change the {@link Parser}'s behaviour.
73
   */
74
  private static List<Extension> createDefaultExtensions() {
75
    final List<Extension> extensions = new ArrayList<>();
76
    extensions.add( DefinitionExtension.create() );
77
    extensions.add( StrikethroughSubscriptExtension.create() );
78
    extensions.add( SuperscriptExtension.create() );
79
    extensions.add( TablesExtension.create() );
80
    extensions.add( TypographicExtension.create() );
81
    return extensions;
37
    return new MarkdownProcessor( successor, context );
8238
  }
8339
...
9147
   * formats can be edited.
9248
   *
93
   * @param context Contains necessary information needed to create extensions
94
   *                used by the Markdown parser.
95
   * @return {@link List} of extensions invoked when parsing Markdown.
49
   * @param extensions {@link List} of extensions invoked when parsing Markdown.
50
   * @param context    Contains necessary information needed to create
51
   *                   extensions used by the Markdown parser.
9652
   */
97
  private static List<Extension> createExtensions(
98
    final ProcessorContext context ) {
99
    final var extensions = createEmptyExtensions();
100
    final var editorFile = context.getPath();
101
53
  void init(
54
    final List<Extension> extensions, final ProcessorContext context ) {
55
    final var editorFile = context.getDocumentPath();
10256
    final var mediaType = MediaType.valueFrom( editorFile );
57
    final Processor<String> processor;
58
10359
    if( mediaType == TEXT_R_MARKDOWN || mediaType == TEXT_R_XML ) {
104
      extensions.add( RExtension.create( context ) );
60
      final var rProcessor = new RProcessor( context );
61
      extensions.add( RExtension.create( rProcessor ) );
62
      processor = rProcessor;
63
    }
64
    else {
65
      processor = IDENTITY;
10566
    }
10667
107
    extensions.addAll( DEFAULT_EXTENSIONS );
68
    // Add typographic, table, strikethrough, and similar extensions.
69
    super.init( extensions, context );
70
10871
    extensions.add( ImageLinkExtension.create( context ) );
109
    extensions.add( TeXExtension.create( context ) );
72
    extensions.add( TeXExtension.create( context, processor ) );
11073
    extensions.add( FencedBlockExtension.create( context ) );
11174
    extensions.add( CaretExtension.create( context ) );
112
113
    return extensions;
114
  }
115
116
  /**
117
   * Converts the given Markdown string into HTML, without the doctype, html,
118
   * head, and body tags.
119
   *
120
   * @param markdown The string to convert from Markdown to HTML.
121
   * @return The HTML representation of the Markdown document.
122
   */
123
  @Override
124
  public String apply( final String markdown ) {
125
    return toHtml( parse( markdown ) );
126
  }
127
128
  /**
129
   * Returns the AST in the form of a node for the given Markdown document. This
130
   * can be used, for example, to determine if a hyperlink exists inside of a
131
   * paragraph.
132
   *
133
   * @param markdown The Markdown to convert into an AST.
134
   * @return The Markdown AST for the given text (usually a paragraph).
135
   */
136
  public Node toNode( final String markdown ) {
137
    return parse( markdown );
138
  }
139
140
  /**
141
   * Returns the result of converting the given AST into an HTML string.
142
   *
143
   * @param node The AST {@link Node} to convert to an HTML string.
144
   * @return The given {@link Node} as an HTML string.
145
   */
146
  public String toHtml( final Node node ) {
147
    return getRenderer().render( node );
148
  }
149
150
  /**
151
   * Helper method to create an AST given some Markdown.
152
   *
153
   * @param markdown The Markdown to parse.
154
   * @return The root node of the Markdown tree.
155
   */
156
  private Node parse( final String markdown ) {
157
    return getParser().parse( markdown );
158
  }
159
160
  /**
161
   * Creates the Markdown document processor.
162
   *
163
   * @return An instance of {@link IParse} for building abstract syntax trees.
164
   */
165
  private IParse getParser() {
166
    return mParser;
167
  }
168
169
  private IRender getRenderer() {
170
    return mRenderer;
17175
  }
17276
}
M src/main/java/com/keenwrite/processors/markdown/extensions/ImageLinkExtension.java
22
package com.keenwrite.processors.markdown.extensions;
33
4
import com.keenwrite.ExportFormat;
45
import com.keenwrite.exceptions.MissingFileException;
56
import com.keenwrite.preferences.Workspace;
...
1213
import com.vladsch.flexmark.util.ast.Node;
1314
import org.jetbrains.annotations.NotNull;
14
import org.renjin.repackaged.guava.base.Splitter;
1515
1616
import java.io.File;
1717
import java.nio.file.Path;
18
import java.nio.file.Paths;
1918
19
import static com.keenwrite.ExportFormat.NONE;
2020
import static com.keenwrite.StatusNotifier.clue;
2121
import static com.keenwrite.preferences.Workspace.KEY_IMAGES_DIR;
2222
import static com.keenwrite.preferences.Workspace.KEY_IMAGES_ORDER;
2323
import static com.keenwrite.util.ProtocolScheme.getProtocol;
2424
import static com.vladsch.flexmark.html.HtmlRenderer.Builder;
2525
import static com.vladsch.flexmark.html.renderer.LinkStatus.VALID;
26
import static java.lang.String.format;
26
import static org.renjin.repackaged.guava.base.Splitter.on;
2727
2828
/**
2929
 * Responsible for ensuring that images can be rendered relative to a path.
3030
 * This allows images to be located virtually anywhere.
3131
 */
3232
public class ImageLinkExtension extends HtmlRendererAdapter {
3333
3434
  private final Path mBaseDir;
3535
  private final Workspace mWorkspace;
36
  private final ExportFormat mExportFormat;
3637
3738
  private ImageLinkExtension( @NotNull final ProcessorContext context ) {
3839
    mBaseDir = context.getBaseDir();
3940
    mWorkspace = context.getWorkspace();
41
    mExportFormat = context.getExportFormat();
4042
  }
4143
...
7880
    }
7981
82
    /**
83
     * Algorithm:
84
     * <ol>
85
     *   <li>Accept remote URLs as valid links.</li>
86
     *   <li>Accept existing readable files as valid links.</li>
87
     *   <li>Accept non-{@link ExportFormat#NONE} exports as valid links.</li>
88
     *   <li>Append the images dir to the edited file's dir (baseDir).</li>
89
     *   <li>Search for images by extension.</li>
90
     * </ol>
91
     *
92
     * @param link The link URL to resolve.
93
     * @return The {@link ResolvedLink} instance used to render the link.
94
     */
8095
    private ResolvedLink resolve( final ResolvedLink link ) {
8196
      var uri = link.getUrl();
8297
      final var protocol = getProtocol( uri );
8398
84
      if( protocol.isHttp() ) {
99
      if( protocol.isRemote() ) {
85100
        return valid( link, uri );
86101
      }
102
103
      final var baseDir = getBaseDir();
87104
88105
      // Determine the fully-qualified file name (fqfn).
89
      final var fqfn = Paths.get( getBaseDir().toString(), uri ).toFile();
106
      final var fqfn = Path.of( baseDir.toString(), uri ).toFile();
90107
91
      if( fqfn.isFile() ) {
108
      if( fqfn.isFile() && fqfn.canRead() ) {
92109
        return valid( link, uri );
93110
      }
94111
95
      // At this point either the image directory is qualified or needs to be
96
      // qualified using the image prefix, as set in the user preferences.
97
      try {
98
        final var imagePrefix = getImagePrefix();
99
        final var baseDir = getBaseDir().resolve( imagePrefix );
112
      if( mExportFormat != NONE ) {
113
        return valid( link, uri );
114
      }
100115
101
        final var imagePrefixDir = Path.of( baseDir.toString(), uri );
102
        final var suffixes = getImageExtensions();
103
        boolean missing = true;
116
      try {
117
        // Compute the path to the image file. The base directory should
118
        // be an absolute path to the file being edited, without an extension.
119
        final var imagesDir = getUserImagesDir();
120
        final var relativeDir = imagesDir.toString().isEmpty()
121
          ? imagesDir : baseDir.relativize( imagesDir );
122
        final var imageFile = Path.of(
123
          baseDir.toString(), relativeDir.toString(), uri );
104124
105
        // Iterate over the user's preferred image file type extensions.
106
        for( final var ext : Splitter.on( ' ' ).split( suffixes ) ) {
107
          final var imagePath = format( "%s.%s", imagePrefixDir, ext );
108
          final var file = new File( imagePath );
125
        for( final var ext : getImageExtensions() ) {
126
          var file = new File( imageFile.toString() + '.' + ext );
109127
110
          if( file.exists() ) {
111
            uri += '.' + ext;
112
            final var path = Path.of( imagePrefix.toString(), uri );
113
            uri = path.normalize().toString();
114
            missing = false;
115
            break;
128
          if( file.exists() && file.canRead() ) {
129
            uri = file.toURI().toString();
130
            return valid( link, uri );
116131
          }
117
        }
118
119
        if( missing ) {
120
          throw new MissingFileException( imagePrefixDir + ".*" );
121132
        }
122133
123
        return valid( link, uri );
134
        throw new MissingFileException( imageFile + ".*" );
124135
      } catch( final Exception ex ) {
125136
        clue( ex );
...
133144
    }
134145
135
    private Path getImagePrefix() {
146
    private Path getUserImagesDir() {
136147
      return mWorkspace.toFile( KEY_IMAGES_DIR ).toPath();
137148
    }
138149
139
    private String getImageExtensions() {
140
      return mWorkspace.toString( KEY_IMAGES_ORDER );
150
    private Iterable<String> getImageExtensions() {
151
      return on( ' ' ).split( mWorkspace.toString( KEY_IMAGES_ORDER ) );
141152
    }
142153
M src/main/java/com/keenwrite/processors/markdown/extensions/r/RExtension.java
22
package com.keenwrite.processors.markdown.extensions.r;
33
4
import com.keenwrite.processors.*;
4
import com.keenwrite.processors.Processor;
5
import com.keenwrite.processors.r.InlineRProcessor;
6
import com.keenwrite.processors.r.RProcessor;
57
import com.keenwrite.sigils.RSigilOperator;
68
import com.vladsch.flexmark.ast.Text;
...
1719
import java.util.Map;
1820
19
import static com.keenwrite.processors.IdentityProcessor.IDENTITY;
2021
import static com.vladsch.flexmark.parser.Parser.Builder;
2122
import static com.vladsch.flexmark.parser.Parser.ParserExtension;
...
3132
public final class RExtension implements ParserExtension {
3233
  private final InlineParserFactory FACTORY = CustomParser::new;
33
34
  private final Processor<String> mProcessor;
35
  private final InlineRProcessor mInlineRProcessor;
36
  private boolean mReady;
34
  private final RProcessor mProcessor;
3735
38
  private RExtension( final ProcessorContext context ) {
39
    final var irp = new InlineRProcessor( IDENTITY, context );
40
    final var rvp = new RVariableProcessor( irp, context );
41
    mProcessor = new ExecutorProcessor<>( rvp );
42
    mInlineRProcessor = irp;
36
  private RExtension( final RProcessor processor ) {
37
    mProcessor = processor;
4338
  }
4439
4540
  /**
4641
   * Creates an extension capable of intercepting R code blocks and preventing
4742
   * them from being converted into HTML {@code <code>} elements.
4843
   */
49
  public static RExtension create( final ProcessorContext context ) {
50
    return new RExtension( context );
44
  public static RExtension create( final RProcessor processor ) {
45
    return new RExtension( processor );
5146
  }
5247
...
8681
             referenceLinkProcessors,
8782
             inlineParserExtensions );
88
      mReady = mInlineRProcessor.init();
83
      mProcessor.init();
8984
    }
9085
...
10398
      final var foundTicks = super.parseBackticks();
10499
105
      if( foundTicks && mReady ) {
100
      if( foundTicks && mProcessor.isReady() ) {
106101
        final var blockNode = getBlock();
107102
        final var codeNode = blockNode.getLastChild();
M src/main/java/com/keenwrite/processors/markdown/extensions/r/ROutputProcessor.java
33
44
import com.keenwrite.processors.ExecutorProcessor;
5
import com.keenwrite.processors.InlineRProcessor;
5
import com.keenwrite.processors.r.InlineRProcessor;
66
import com.keenwrite.processors.markdown.MarkdownProcessor;
77
import com.keenwrite.processors.markdown.extensions.tex.TeXExtension;
M src/main/java/com/keenwrite/processors/markdown/extensions/tex/TeXExtension.java
33
44
import com.keenwrite.ExportFormat;
5
import com.keenwrite.processors.Processor;
56
import com.keenwrite.processors.ProcessorContext;
67
import com.keenwrite.processors.markdown.extensions.HtmlRendererAdapter;
...
3031
  private final ExportFormat mExportFormat;
3132
32
  private TeXExtension( final ProcessorContext context ) {
33
  private final Processor<String> mProcessor;
34
35
  private TeXExtension(
36
    final ProcessorContext context, final Processor<String> processor ) {
3337
    mExportFormat = context.getExportFormat();
38
    mProcessor = processor;
3439
  }
3540
3641
  /**
3742
   * Creates an extension capable of handling delimited TeX code in Markdown.
3843
   *
3944
   * @return The new {@link TeXExtension}, never {@code null}.
4045
   */
41
  public static TeXExtension create( final ProcessorContext context ) {
42
    return new TeXExtension( context );
46
  public static TeXExtension create(
47
    final ProcessorContext context, final Processor<String> processor ) {
48
    return new TeXExtension( context, processor );
4349
  }
4450
...
5359
                      @NotNull final String rendererType ) {
5460
    if( "HTML".equalsIgnoreCase( rendererType ) ) {
55
      builder.nodeRendererFactory( new Factory( mExportFormat ) );
61
      builder.nodeRendererFactory( new Factory( mExportFormat, mProcessor ) );
5662
    }
5763
  }
M src/main/java/com/keenwrite/processors/markdown/extensions/tex/TexNodeRenderer.java
44
import com.keenwrite.ExportFormat;
55
import com.keenwrite.preview.SvgRasterizer;
6
import com.keenwrite.processors.Processor;
67
import com.vladsch.flexmark.html.HtmlWriter;
78
import com.vladsch.flexmark.html.renderer.NodeRenderer;
...
1415
import org.jetbrains.annotations.Nullable;
1516
17
import java.util.Map;
1618
import java.util.Set;
1719
20
import static com.keenwrite.ExportFormat.*;
1821
import static com.keenwrite.preview.MathRenderer.MATH_RENDERER;
1922
import static com.keenwrite.processors.markdown.extensions.tex.TexNode.*;
2023
2124
public class TexNodeRenderer {
25
  private static final Map<ExportFormat, RendererFacade> EXPORT_RENDERERS =
26
    Map.of(
27
      HTML_TEX_SVG, new TexSvgNodeRenderer(),
28
      HTML_TEX_DELIMITED, new TexDelimNodeRenderer(),
29
      MARKDOWN_PLAIN, new TexDelimNodeRenderer(),
30
      NONE, new TexElementNodeRenderer()
31
    );
2232
2333
  public static class Factory implements NodeRendererFactory {
24
    private final ExportFormat mExportFormat;
34
    private final RendererFacade mNodeRenderer;
2535
26
    public Factory( final ExportFormat exportFormat ) {
27
      mExportFormat = exportFormat;
36
    public Factory(
37
      final ExportFormat exportFormat, final Processor<String> processor ) {
38
      mNodeRenderer = EXPORT_RENDERERS.get( exportFormat );
39
      mNodeRenderer.setProcessor( processor );
2840
    }
2941
3042
    @NotNull
3143
    @Override
3244
    public NodeRenderer apply( @NotNull DataHolder options ) {
33
      return switch( mExportFormat ) {
34
        case HTML_TEX_SVG -> new TexSvgNodeRenderer();
35
        case HTML_TEX_DELIMITED, MARKDOWN_PLAIN -> new TexDelimNodeRenderer();
36
        case NONE -> new TexElementNodeRenderer();
37
      };
45
      return mNodeRenderer;
3846
    }
3947
  }
4048
41
  private static abstract class AbstractTexNodeRenderer
42
      implements NodeRenderer {
49
  private static abstract class RendererFacade
50
    implements NodeRenderer {
51
    private Processor<String> mProcessor;
4352
4453
    @Override
...
5968
                          final NodeRendererContext context,
6069
                          final HtmlWriter html );
70
71
    private void setProcessor( final Processor<String> processor ) {
72
      mProcessor = processor;
73
    }
74
75
    Processor<String> getProcessor() {
76
      return mProcessor;
77
    }
6178
  }
6279
6380
  /**
6481
   * Responsible for rendering a TeX node as an HTML {@code <tex>}
6582
   * element. This is the default behaviour.
6683
   */
67
  private static class TexElementNodeRenderer extends AbstractTexNodeRenderer {
84
  private static class TexElementNodeRenderer extends RendererFacade {
6885
    void render( final TexNode node,
6986
                 final NodeRendererContext context,
7087
                 final HtmlWriter html ) {
7188
      html.tag( HTML_TEX );
72
      html.raw( node.getText() );
89
      html.raw( getProcessor().apply( node.getText().toString() ) );
7390
      html.closeTag( HTML_TEX );
7491
    }
7592
  }
7693
7794
  /**
7895
   * Responsible for rendering a TeX node as an HTML {@code <svg>}
7996
   * element.
8097
   */
81
  private static class TexSvgNodeRenderer extends AbstractTexNodeRenderer {
98
  private static class TexSvgNodeRenderer extends RendererFacade {
8299
    void render( final TexNode node,
83100
                 final NodeRendererContext context,
84101
                 final HtmlWriter html ) {
85102
      final var tex = node.getText().toStringOrNull();
86
      final var doc = MATH_RENDERER.render( tex == null ? "" : tex );
103
      final var doc = MATH_RENDERER.render(
104
        tex == null ? "" : getProcessor().apply( tex ) );
87105
      final var svg = SvgRasterizer.toSvg( doc.getDocumentElement() );
88106
      html.raw( svg );
89107
    }
90108
  }
91109
92110
  /**
93111
   * Responsible for rendering a TeX node as text bracketed by $ tokens.
94112
   */
95
  private static class TexDelimNodeRenderer extends AbstractTexNodeRenderer {
113
  private static class TexDelimNodeRenderer extends RendererFacade {
96114
    void render( final TexNode node,
97115
                 final NodeRendererContext context,
98116
                 final HtmlWriter html ) {
99117
      html.raw( TOKEN_OPEN );
100
      html.raw( node.getText() );
118
      html.raw( getProcessor().apply( node.getText().toString() ) );
101119
      html.raw( TOKEN_CLOSE );
102120
    }
A src/main/java/com/keenwrite/processors/r/InlineRProcessor.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.processors.r;
3
4
import com.keenwrite.preferences.Workspace;
5
import com.keenwrite.processors.DefinitionProcessor;
6
import com.keenwrite.processors.Processor;
7
import com.keenwrite.processors.ProcessorContext;
8
import com.keenwrite.processors.markdown.extensions.r.ROutputProcessor;
9
import com.keenwrite.util.BoundedCache;
10
import javafx.beans.property.Property;
11
12
import javax.script.ScriptEngine;
13
import javax.script.ScriptEngineManager;
14
import java.io.File;
15
import java.nio.file.Path;
16
import java.util.Map;
17
import java.util.concurrent.atomic.AtomicBoolean;
18
19
import static com.keenwrite.Constants.STATUS_PARSE_ERROR;
20
import static com.keenwrite.Messages.get;
21
import static com.keenwrite.StatusNotifier.clue;
22
import static com.keenwrite.preferences.Workspace.*;
23
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;
27
import static java.lang.Math.min;
28
import static java.lang.String.format;
29
30
/**
31
 * Transforms a document containing R statements into Markdown.
32
 */
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" );
45
46
  private static final int PREFIX_LENGTH = PREFIX.length();
47
48
  private final AtomicBoolean mDirty = new AtomicBoolean( false );
49
50
  private final Workspace mWorkspace;
51
52
  /**
53
   * Constructs a processor capable of evaluating R statements.
54
   *
55
   * @param successor Subsequent link in the processing chain.
56
   * @param context   Contains resolved definitions map.
57
   */
58
  public InlineRProcessor(
59
    final Processor<String> successor,
60
    final ProcessorContext context ) {
61
    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();
82
  }
83
84
  /**
85
   * Initialises the R code so that R can find imported libraries. Note that
86
   * any existing R functionality will not be overwritten if this method is
87
   * called multiple times.
88
   *
89
   * @return {@code true} if initialization completed and all variables were
90
   * replaced; {@code false} if any variables remain.
91
   */
92
  public boolean init() {
93
    final var bootstrap = getBootstrapScript();
94
95
    if( !bootstrap.isBlank() ) {
96
      final var wd = getWorkingDirectory();
97
      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 );
112
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 ) );
117
118
        return false;
119
      }
120
      else {
121
        eval( replaced );
122
      }
123
    }
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 );
144
  }
145
146
  /**
147
   * Answers whether R-related settings have been modified.
148
   *
149
   * @return {@code true} when the settings have changed.
150
   */
151
  private boolean isDirty() {
152
    return mDirty.get();
153
  }
154
155
  /**
156
   * Evaluates all R statements in the source document and inserts the
157
   * calculated value into the generated document.
158
   *
159
   * @param text The document text that includes variables that should be
160
   *             replaced with values when rendered as HTML.
161
   * @return The generated document with output from all R statements
162
   * substituted with value returned from their execution.
163
   */
164
  @Override
165
  public String apply( final String text ) {
166
    final int length = text.length();
167
168
    // The * 2 is a wild guess at the ratio of R statements to the length
169
    // of text produced by those statements.
170
    final StringBuilder sb = new StringBuilder( length * 2 );
171
172
    int prevIndex = 0;
173
    int currIndex = text.indexOf( PREFIX );
174
175
    while( currIndex >= 0 ) {
176
      // Copy everything up to, but not including, the opening token.
177
      sb.append( text, prevIndex, currIndex );
178
179
      // Jump to the start of the R statement.
180
      prevIndex = currIndex + PREFIX_LENGTH;
181
182
      // Find the closing token, without indexing past the text boundary.
183
      currIndex = text.indexOf( SUFFIX, min( currIndex + 1, length ) );
184
185
      // Only evaluate inline R statements that have end delimiters.
186
      if( currIndex > 1 ) {
187
        // Extract the inline R statement to be evaluated.
188
        final var r = text.substring( prevIndex, currIndex );
189
190
        // Pass the R statement into the R engine for evaluation.
191
        try {
192
          // Append the string representation of the result into the text.
193
          sb.append( evalCached( r ) );
194
        } catch( final Exception ex ) {
195
          // Inform the user that there was a problem.
196
          clue( STATUS_PARSE_ERROR, ex.getMessage(), currIndex );
197
198
          // If the string couldn't be parsed using R, append the statement
199
          // that failed to parse, instead of its evaluated value.
200
          sb.append( PREFIX ).append( r ).append( SUFFIX );
201
        }
202
203
        // Retain the R statement's ending position in the text.
204
        prevIndex = currIndex + 1;
205
      }
206
207
      // Find the start of the next inline R statement.
208
      currIndex = text.indexOf( PREFIX, min( currIndex + 1, length ) );
209
    }
210
211
    // Copy from the previous index to the end of the string.
212
    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
    }
253
  }
254
255
  /**
256
   * Return the given path if not {@code null}, otherwise return the path to
257
   * the user's directory.
258
   *
259
   * @return A non-null path.
260
   */
261
  private Path getWorkingDirectory() {
262
    return workingDirectoryProperty().getValue().toPath();
263
  }
264
265
  private Property<File> workingDirectoryProperty() {
266
    return getWorkspace().fileProperty( KEY_R_DIR );
267
  }
268
269
  /**
270
   * Loads the R init script from the application's persisted preferences.
271
   *
272
   * @return A non-null string, possibly empty.
273
   */
274
  private String getBootstrapScript() {
275
    return bootstrapScriptProperty().getValue();
276
  }
277
278
  private Property<String> bootstrapScriptProperty() {
279
    return getWorkspace().valuesProperty( KEY_R_SCRIPT );
280
  }
281
282
  private Workspace getWorkspace() {
283
    return mWorkspace;
284
  }
285
}
1286
A src/main/java/com/keenwrite/processors/r/RProcessor.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.processors.r;
3
4
import com.keenwrite.processors.ExecutorProcessor;
5
import com.keenwrite.processors.Processor;
6
import com.keenwrite.processors.ProcessorContext;
7
8
import static com.keenwrite.processors.IdentityProcessor.IDENTITY;
9
10
/**
11
 * Responsible for processing R statements within a text block.
12
 */
13
public final class RProcessor extends ExecutorProcessor<String> {
14
  private final Processor<String> mProcessor;
15
  private final InlineRProcessor mInlineRProcessor;
16
  private volatile boolean mReady;
17
18
  public RProcessor( final ProcessorContext context ) {
19
    final var irp = new InlineRProcessor( IDENTITY, context );
20
    final var rvp = new RVariableProcessor( irp, context );
21
    mProcessor = new ExecutorProcessor<>( rvp );
22
    mInlineRProcessor = irp;
23
  }
24
25
  public void init() {
26
    mReady = mInlineRProcessor.init();
27
  }
28
29
  public boolean isReady() {
30
    return mReady;
31
  }
32
33
  public String apply( final String text ) {
34
    return mProcessor.apply( text );
35
  }
36
}
137
A src/main/java/com/keenwrite/processors/r/RVariableProcessor.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.processors.r;
3
4
import com.keenwrite.preferences.Workspace;
5
import com.keenwrite.processors.DefinitionProcessor;
6
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;
13
14
import static com.keenwrite.preferences.Workspace.*;
15
16
/**
17
 * Converts the keys of the resolved map from default form to R form, then
18
 * performs a substitution on the text. The default R variable syntax is
19
 * {@code v$tree$leaf}.
20
 */
21
public final class RVariableProcessor extends DefinitionProcessor {
22
23
  private final SigilOperator mSigilOperator;
24
25
  public RVariableProcessor(
26
    final InlineRProcessor irp, final ProcessorContext context ) {
27
    super( irp, context );
28
    mSigilOperator = createSigilOperator( context.getWorkspace() );
29
  }
30
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
   */
36
  @Override
37
  protected Map<String, String> getDefinitions() {
38
    return entoken( super.getDefinitions() );
39
  }
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() );
49
50
    for( final var entry : map.entrySet() ) {
51
      final var key = entry.getKey();
52
      rMap.put( mSigilOperator.entoken( key ), escape( map.get( key ) ) );
53
    }
54
55
    return rMap;
56
  }
57
58
  private String escape( final String value ) {
59
    return '\'' + escape( value, '\'', "\\'" ) + '\'';
60
  }
61
62
  /**
63
   * TODO: Make generic method for replacing text.
64
   *
65
   * @param haystack Search this string for the needle, must not be null.
66
   * @param needle   The character to find in the haystack.
67
   * @param thread   Replace the needle with this text, if the needle is found.
68
   * @return The haystack with the all instances of needle replaced with thread.
69
   */
70
  @SuppressWarnings("SameParameterValue")
71
  private String escape(
72
    final String haystack, final char needle, final String thread ) {
73
    int end = haystack.indexOf( needle );
74
75
    if( end < 0 ) {
76
      return haystack;
77
    }
78
79
    final int length = haystack.length();
80
    int start = 0;
81
82
    // Replace up to 32 occurrences before the string reallocates its buffer.
83
    final var sb = new StringBuilder( length + 32 );
84
85
    while( end >= 0 ) {
86
      sb.append( haystack, start, end ).append( thread );
87
      start = end + 1;
88
      end = haystack.indexOf( needle, start );
89
    }
90
91
    return sb.append( haystack.substring( start ) ).toString();
92
  }
93
94
  private SigilOperator createSigilOperator( final Workspace workspace ) {
95
    final var tokens = workspace.toTokens(
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(
102
    final Workspace workspace ) {
103
    final var tokens = workspace.toTokens(
104
      KEY_DEF_DELIM_BEGAN, KEY_DEF_DELIM_ENDED );
105
    return new YamlSigilOperator( tokens );
106
  }
107
}
1108
M src/main/java/com/keenwrite/search/SearchModel.java
1919
 * a {@link Trie} for efficiency.
2020
 */
21
public class SearchModel {
21
public final class SearchModel {
2222
  private final ObjectProperty<IndexRange> mMatchOffset =
2323
      new SimpleObjectProperty<>();
M src/main/java/com/keenwrite/sigils/RSigilOperator.java
77
 * Brackets variable names between {@link #PREFIX} and {@link #SUFFIX} sigils.
88
 */
9
public class RSigilOperator extends SigilOperator {
9
public final class RSigilOperator extends SigilOperator {
1010
  public static final char KEY_SEPARATOR_R = '$';
1111
M src/main/java/com/keenwrite/sigils/YamlSigilOperator.java
55
 * Brackets definition keys with token delimiters.
66
 */
7
public class YamlSigilOperator extends SigilOperator {
7
public final class YamlSigilOperator extends SigilOperator {
88
  public static final char KEY_SEPARATOR_DEF = '.';
99
...
3636
3737
  /**
38
   * Removes start and stop definition key delimiters from the given key. This
39
   * method does not check for delimiters, only that there are sufficient
40
   * characters to remove from either end of the given key.
38
   * Removes start and stop definition key delimiters from the given key.
4139
   *
42
   * @param key The key adorned with start and stop definition tokens.
40
   * @param key The key that may have start and stop tokens.
4341
   * @return The given key with the delimiters removed.
4442
   */
4543
  public String detoken( final String key ) {
46
    final int beganLen = getBegan().length();
47
    final int endedLen = getEnded().length();
44
    final var began = getBegan();
45
    final var ended = getEnded();
46
    final int bLength = began.length();
47
    final int eLength = ended.length();
48
    final var bIndex = key.indexOf( began );
49
    final var eIndex = key.indexOf( ended, bIndex );
50
    final var kLength = key.length();
4851
49
    return key.length() > beganLen + endedLen
50
      ? key.substring( beganLen, key.length() - endedLen )
51
      : key;
52
    return key.substring(
53
      bIndex == -1 ? 0 : bLength, eIndex == -1 ? kLength : kLength - eLength );
5254
  }
5355
}
M src/main/java/com/keenwrite/spelling/impl/PermissiveSpeller.java
1212
 * spell checking and indicates that any given lexeme is in the lexicon.
1313
 */
14
public class PermissiveSpeller implements SpellChecker {
14
public final class PermissiveSpeller implements SpellChecker {
1515
  /**
1616
   * Returns {@code true}, ignoring the given word.
M src/main/java/com/keenwrite/spelling/impl/TextEditorSpeller.java
2121
 * Responsible for checking the spelling of a document being edited.
2222
 */
23
public class TextEditorSpeller {
23
public final class TextEditorSpeller {
2424
  /**
2525
   * Only load the dictionary into memory once, because it's huge.
M src/main/java/com/keenwrite/ui/actions/Action.java
2323
 * Defines actions the user can take through GUI interactions
2424
 */
25
public class Action implements MenuAction {
25
public final class Action implements MenuAction {
2626
  private final String mText;
2727
  private final KeyCombination mAccelerator;
M src/main/java/com/keenwrite/ui/actions/ApplicationActions.java
4343
 */
4444
@SuppressWarnings( "NonAsciiCharacters" )
45
public class ApplicationActions {
45
public final class ApplicationActions {
4646
  private static final String STYLE_SEARCH = "search";
4747
...
125125
  private void file‿export( final ExportFormat format ) {
126126
    final var main = getMainPane();
127
    final var context = main.createProcessorContext();
127
    final var context = main.createProcessorContext( format );
128128
    final var chain = createProcessors( context );
129129
    final var editor = main.getActiveTextEditor();
M src/main/java/com/keenwrite/ui/actions/ApplicationBars.java
1919
 * and keyboard shortcuts.
2020
 */
21
public class ApplicationBars {
21
public final class ApplicationBars {
2222
2323
  private static final Map<String, Action> sMap = new HashMap<>( 64 );
...
119119
      addAction( "view.preview", e -> actions.view‿preview() ),
120120
      SEPARATOR_ACTION,
121
      addAction( "view.menubar", e -> actions.view‿menubar() ),
121122
      addAction( "view.toolbar", e -> actions.view‿toolbar() ),
122
      addAction( "view.statusbar", e -> actions.view‿statusbar() ),
123
      addAction( "view.menubar", e -> actions.view‿menubar() )
123
      addAction( "view.statusbar", e -> actions.view‿statusbar() )
124124
    ),
125125
    createMenu(
M src/main/java/com/keenwrite/ui/actions/FileChooserCommand.java
2424
 * select files.
2525
 */
26
public class FileChooserCommand {
26
public final class FileChooserCommand {
2727
  private static final String FILTER_EXTENSION_TITLES =
2828
    "Dialog.file.choose.filter";
M src/main/java/com/keenwrite/ui/actions/SeparatorAction.java
99
 * operation, acting as a placeholder for line separators.
1010
 */
11
public class SeparatorAction implements MenuAction {
11
public final class SeparatorAction implements MenuAction {
1212
  @Override
1313
  public MenuItem createMenuItem() {
M src/main/java/com/keenwrite/ui/logging/LogView.java
1212
1313
import java.time.LocalDateTime;
14
import java.util.Objects;
1415
import java.util.TreeSet;
1516
import java.util.stream.Collectors;
...
3536
 * Responsible for logging application issues to {@link TableView} entries.
3637
 */
37
public class LogView extends Alert {
38
public final class LogView extends Alert {
3839
  /**
39
   * Number of error messages to retain in the {@link TableView}, must be
40
   * Number of error messages to retain in the {@link TableView}; must be
4041
   * greater than zero.
4142
   */
...
8485
8586
  private void log( final LogEntry logEntry ) {
87
    // Exit early if the log already contains the message. The status bar will
88
    // remain current.
89
    if( mEntries.contains( logEntry ) ) {
90
      return;
91
    }
92
8693
    mEntries.add( logEntry );
8794
...
225232
226233
      return sb.toString();
234
    }
235
236
    @Override
237
    public boolean equals( final Object o ) {
238
      if( this == o ) { return true; }
239
      if( o == null || getClass() != o.getClass() ) { return false; }
240
241
      return Objects.equals( mMessage.get(), ((LogEntry) o).mMessage.get() );
242
    }
243
244
    @Override
245
    public int hashCode() {
246
      return mMessage != null ? mMessage.hashCode() : 0;
227247
    }
228248
  }
M src/main/java/com/keenwrite/util/CyclicIterator.java
1919
 * @param <T> The type of list to be cycled.
2020
 */
21
public class CyclicIterator<T> implements ListIterator<T> {
21
public final class CyclicIterator<T> implements ListIterator<T> {
2222
  private final List<T> mList;
2323
M src/main/java/com/keenwrite/util/ProtocolScheme.java
2323
  HTTP,
2424
  /**
25
   * Denotes FTP.
26
   */
27
  FTP,
28
  /**
2529
   * Denotes Java archive file.
2630
   */
...
4145
      final var uri = new URI( resource );
4246
      return uri.isAbsolute()
43
          ? valueFrom( uri )
44
          : valueFrom( new URL( resource ) );
47
        ? valueFrom( uri )
48
        : valueFrom( new URL( resource ) );
4549
    } catch( final Exception ex ) {
4650
      // Using double-slashes is a short-hand to instruct the browser to
4751
      // reference a resource using the parent URL's security model. This
4852
      // is known as a protocol-relative URL.
4953
      return resource.startsWith( "//" )
50
          ? HTTP
51
          : valueFrom( new File( resource ) );
54
        ? HTTP
55
        : valueFrom( new File( resource ) );
5256
    }
5357
  }
...
121125
122126
  /**
123
   * Answers {@code true} if the given protocol is either HTTP or HTTPS.
127
   * Answers whether the given protocol is HTTP or HTTPS.
124128
   *
125129
   * @return {@code true} the protocol is either HTTP or HTTPS.
126130
   */
127131
  public boolean isHttp() {
132
    return this == HTTP;
133
  }
134
135
  /**
136
   * Answers whether the given protocol is FTP.
137
   *
138
   * @return {@code true} the protocol is FTP.
139
   */
140
  public boolean isFtp() {
128141
    return this == HTTP;
142
  }
143
144
  /**
145
   * Answers whether the given protocol represents a remote resource.
146
   *
147
   * @return {@code true} the protocol is HTTP(S) or FTP.
148
   */
149
  public boolean isRemote() {
150
    return isHttp() || isFtp();
129151
  }
130152
M src/main/java/com/keenwrite/util/ResourceWalker.java
1919
 * Responsible for finding file resources.
2020
 */
21
public class ResourceWalker {
21
public final class ResourceWalker {
2222
  /**
2323
   * Globbing pattern to match font names.
A src/main/resources/styles/dark/charcoal.css
1
.root { 
2
    -fx-accent: #1e74c6;
3
    -fx-focus-color: -fx-accent;
4
    -fx-base: #373e43;
5
    -fx-control-inner-background: derive(-fx-base, 35%);
6
    -fx-control-inner-background-alt: -fx-control-inner-background ;
7
}
8
9
.label{
10
    -fx-text-fill: lightgray;
11
}
12
13
.text-field {
14
    -fx-prompt-text-fill: gray;
15
}
16
17
.titulo{
18
    -fx-font-weight: bold;
19
    -fx-font-size: 18px;
20
}
21
22
.button{
23
    -fx-focus-traversable: false;
24
}
25
26
.button:hover{
27
    -fx-text-fill: white;
28
}
29
30
.separator *.line { 
31
    -fx-background-color: #3C3C3C;
32
    -fx-border-style: solid;
33
    -fx-border-width: 1px;
34
}
35
36
.scroll-bar{
37
    -fx-background-color: derive(-fx-base,45%)
38
}
39
40
.button:default {
41
    -fx-base: -fx-accent ;
42
} 
43
44
.table-view{
45
    /*-fx-background-color: derive(-fx-base, 10%);*/
46
    -fx-selection-bar-non-focused: derive(-fx-base, 50%);
47
}
48
49
.table-view .column-header .label{
50
    -fx-alignment: CENTER_LEFT;
51
    -fx-font-weight: none;
52
}
53
54
.list-cell:even,
55
.list-cell:odd,
56
.table-row-cell:even,
57
.table-row-cell:odd{    
58
    -fx-control-inner-background: derive(-fx-base, 15%);
59
}
60
61
.list-cell:empty,
62
.table-row-cell:empty {
63
    -fx-background-color: transparent;
64
}
65
66
.list-cell,
67
.table-row-cell{
68
    -fx-border-color: transparent;
69
    -fx-table-cell-border-color:transparent;
70
}
71
172
A src/main/resources/styles/dark/modena.css
1
/*
2
 * This is an adjustment of the original modena.css for a consistent dark theme.
3
 * Original modena.css here: https://gist.github.com/maxd/63691840fc372f22f470.
4
 */
5
6
/* Redefine base colors */
7
.root {
8
    -fx-base: rgb(50, 50, 50);
9
    -fx-background: rgb(50, 50, 50);
10
11
    /* make controls (buttons, thumb, etc.) slightly lighter */
12
    -fx-color: derive(-fx-base, 10%);
13
14
    /* text fields and table rows background */
15
    -fx-control-inner-background: rgb(20, 20, 20);
16
    /* version of -fx-control-inner-background for alternative rows */
17
    -fx-control-inner-background-alt: derive(-fx-control-inner-background, 2.5%);
18
19
    /* text colors depending on background's brightness */
20
    -fx-light-text-color: rgb(220, 220, 220);
21
    -fx-mid-text-color: rgb(100, 100, 100);
22
    -fx-dark-text-color: rgb(20, 20, 20);
23
24
    /* A bright blue for highlighting/accenting objects.  For example: selected
25
     * text; selected items in menus, lists, trees, and tables; progress bars */
26
    -fx-accent: rgb(0, 80, 100);
27
28
    /* color of non-focused yet selected elements */
29
    -fx-selection-bar-non-focused: rgb(50, 50, 50);
30
}
31
32
/* Fix derived prompt color for text fields */
33
.text-input {
34
    -fx-prompt-text-fill: derive(-fx-control-inner-background, +50%);
35
}
36
37
/* Keep prompt invisible when focused (above color fix overrides it) */
38
.text-input:focused {
39
    -fx-prompt-text-fill: transparent;
40
}
41
42
/* Fix scroll bar buttons arrows colors */
43
.scroll-bar > .increment-button > .increment-arrow,
44
.scroll-bar > .decrement-button > .decrement-arrow {
45
    -fx-background-color: -fx-mark-highlight-color, rgb(220, 220, 220);
46
}
47
48
.scroll-bar > .increment-button:hover > .increment-arrow,
49
.scroll-bar > .decrement-button:hover > .decrement-arrow {
50
    -fx-background-color: -fx-mark-highlight-color, rgb(240, 240, 240);
51
}
152
53
.scroll-bar > .increment-button:pressed > .increment-arrow,
54
.scroll-bar > .decrement-button:pressed > .decrement-arrow {
55
    -fx-background-color: -fx-mark-highlight-color, rgb(255, 255, 255);
56
}
A src/main/resources/styles/dark/recaf.css
1
/* =========================
2
 * ==     JFX Controls    ==
3
 * =========================
4
 */
5
.root {
6
	-fx-base: rgb(45, 45, 46);
7
	-fx-background: rgb(45, 45, 46);
8
	/* Brighten controls */
9
	-fx-color: derive(-fx-base, -40%);
10
	/* Control background */
11
	-fx-control-inner-background: rgb(46, 46, 47);
12
	/* Alternative control background (rows) */
13
	-fx-control-inner-background-alt: derive(-fx-control-inner-background, 2.5%);
14
	/* Text colors */
15
	-fx-light-text-color: rgb(220, 220, 220);
16
	-fx-mid-text-color: rgb(100, 100, 100);
17
	-fx-dark-text-color: rgb(20, 20, 20);
18
	/* Accent colors */
19
	-fx-accent: rgb(51, 51, 52);
20
	-fx-focus-color: rgb(51, 51, 52);
21
	/* Non-focused-selected elements */
22
	-fx-selection-bar-non-focused: rgb(45, 45, 46);
23
}
24
* {
25
	-fx-highlight-fill: rgba(0, 180, 255, 0.4);
26
}
27
/* Scroll */
28
.scroll-bar {
29
	-fx-background-color: rgb(61,61,62);
30
}
31
.scroll-bar .thumb {
32
	-fx-background-color: rgb(91,91,92);
33
	-fx-background-radius: 0;
34
}
35
.scroll-bar .thumb:hover,
36
.scroll-bar .thumb:pressed {
37
	-fx-background-color: rgb(141,141,142);
38
}
39
.scroll-bar .increment-button .increment-arrow,
40
.scroll-bar .decrement-button .decrement-arrow {
41
	-fx-background-color: rgb(200,200,200);
42
}
43
.corner {
44
    -fx-background-color: rgb(61,61,62);
45
}
46
/* Menu */
47
.menu-bar {
48
	-fx-background-color: rgb(45, 45, 48);
49
}
50
.menu {
51
	-fx-padding: 6 14 6 14;
52
	-fx-background-insets: -1;
53
}
54
.menu-item {
55
	-fx-padding: 5 11 5 11;
56
	-fx-background-insets: -1;
57
}
58
.menu:hover {
59
	-fx-background-color: rgb(61, 61, 62);
60
}
61
.context-menu,
62
.menu:showing {
63
	-fx-background-color: rgb(27, 27, 28);
64
	-fx-border-insets: -1;
65
	-fx-border-width: 1;
66
	-fx-border-color: black;
67
}
68
.context-menu {
69
	-fx-min-width: 80px;
70
	-fx-background-insets: -1;
71
	-fx-border-insets: -1;
72
	-fx-border-width: 1;
73
	-fx-border-color: black;
74
}
75
.context-menu .menu-item:focused {
76
	-fx-background-color: rgb(61, 61, 62);
77
}
78
.context-menu-header {
79
	/* TODO: Find a way to disable hover coloring on the menu header */
80
	-fx-opacity: 1.0;
81
	-fx-background-color: rgb(24, 50, 95);
82
}
83
.context-menu-header .label {
84
    -fx-opacity: 1.0;
85
}
86
87
/* Tabs */
88
.tab-pane {
89
	-fx-tab-min-width: 100px;
90
}
91
.tab-pane *.tab-header-background {
92
	-fx-background-color: rgb(29, 29, 31);
93
	-fx-border-width: 0 0 1 0;
94
	-fx-border-color: black;
95
}
96
.headers-region {
97
	-fx-background-color: rgb(75, 75, 76);
98
}
99
.tab {
100
	-fx-background-color: rgb(36,36,37);
101
	-fx-background-insets: 2 -1 -1 -1;
102
	-fx-background-radius: 0;
103
	-fx-padding: 2 2 1 2;
104
	-fx-border-insets: 0;
105
	-fx-border-width: 1 1 1 1;
106
	-fx-border-color: black;
107
}
108
.tab:selected {
109
	-fx-background-color: rgb(45, 45, 46);
110
	-fx-background-insets: 2 -1 -1 -1;
111
	-fx-padding: 2;
112
	-fx-border-insets: 0;
113
	-fx-border-width: 1 1 0 1;
114
	-fx-border-color: black;
115
}
116
.tab:selected .focus-indicator {
117
	-fx-border-color: transparent;
118
}
119
/* Table */
120
.table-view {
121
	-fx-selection-bar: rgb(50, 71, 77);
122
	-fx-selection-bar-non-focused: rgb(46, 56, 59);
123
	-fx-background-color: rgb(36,36,37);
124
	-fx-background-insets: 2 -1 -1 -1;
125
	-fx-background-radius: 0;
126
	-fx-padding: -1;
127
	-fx-border-width: 0 1 1 1;
128
	-fx-border-color: rgb(22, 22, 23);
129
}
130
.table-view .filler,
131
.table-view .show-hide-columns-button,
132
.column-overlay {
133
	-fx-background-color: transparent;
134
}
135
.column-header-background {
136
	-fx-background-color: rgb(36,36,37);
137
	-fx-background-insets: 2 -1 -1 -1;
138
	-fx-padding: -1;
139
	-fx-border-insets: 0;
140
	-fx-border-width: 0 1 0 1;
141
	-fx-border-color: rgb(22, 22, 23);
142
}
143
.column-header {
144
	-fx-background-color: rgb(45, 45, 46);
145
	-fx-background-insets: -1 -0 -1 0;
146
	-fx-padding: 2;
147
	-fx-border-insets: 1 -1 1 0;
148
	-fx-border-width: 1;
149
	-fx-border-color: rgb(22, 22, 23);
150
}
151
/* Splitpane */
152
.split-pane-divider {
153
	-fx-background-color: black;
154
	-fx-padding: 0;
155
	-fx-background-insets: -5;
156
}
157
/* Tree */
158
.tree-table-view,
159
.tree-view {
160
	-fx-background-color: rgb(29, 29, 31);
161
	-fx-background-insets: 0;
162
	-fx-border-width: 0 1 0 0;
163
	-fx-border-color: black;
164
}
165
.tree-table-cell,
166
.tree-cell {
167
	-fx-background-color: rgb(29, 29, 31);
168
}
169
.tree-cell:selected {
170
	-fx-background-color: rgb(44, 48, 55);
171
}
172
/* Buttons */
173
.box,
174
.button,
175
.combo-box,
176
.slider .thumb {
177
	-fx-background-radius: 0;
178
	-fx-background-color: rgb(63, 63, 70);
179
	-fx-background-insets: 0;
180
	-fx-border-width: 1;
181
	-fx-border-color: rgb(85, 85, 85);
182
}
183
.check-box:hover .box,
184
.button:hover,
185
.combo-box:hover,
186
.slider .thumb:hover {
187
	-fx-background-color: rgb(80, 80, 85);
188
	-fx-border-color: rgb(0, 122, 205);
189
}
190
.check-box:pressed .box,
191
.button:pressed,
192
.combo-box:pressed,
193
.slider .thumb:pressed {
194
	-fx-background-color: rgb(0, 122, 205);
195
	-fx-border-color: rgb(0, 162, 245);
196
}
197
.combo-box:showing {
198
	-fx-background-color: rgb(27, 27, 28);
199
	-fx-border-width: 1 1 0 1;
200
	-fx-border-color: black;
201
}
202
.combo-box .combo-box-popup .list-cell {
203
	-fx-background-color: rgb(27, 27, 28);
204
}
205
.combo-box .combo-box-popup .list-cell:hover {
206
	-fx-background-color: rgb(61, 61, 62);
207
}
208
.combo-box .combo-box-popup .list-view {
209
	-fx-background-color: rgb(27, 27, 28);
210
	-fx-border-width: 0 1 1 1;
211
	-fx-border-color: black;
212
}
213
.hyperlink {
214
	-fx-text-fill: rgb(30, 132, 250);
215
}
216
hyperlink:visited {
217
	-fx-text-fill: rgb(98, 59, 217);
218
}
219
/* slider */
220
.slider .track {
221
	-fx-background-radius: 0;
222
	-fx-background-color: rgb(29, 29, 31);
223
	-fx-background-insets: 0;
224
	-fx-border-width: 1;
225
	-fx-border-color: rgb(65, 65, 65);
226
}
227
.slider .thumb {
228
/*
229
	-fx-background-insets: 3;
230
	-fx-border-insets: 3;
231
	*/
232
	-fx-padding: 5;
233
}
234
.axis-tick-mark {
235
	-fx-stroke: rgb(100, 100, 100);
236
}
237
/* Text */
238
.text-area .content,
239
.text-field {
240
	-fx-background-radius: 0;
241
	-fx-background-color: rgb(63, 63, 70);
242
	-fx-background-insets: 0;
243
	-fx-border-width: 1;
244
	-fx-border-color: rgb(85, 85, 85);
245
}
246
.text-area {
247
	-fx-background-radius: 0;
248
	-fx-background-color: rgb(63, 63, 70);
249
	-fx-background-insets: 0;
250
	-fx-border-width: 1;
251
	-fx-border-color: rgb(85, 85, 85);
252
}
253
.text-area .content {
254
	-fx-border-width: 0;
255
}
256
/* Popup */
257
.tooltip {
258
	-fx-background-radius: 0;
259
	-fx-background-color: rgb(40, 40, 42);
260
	-fx-background-insets: 0;
261
	-fx-border-width: 1;
262
	-fx-border-color: rgb(70, 70, 72);
263
}
264
/* =========================
265
 * ==   Attach Elements   ==
266
 * =========================
267
 */
268
.vm-view {
269
	-fx-border-width: 0 0 0 1;
270
	-fx-border-color: black;
271
}
272
.vm-buttons {
273
	-fx-padding: 1 0 1 0;
274
}
275
.vm-buttons .button {
276
	-fx-min-width: 140px;
277
	-fx-min-height: 48px;
278
}
279
.vm-icon {
280
	-fx-padding: 2 15 2 2;
281
}
1282
283
/* =========================
284
 * ==   History Elements  ==
285
 * =========================
286
 */
287
.hist-view {
288
	-fx-border-width: 0 0 0 1;
289
	-fx-border-color: black;
290
}
291
.hist-buttons {
292
	-fx-padding: 1 0 1 0;
293
}
294
.hist-buttons .button {
295
	-fx-min-width: 140px;
296
	-fx-min-height: 48px;
297
}
298
.hist-icon {
299
	-fx-padding: 2 13 2 2;
300
}
301
/* =========================
302
 * ==    Other Elements   ==
303
 * =========================
304
 */
305
.faint {
306
	-fx-text-fill: rgb(134, 134, 135);
307
}
308
.search-button {
309
	-fx-background-image: url('../icons/find-light.png');
310
}
311
.search-field {
312
	-fx-prompt-text-fill: rgb(134, 134, 135);
313
	-fx-background-image: url('../icons/find-light.png');
314
	-fx-background-color: rgb(39, 39, 41);
315
	-fx-border-width: 1;
316
	-fx-border-insets: 0 0 -1 -1;
317
	-fx-border-color: black;
318
}
319
.resource-selector {
320
	-fx-prompt-text-fill: rgb(134, 134, 135);
321
	-fx-background-color: rgb(39, 39, 41);
322
	-fx-border-color: rgb(39, 39, 41) black black rgb(39, 39, 41);
323
	-fx-border-insets: 0 0 0 -1;
324
}
325
.resource-selector:hover {
326
	-fx-border-width: 1;
327
	-fx-border-insets: 0;
328
	-fx-padding: 0 0 0 -1;
329
}
330
.resource-selector:showing {
331
	-fx-border-color: black;
332
	-fx-border-insets: 0;
333
	-fx-border-width: 1 1 0 1;
334
	-fx-padding: 0 0 1 -1;
335
}
336
  /* Javadoc popup */
337
.drag-popup-wrapper {
338
	-fx-background-radius: 0;
339
	-fx-background-color: rgb(40, 40, 42);
340
	-fx-background-insets: 0;
341
	-fx-border-width: 1;
342
	-fx-border-color: rgb(95, 95, 95)
343
}
344
.drag-popup-wrapper .scroll-pane {
345
	-fx-background-insets: 0;
346
	-fx-border-width: 0;
347
	-fx-padding: 15;
348
}
349
.drag-popup-header {
350
	-fx-padding: 5;
351
	-fx-background-radius: 0;
352
	-fx-background-color: rgb(63, 63, 70);
353
	-fx-background-insets: 0;
354
	-fx-border-width: 0 0 1 0;
355
	-fx-border-color: rgb(95, 95, 95);
356
}
357
.update-header {
358
	-fx-padding: 5;
359
	-fx-background-color: rgb(32, 33, 35);
360
	-fx-border-width: 0 0 1 0;
361
	-fx-border-color: rgb(95, 95, 95);
362
}
363
.update-notes * {
364
	-fx-fill: rgb(220, 220, 220);
365
}
A src/main/resources/styles/dark/sakura-solarized.css
1
/* $color-text: #dedce5; */
2
/* Sakura.css v1.3.1
3
 * ================
4
 * Minimal css theme.
5
 * Project: https://github.com/oxalorg/sakura/
6
 */
7
/* Body */
8
html {
9
  font-size: 62.5%;
10
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif; }
11
12
body {
13
  font-size: 1.8rem;
14
  line-height: 1.618;
15
  max-width: 38em;
16
  margin: auto;
17
  color: #839496;
18
  background-color: #002b36;
19
  padding: 13px; }
20
21
@media (max-width: 684px) {
22
  body {
23
    font-size: 1.53rem; } }
24
25
@media (max-width: 382px) {
26
  body {
27
    font-size: 1.35rem; } }
28
29
h1, h2, h3, h4, h5, h6 {
30
  line-height: 1.1;
31
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif;
32
  font-weight: 700;
33
  margin-top: 3rem;
34
  margin-bottom: 1.5rem;
35
  overflow-wrap: break-word;
36
  word-wrap: break-word;
37
  -ms-word-break: break-all;
38
  word-break: break-word; }
39
40
h1 {
41
  font-size: 2.35em; }
42
43
h2 {
44
  font-size: 2.00em; }
45
46
h3 {
47
  font-size: 1.75em; }
48
49
h4 {
50
  font-size: 1.5em; }
51
52
h5 {
53
  font-size: 1.25em; }
54
55
h6 {
56
  font-size: 1em; }
57
58
p {
59
  margin-top: 0px;
60
  margin-bottom: 2.5rem; }
61
62
small, sub, sup {
63
  font-size: 75%; }
64
65
hr {
66
  border-color: #2aa198; }
67
68
a {
69
  text-decoration: none;
70
  color: #2aa198; }
71
  a:hover {
72
    color: #657b83;
73
    border-bottom: 2px solid #839496; }
74
  a:visited {
75
    color: #1f7972; }
76
77
ul {
78
  padding-left: 1.4em;
79
  margin-top: 0px;
80
  margin-bottom: 2.5rem; }
81
82
li {
83
  margin-bottom: 0.4em; }
84
85
blockquote {
86
  margin-left: 0px;
87
  margin-right: 0px;
88
  padding-left: 1em;
89
  padding-top: 0.8em;
90
  padding-bottom: 0.8em;
91
  padding-right: 0.8em;
92
  border-left: 5px solid #2aa198;
93
  margin-bottom: 2.5rem;
94
  background-color: #073642; }
95
96
blockquote p {
97
  margin-bottom: 0; }
98
99
img, video {
100
  height: auto;
101
  max-width: 100%;
102
  margin-top: 0px;
103
  margin-bottom: 2.5rem; }
104
105
/* Pre and Code */
106
pre {
107
  background-color: #073642;
108
  display: block;
109
  padding: 1em;
110
  overflow-x: auto;
111
  margin-top: 0px;
112
  margin-bottom: 2.5rem; }
113
114
code {
115
  font-size: 0.9em;
116
  padding: 0 0.5em;
117
  background-color: #073642;
118
  white-space: pre-wrap; }
119
120
pre > code {
121
  padding: 0;
122
  background-color: transparent;
123
  white-space: pre; }
124
125
/* Tables */
126
table {
127
  text-align: justify;
128
  width: 100%;
129
  border-collapse: collapse; }
130
131
td, th {
132
  padding: 0.5em;
133
  border-bottom: 1px solid #073642; }
134
135
/* Buttons, forms and input */
136
input, textarea {
137
  border: 1px solid #839496; }
138
  input:focus, textarea:focus {
139
    border: 1px solid #2aa198; }
140
141
textarea {
142
  width: 100%; }
143
144
.button, button, input[type="submit"], input[type="reset"], input[type="button"] {
145
  display: inline-block;
146
  padding: 5px 10px;
147
  text-align: center;
148
  text-decoration: none;
149
  white-space: nowrap;
150
  background-color: #2aa198;
151
  color: #002b36;
152
  border-radius: 1px;
153
  border: 1px solid #2aa198;
154
  cursor: pointer;
155
  box-sizing: border-box; }
156
  .button[disabled], button[disabled], input[type="submit"][disabled], input[type="reset"][disabled], input[type="button"][disabled] {
157
    cursor: default;
158
    opacity: .5; }
159
  .button:focus:enabled, .button:hover:enabled, button:focus:enabled, button:hover:enabled, input[type="submit"]:focus:enabled, input[type="submit"]:hover:enabled, input[type="reset"]:focus:enabled, input[type="reset"]:hover:enabled, input[type="button"]:focus:enabled, input[type="button"]:hover:enabled {
160
    background-color: #657b83;
161
    border-color: #657b83;
162
    color: #002b36;
163
    outline: 0; }
164
165
textarea, select, input {
166
  color: #839496;
167
  padding: 6px 10px;
168
  /* The 6px vertically centers text on FF, ignored by Webkit */
169
  margin-bottom: 10px;
170
  background-color: #073642;
171
  border: 1px solid #073642;
172
  border-radius: 4px;
173
  box-shadow: none;
174
  box-sizing: border-box; }
175
  textarea:focus, select:focus, input:focus {
176
    border: 1px solid #2aa198;
177
    outline: 0; }
178
179
input[type="checkbox"]:focus {
180
  outline: 1px dotted #2aa198; }
181
182
label, legend, fieldset {
183
  display: block;
184
  margin-bottom: .5rem;
185
  font-weight: 600; }
1186
A src/main/resources/styles/dark/toedter.css
1
.root {
2
  -fx-base: rgb(50, 50, 50);
3
  -fx-background: rgb(50, 50, 50);
4
  -fx-control-inner-background:  rgb(50, 50, 50);
5
}
6
 
7
.tab {
8
  -fx-background-color: linear-gradient(to top, -fx-base, derive(-fx-base,30%));
9
}
10
 
11
.menu-bar {
12
  -fx-background-color: linear-gradient(to bottom, -fx-base, derive(-fx-base,30%));
13
}
14
 
15
.tool-bar:horizontal {
16
  -fx-background-color:
17
linear-gradient(to bottom, derive(-fx-base,+50%), derive(-fx-base,-40%), derive(-fx-base,-20%));
18
}
19
 
20
.button {
21
  -fx-background-color: transparent;
22
}
23
 
24
.button:hover {
25
  -fx-background-color: -fx-shadow-highlight-color, -fx-outer-border, -fx-inner-border, -fx-body-color;
26
  -fx-color: -fx-hover-base;
27
}
28
 
29
.table-view {
30
  -fx-table-cell-border-color:derive(-fx-base,+10%);
31
  -fx-table-header-border-color:derive(-fx-base,+20%);
32
}
33
 
34
.split-pane:horizontal > * > .split-pane-divider {
35
  -fx-border-color: transparent -fx-base transparent -fx-base;
36
  -fx-background-color: transparent, derive(-fx-base,20%);
37
  -fx-background-insets: 0, 0 1 0 1;
38
}
39
 
40
.my-gridpane {
41
  -fx-background-color: radial-gradient(radius 100%, derive(-fx-base,20%), derive(-fx-base,-20%));
42
}
43
 
44
.separator-label {
45
  -fx-text-fill: orange;
46
}
47
148
A src/test/java/com/keenwrite/AwaitFxExtension.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite;
3
4
import javafx.embed.swing.JFXPanel;
5
import org.junit.jupiter.api.extension.BeforeAllCallback;
6
import org.junit.jupiter.api.extension.ExtensionContext;
7
import org.testfx.osgi.service.TestFx;
8
9
import java.util.concurrent.Semaphore;
10
11
import static javafx.application.Platform.runLater;
12
import static javax.swing.SwingUtilities.invokeLater;
13
14
/**
15
 * Blocks all unit tests until JavaFX is ready.
16
 */
17
public class AwaitFxExtension implements BeforeAllCallback {
18
  /**
19
   * Prevent {@link RuntimeException} for internal graphics not initialized yet.
20
   *
21
   * @param context Provided by the {@link TestFx} framework.
22
   * @throws InterruptedException Could not acquire semaphore.
23
   */
24
  @Override
25
  public void beforeAll( final ExtensionContext context )
26
    throws InterruptedException {
27
    final var semaphore = new Semaphore( 0 );
28
29
    invokeLater( () -> {
30
      // Prepare JavaFX toolkit and environment.
31
      new JFXPanel();
32
      runLater( semaphore::release );
33
    } );
34
35
    semaphore.acquire();
36
  }
37
}
138
M src/test/java/com/keenwrite/editors/markdown/MarkdownEditorTest.java
11
package com.keenwrite.editors.markdown;
22
3
import com.keenwrite.AwaitFxExtension;
34
import com.keenwrite.preferences.Workspace;
45
import org.junit.jupiter.api.Test;
56
import org.junit.jupiter.api.extension.ExtendWith;
67
import org.testfx.framework.junit5.ApplicationExtension;
78
89
import java.util.regex.Pattern;
910
1011
import static java.util.regex.Pattern.compile;
12
import static javafx.application.Platform.runLater;
1113
import static org.junit.jupiter.api.Assertions.assertEquals;
1214
import static org.junit.jupiter.api.Assertions.assertTrue;
1315
14
@ExtendWith( ApplicationExtension.class )
16
@ExtendWith( {ApplicationExtension.class, AwaitFxExtension.class} )
1517
public class MarkdownEditorTest {
1618
  private static final String[] WORDS = new String[]{
...
5254
  @Test
5355
  public void test_CaretWord_GetISO88591Word_WordSelected() {
54
    final var editor = createMarkdownEditor();
56
    runLater( () -> {
57
      final var editor = createMarkdownEditor();
5558
56
    for( int i = 0; i < WORDS.length; i++ ) {
57
      final var word = WORDS[ i ];
58
      final var len = word.length();
59
      final var expected = REGEX.matcher( word ).replaceAll( "" );
59
      for( int i = 0; i < WORDS.length; i++ ) {
60
        final var word = WORDS[ i ];
61
        final var len = word.length();
62
        final var expected = REGEX.matcher( word ).replaceAll( "" );
6063
61
      for( int j = 0; j < len; j++ ) {
62
        editor.moveTo( offset( i ) + j );
63
        final var actual = editor.getCaretWordText();
64
        assertEquals( expected, actual );
64
        for( int j = 0; j < len; j++ ) {
65
          editor.moveTo( offset( i ) + j );
66
          final var actual = editor.getCaretWordText();
67
          assertEquals( expected, actual );
68
        }
6569
      }
66
    }
70
    } );
6771
  }
6872
M src/test/java/com/keenwrite/processors/markdown/ImageLinkExtensionTest.java
22
package com.keenwrite.processors.markdown;
33
4
import com.keenwrite.AwaitFxExtension;
5
import com.keenwrite.Caret;
46
import com.keenwrite.preferences.Workspace;
7
import com.keenwrite.preview.HtmlPreview;
8
import com.keenwrite.processors.Processor;
9
import com.keenwrite.processors.ProcessorContext;
510
import com.keenwrite.processors.markdown.extensions.ImageLinkExtension;
611
import com.vladsch.flexmark.html.HtmlRenderer;
712
import com.vladsch.flexmark.parser.Parser;
13
import javafx.stage.Stage;
814
import org.junit.jupiter.api.Test;
915
import org.junit.jupiter.api.extension.ExtendWith;
1016
import org.testfx.framework.junit5.ApplicationExtension;
17
import org.testfx.framework.junit5.Start;
1118
1219
import java.io.File;
13
import java.net.URISyntaxException;
20
import java.net.URI;
1421
import java.net.URL;
1522
import java.nio.file.Path;
1623
import java.nio.file.Paths;
1724
import java.util.HashMap;
1825
import java.util.List;
1926
import java.util.Map;
2027
28
import static com.keenwrite.Constants.DOCUMENT_DEFAULT;
29
import static com.keenwrite.ExportFormat.NONE;
2130
import static java.lang.String.format;
31
import static javafx.application.Platform.runLater;
2232
import static org.junit.jupiter.api.Assertions.assertEquals;
2333
import static org.junit.jupiter.api.Assertions.assertNotNull;
34
import static org.testfx.util.WaitForAsyncUtils.waitForFxEvents;
2435
2536
/**
2637
 * Responsible for testing that linked images render into HTML according to
2738
 * the {@link ImageLinkExtension} rules.
2839
 */
29
@ExtendWith( ApplicationExtension.class )
40
@ExtendWith( {ApplicationExtension.class, AwaitFxExtension.class} )
3041
@SuppressWarnings( "SameParameterValue" )
3142
public class ImageLinkExtensionTest {
43
  private static final Workspace sWorkspace = new Workspace(
44
    getResource( "workspace.xml" ) );
3245
3346
  private static final Map<String, String> IMAGES = new HashMap<>();
...
5770
    addUri( URI_PATH + ".png" );
5871
    addUri( URI_PATH + ".jpg" );
59
    addUri( URI_PATH, URI_PATH + URI_IMAGE_EXT );
72
    addUri( URI_PATH, getResource( URI_PATH + URI_IMAGE_EXT ) );
6073
    addUri( "//" + URI_WEB );
6174
    addUri( "http://" + URI_WEB );
6275
    addUri( "https://" + URI_WEB );
6376
  }
6477
65
  private static void addUri( final String uri ) {
66
    addUri( uri, uri );
78
  private HtmlPreview mPreview;
79
80
  @Start
81
  @SuppressWarnings( "unused" )
82
  private void start( final Stage stage ) {
83
    mPreview = new HtmlPreview( sWorkspace );
6784
  }
6885
69
  private static void addUri( final String uriKey, final String uriValue ) {
70
    IMAGES.put( toMd( uriKey ), toHtml( uriValue ) );
86
  private static void addUri( final String actualExpected ) {
87
    addUri( actualExpected, actualExpected );
7188
  }
7289
73
  private static String toMd( final String file ) {
74
    return format( "![Tooltip](%s 'Title')", file );
90
  private static void addUri( final String actual, final String expected ) {
91
    IMAGES.put( toMd( actual ), toHtml( expected ) );
7592
  }
7693
77
  private static String toHtml( final String file ) {
94
  private static String toMd( final String resource ) {
95
    return format( "![Tooltip](%s 'Title')", resource );
96
  }
97
98
  private static String toHtml( final String url ) {
7899
    return format(
79
      "<p><img src=\"%s\" alt=\"Tooltip\" title=\"Title\" /></p>\n", file );
100
      "<p><img src=\"%s\" alt=\"Tooltip\" title=\"Title\" /></p>\n", url );
80101
  }
81102
82103
  /**
83104
   * Test that the key URIs present in the {@link #IMAGES} map are rendered
84105
   * as the value URIs present in the same map.
85106
   */
86107
  @Test
87
  void test_LocalImage_RelativePathWithExtension_ResolvedSuccessfully()
88
    throws URISyntaxException {
89
    final var workspace = new Workspace();
90
    final var resource = getPathResource( URI_IMAGE );
108
  void test_ImageLookup_RelativePathWithExtension_ResolvedSuccessfully() {
109
    final var resource = getResourcePath( URI_IMAGE );
91110
    final var imagePath = new File( URI_IMAGE ).toPath();
92111
    final var subpaths = resource.getNameCount() - imagePath.getNameCount();
93112
    final var subpath = resource.subpath( 0, subpaths );
94113
95114
    // The root component isn't considered part of the path, so add it back.
96
    final var path = resource.getRoot().resolve( subpath );
97
98
    final var extension = ImageLinkExtension.create( path, workspace );
115
    final var documentPath = Path.of(
116
      resource.getRoot().resolve( subpath ).toString(),
117
      DOCUMENT_DEFAULT.getName() );
118
    final var context = createProcessorContext( documentPath );
119
    final var extension = ImageLinkExtension.create( context );
99120
    final var extensions = List.of( extension );
100121
    final var pBuilder = Parser.builder();
101122
    final var hBuilder = HtmlRenderer.builder();
102123
    final var parser = pBuilder.extensions( extensions ).build();
103124
    final var renderer = hBuilder.extensions( extensions ).build();
104125
105126
    assertNotNull( parser );
106127
    assertNotNull( renderer );
107
108
    // Set a default (fallback) image directory search location.
109
    //getInstance().imagesDirectoryProperty().setValue( new File( "." ) );
110128
111129
    for( final var entry : IMAGES.entrySet() ) {
112130
      final var key = entry.getKey();
113131
      final var node = parser.parse( key );
114132
      final var expectedHtml = entry.getValue();
115
      final var actualHtml = renderer.render( node );
133
      final var actualHtml = new StringBuilder( 128 );
116134
117
      assertEquals( expectedHtml, actualHtml );
135
      runLater( () -> actualHtml.append( renderer.render( node ) ) );
136
137
      waitForFxEvents();
138
      assertEquals( expectedHtml, actualHtml.toString() );
118139
    }
119140
  }
120
121
  private Path getPathResource( final String path )
122
    throws URISyntaxException {
123
    final var url = getResource( path );
124
    assert url != null;
125141
126
    final var uri = url.toURI();
127
    return Paths.get( uri );
142
  /**
143
   * Creates a new {@link ProcessorContext} for the given file name path.
144
   *
145
   * @param documentPath Fully qualified path to the file name.
146
   * @return A context used for creating new {@link Processor} instances.
147
   */
148
  private ProcessorContext createProcessorContext( final Path documentPath ) {
149
    return new ProcessorContext(
150
      mPreview,
151
      new HashMap<>(),
152
      documentPath,
153
      Caret.builder().build(),
154
      NONE,
155
      sWorkspace
156
    );
128157
  }
129158
130
  private URL getResource( final String path ) {
131
    final var packagePath = getClass().getPackageName().replace( '.', '/' );
159
  private static URL toUrl( final String path ) {
160
    final var clazz = ImageLinkExtensionTest.class;
161
    final var packagePath = clazz.getPackageName().replace( '.', '/' );
132162
    final var resourcePath = '/' + packagePath + '/' + path;
133
    return getClass().getResource( resourcePath );
163
    return clazz.getResource( resourcePath );
164
  }
165
166
  private static URI toUri( final String path ) {
167
    try {
168
      return toUrl( path ).toURI();
169
    } catch( final Exception ex ) {
170
      throw new RuntimeException( ex );
171
    }
172
  }
173
174
  private static Path getResourcePath( final String path ) {
175
    return Paths.get( toUri( path ) );
176
  }
177
178
  private static String getResource( final String path ) {
179
    return toUri( path ).toString();
134180
  }
135181
}
A src/test/resources/com/keenwrite/processors/markdown/workspace.xml
1
<?xml version="1.0" encoding="UTF-8"?>
2
<keenwrite>
3
   <workspace>
4
      <images>
5
         <order>svg pdf png jpg tiff</order>
6
         <dir></dir>
7
      </images>
8
   </workspace>
9
</keenwrite>
110