Dave Jarvis' Repositories

git clone https://repo.autonoma.ca/repo/keenwrite.git
M .gitignore
55
build
66
.gradle
7
contacted.csv
87
video
98
.settings
109
.classpath
1110
.idea
1211
themes
1312
quotes
13
tex
14
spell
1415
A Containerfile
1
FROM alpine:latest
2
3
RUN apk --update add --no-cache fontconfig curl
4
RUN rm -rf /var/cache
5
6
# Download fonts.
7
ENV FONT_DIR=/usr/share/fonts/user
8
RUN mkdir -p $FONT_DIR
9
WORKDIR $FONT_DIR
10
11
ADD "https://fonts.google.com/download?family=Roboto" "roboto.zip"
12
ADD "https://fonts.google.com/download?family=Inconsolata" "inconsolata.zip"
13
ADD "https://github.com/adobe-fonts/source-serif/releases/download/4.004R/source-serif-4.004.zip" "source-serif.zip"
14
ADD "https://github.com/googlefonts/Libre-Baskerville/blob/master/fonts/ttf/LibreBaskerville-Bold.ttf" "LibreBaskerville-Bold.ttf"
15
ADD "https://github.com/googlefonts/Libre-Baskerville/blob/master/fonts/ttf/LibreBaskerville-Italic.ttf" "LibreBaskerville-Italic.ttf"
16
ADD "https://github.com/googlefonts/Libre-Baskerville/blob/master/fonts/ttf/LibreBaskerville-Regular.ttf" "LibreBaskerville-Regular.ttf"
17
ADD "https://www.omnibus-type.com/wp-content/uploads/Archivo-Narrow.zip" "archivo-narrow.zip"
18
19
# Unpack fonts (prior to ConTeXt).
20
RUN unzip -j -o roboto.zip "*.ttf"
21
RUN unzip -j -o inconsolata.zip "**/Inconsolata/*.ttf"
22
RUN unzip -j -o source-serif.zip "source-serif-4.004/OTF/SourceSerif4-*.otf"
23
RUN unzip -j -o archivo-narrow.zip "Archivo-Narrow/otf/*.otf"
24
RUN rm -f roboto.zip
25
RUN rm -f inconsolata.zip
26
RUN rm -f source-serif.zip
27
RUN rm -f archivo-narrow.zip
28
29
# Update system font cache.
30
RUN fc-cache -f -v
31
32
WORKDIR "/opt"
33
34
# Download themes.
35
ADD "https://github.com/DaveJarvis/keenwrite-themes/releases/latest/download/theme-pack.zip" "theme-pack.zip"
36
RUN unzip theme-pack.zip
37
38
# Download ConTeXt.
39
ADD "http://lmtx.pragma-ade.nl/install-lmtx/context-linuxmusl.zip" "context.zip"
40
RUN unzip context.zip -d context
41
RUN rm -f context.zip
42
43
# Install ConTeXt.
44
WORKDIR "context"
45
RUN sh install.sh
46
47
# Configure environment to find ConTeXt.
48
ENV PROFILE=/etc/profile
49
ENV CONTEXT_HOME=/opt/context
50
RUN echo "export CONTEXT_HOME=\"$CONTEXT_HOME\"" >> $PROFILE
51
RUN echo "export PATH=\"\$PATH:\$CONTEXT_HOME/tex/texmf-linuxmusl/bin\"" >> $PROFILE
52
RUN echo "export OSFONTDIR=\"/usr/share/fonts//\""
53
RUN echo "PS1=\"typesetter:\\w\\\$ \"" >> $PROFILE
54
55
# Trim the fat.
56
RUN source $PROFILE
57
RUN rm -rf $CONTEXT_HOME/tex/texmf-context/doc
58
RUN find . -type f -name "*.pdf" -exec rm {} \;
59
60
# Prepare to process text files.
61
WORKDIR "/root"
62
163
M build.gradle
1010
  maven { url 'https://oss.sonatype.org/content/repositories/snapshots/' }
1111
  maven { url 'https://nexus.bedatadriven.com/content/groups/public' }
12
13
  maven {
14
    url "https://css4j.github.io/maven/"
15
    mavenContent {
16
      releasesOnly()
17
    }
18
    content {
19
      includeGroup 'com.github.css4j'
20
      includeGroup 'io.sf.carte'
21
      includeGroup 'io.sf.jclf'
22
    }
23
  }
1224
}
1325
...
4961
  def v_jackson = '2.13.3'
5062
  def v_batik = '1.14'
63
  def v_echosvg = '0.2.1'
5164
5265
  // JavaFX
5366
  implementation 'org.controlsfx:controlsfx:11.1.1'
5467
  implementation 'org.fxmisc.richtext:richtextfx:0.10.9'
5568
  implementation 'org.fxmisc.flowless:flowless:0.6.10'
5669
  implementation 'org.fxmisc.wellbehaved:wellbehavedfx:0.3.3'
5770
  implementation 'com.miglayout:miglayout-javafx:11.0'
58
  implementation 'com.dlsc.preferencesfx:preferencesfx-core:11.9.1'
71
  implementation 'com.dlsc.preferencesfx:preferencesfx-core:11.10.0'
5972
6073
  // Markdown
...
7487
7588
  // XML
76
  implementation 'com.ximpleware:vtd-xml:2.13.4'
89
  //implementation 'com.ximpleware:vtd-xml:2.13.4'
7790
7891
  // HTML parsing and rendering
79
  implementation 'org.jsoup:jsoup:1.15.2'
92
  implementation 'org.jsoup:jsoup:1.15.3'
8093
  // TODO: https://github.com/flyingsaucerproject/flyingsaucer/pull/170
8194
  //implementation 'org.xhtmlrenderer:flying-saucer-core:9.1.22'
8295
8396
  // R
8497
  implementation 'org.renjin:renjin-script-engine:3.5-beta76'
98
  implementation 'org.renjin.cran:rjson:0.2.15-renjin-21'
8599
86100
  // SVG
87
  implementation "org.apache.xmlgraphics:batik-anim:${v_batik}"
88
  implementation "org.apache.xmlgraphics:batik-awt-util:${v_batik}"
89
  implementation "org.apache.xmlgraphics:batik-bridge:${v_batik}"
90
  implementation "org.apache.xmlgraphics:batik-css:${v_batik}"
91
  implementation "org.apache.xmlgraphics:batik-dom:${v_batik}"
92
  implementation "org.apache.xmlgraphics:batik-ext:${v_batik}"
93
  implementation "org.apache.xmlgraphics:batik-gvt:${v_batik}"
94
  implementation "org.apache.xmlgraphics:batik-parser:${v_batik}"
95
  implementation "org.apache.xmlgraphics:batik-script:${v_batik}"
96
  implementation "org.apache.xmlgraphics:batik-svg-dom:${v_batik}"
97
  implementation "org.apache.xmlgraphics:batik-svggen:${v_batik}"
98
  implementation "org.apache.xmlgraphics:batik-transcoder:${v_batik}"
99
  implementation "org.apache.xmlgraphics:batik-rasterizer:${v_batik}"
100
  implementation "org.apache.xmlgraphics:batik-util:${v_batik}"
101
  implementation "org.apache.xmlgraphics:batik-xml:${v_batik}"
101
  implementation "io.sf.carte:echosvg-awt-util:${v_echosvg}"
102
  implementation "io.sf.carte:echosvg-bridge:${v_echosvg}"
103
  implementation "io.sf.carte:echosvg-css:${v_echosvg}"
104
  implementation "io.sf.carte:echosvg-dom:${v_echosvg}"
105
  implementation "io.sf.carte:echosvg-ext:${v_echosvg}"
106
  implementation "io.sf.carte:echosvg-gvt:${v_echosvg}"
107
  implementation "io.sf.carte:echosvg-parser:${v_echosvg}"
108
  implementation "io.sf.carte:echosvg-script:${v_echosvg}"
109
  implementation "io.sf.carte:echosvg-svg-dom:${v_echosvg}"
110
  implementation "io.sf.carte:echosvg-svggen:${v_echosvg}"
111
  implementation "io.sf.carte:echosvg-transcoder:${v_echosvg}"
112
  implementation "io.sf.carte:echosvg-util:${v_echosvg}"
113
  implementation "io.sf.carte:echosvg-xml:${v_echosvg}"
102114
103115
  // Misc.
A context-container.sh
1
#!/usr/bin/env bash
2
3
if [ -z ${IMAGES_DIR} ]; then
4
  echo "Set IMAGES_DIR"
5
  exit 10
6
fi
7
8
readonly CONTAINER_NAME=typesetter
9
10
# Force clean
11
podman rmi --all --force
12
13
# Build from Containerfile
14
podman build --tag ${CONTAINER_NAME} .
15
16
# Connect and mount images
17
podman run \
18
  --rm \
19
  -i \
20
  -v ${IMAGES_DIR}:/root/images:ro \
21
  -t ${CONTAINER_NAME} \
22
  /bin/sh --login -c 'context --version'
23
24
# Create a persistent container
25
# podman create typesetter typesetter
26
27
# Create a long-running task
28
# podman create -ti typesetter /bin/sh
29
30
# Connect
31
32
# Export
33
# podman image save context -o typesetter.tar
34
# zip -9 -r typesetter.zip typesetter.tar
35
136
D docs/video/.gitignore
1
*.avi
2
*.wav
3
*.png
4
*.mp4
5
*.mp3
6
71
D docs/video/title.blend
Binary file
D docs/video/traced-text.svg
1
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
2
<svg
3
   xmlns:dc="http://purl.org/dc/elements/1.1/"
4
   xmlns:cc="http://creativecommons.org/ns#"
5
   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
6
   xmlns:svg="http://www.w3.org/2000/svg"
7
   xmlns="http://www.w3.org/2000/svg"
8
   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
9
   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
10
   width="211.87125mm"
11
   height="56.576mm"
12
   viewBox="0 0 211.87125 56.576"
13
   version="1.1"
14
   id="svg8"
15
   inkscape:version="1.0 (4035a4fb49, 2020-05-01)"
16
   sodipodi:docname="traced-text.svg">
17
  <defs
18
     id="defs2" />
19
  <sodipodi:namedview
20
     id="base"
21
     pagecolor="#ffffff"
22
     bordercolor="#666666"
23
     borderopacity="1.0"
24
     inkscape:pageopacity="0.0"
25
     inkscape:pageshadow="2"
26
     inkscape:zoom="1.4142136"
27
     inkscape:cx="367.6429"
28
     inkscape:cy="129.23348"
29
     inkscape:document-units="mm"
30
     inkscape:current-layer="layer1"
31
     inkscape:document-rotation="0"
32
     showgrid="false"
33
     fit-margin-top="10"
34
     fit-margin-left="10"
35
     fit-margin-right="10"
36
     fit-margin-bottom="10" />
37
  <metadata
38
     id="metadata5">
39
    <rdf:RDF>
40
      <cc:Work
41
         rdf:about="">
42
        <dc:format>image/svg+xml</dc:format>
43
        <dc:type
44
           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
45
        <dc:title></dc:title>
46
      </cc:Work>
47
    </rdf:RDF>
48
  </metadata>
49
  <g
50
     inkscape:label="Layer 1"
51
     inkscape:groupmode="layer"
52
     id="layer1"
53
     transform="translate(-1.4263456,-106.05539)">
54
    <text
55
       xml:space="preserve"
56
       style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:50.8px;line-height:1.25;font-family:'Alex Brush';-inkscape-font-specification:'Alex Brush, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.264583"
57
       x="12.289946"
58
       y="147.80539"
59
       id="text835"><tspan
60
         sodipodi:role="line"
61
         id="tspan833"
62
         x="12.289946"
63
         y="147.80539"
64
         style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:50.8px;font-family:'Alex Brush';-inkscape-font-specification:'Alex Brush, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;stroke-width:0.264583">Scrivenvar</tspan></text>
65
    <path
66
       sodipodi:nodetypes="cssssc"
67
       id="path859"
68
       d="m 47.37594,126.25759 c 5.878995,0.58684 8.108819,-2.8906 6.991897,-5.39049 -4.163299,-9.31827 -26.104298,-1.57165 -26.47428,4.67958 -0.290066,4.90098 4.329286,5.69691 9.138161,6.81221 4.75698,1.10326 9.980125,1.72503 10.138085,4.5281 0.511551,9.07772 -11.28247,13.50974 -21.577969,13.14767"
69
       style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#4eb059;stroke-width:0.132292;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" />
70
    <path
71
       sodipodi:nodetypes="cssc"
72
       id="path861"
73
       d="m 61.538159,137.91416 c 8.229745,-12.05206 -9.227635,-1.22793 -10.272792,5.40306 -0.929347,5.89623 4.566953,5.63307 9.024721,2.11036 5.095939,-4.02702 8.706628,-8.11599 12.031905,-13.9409"
74
       style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#4eb059;stroke-width:0.132292;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" />
75
    <path
76
       sodipodi:nodetypes="ccssc"
77
       id="path863"
78
       d="m 72.321991,131.48668 c 3.834665,-5.91801 -1.131419,0.83402 0.75311,2.48796 2.189872,1.94816 6.580549,-2.11016 5.400159,-0.72958 -0.854851,0.99983 -9.857527,10.41157 -5.126492,13.80621 2.461609,1.76627 8.936925,-2.58857 11.751532,-5.5313"
79
       style="fill:none;fill-opacity:0.8;stroke:#4eb059;stroke-width:0.132292;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" />
80
    <path
81
       sodipodi:nodetypes="csssc"
82
       id="path963"
83
       d="m 85.1003,141.51997 c 0,0 6.754775,-9.24626 6.743495,-8.01563 -0.01328,1.44899 -5.040946,6.68411 -6.63123,10.08427 -0.90584,1.93677 -0.626402,4.68995 2.447111,4.25184 1.468017,-0.20926 5.212094,-2.44913 10.029682,-7.66684"
84
       style="fill:none;fill-opacity:0.8;stroke:#4eb059;stroke-width:0.132292;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" />
85
    <path
86
       sodipodi:nodetypes="csccc"
87
       id="path965"
88
       d="m 97.689357,140.17361 c 0,0 3.797813,-8.42805 4.594353,-7.95573 0.58723,0.34822 -6.526154,13.32545 -5.477472,14.50806 2.435753,1.7862 19.064212,-11.51107 15.563042,-16.73913 -0.73409,-1.34256 -3.18033,-1.99148 -3.18033,-1.99148"
89
       style="fill:none;fill-opacity:0.8;stroke:#4eb059;stroke-width:0.132292;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" />
90
    <path
91
       sodipodi:nodetypes="csssc"
92
       d="m 113.37707,141.34636 c 4.23091,0.29831 11.94363,-4.90618 10.94354,-7.7799 -1.29105,-3.70978 -8.05529,1.78774 -9.69006,3.68511 -4.97668,5.77609 -4.11733,10.31478 -0.92228,10.61275 3.436,0.32045 8.83724,-3.13085 13.69698,-9.62574"
93
       style="fill:none;fill-opacity:0.8;stroke:#4eb059;stroke-width:0.132292;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
94
       id="path967" />
95
    <path
96
       style="fill:none;fill-opacity:0.8;stroke:#4eb059;stroke-width:0.132292;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
97
       d="m 146.49943,140.17361 c 0,0 3.79781,-8.42805 4.59435,-7.95573 0.58723,0.34822 -6.52616,13.32545 -5.47747,14.50806 2.43575,1.7862 19.06421,-11.51107 15.56304,-16.73913 -0.73409,-1.34256 -3.10123,-1.96263 -3.10123,-1.96263"
98
       id="path970"
99
       sodipodi:nodetypes="csccc" />
100
    <path
101
       style="fill:none;fill-opacity:0.8;stroke:#4eb059;stroke-width:0.132292;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
102
       d="m 188.80833,131.36316 c 3.83466,-5.91801 -1.13142,0.83402 0.75311,2.48796 2.18987,1.94816 6.58055,-2.11016 5.40016,-0.72958 -0.85485,0.99983 -9.98962,10.60367 -5.12649,13.80621 2.8329,1.86556 9.63808,-2.25455 13.61435,-8.05051"
103
       id="path987"
104
       sodipodi:nodetypes="ccssc" />
105
    <path
106
       sodipodi:nodetypes="ccsssccc"
107
       d="m 127.40525,138.23858 c 1.53961,-1.23511 5.06979,-6.4876 5.94375,-5.82833 -1.7832,2.5949 -8.95273,13.68991 -7.1105,13.94503 1.19011,0.16482 7.25976,-8.00422 10.87675,-10.901 1.83151,-1.46682 4.35069,-3.49971 5.94917,-3.73267 1.66376,-0.24247 -1.93803,2.90472 -3.80099,5.77097 -1.36327,2.14988 -4.92421,8.02816 -2.69839,9.35481 3.0826,1.21137 7.35116,-4.27566 9.93439,-6.67382"
108
       style="fill:none;fill-opacity:0.8;stroke:#4eb059;stroke-width:0.132292;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
109
       id="path989" />
110
    <path
111
       sodipodi:nodetypes="csscsc"
112
       id="path992"
113
       d="m 176.85645,132.78853 c -3.26879,-6.24001 -16.43513,7.99373 -16.14879,12.14556 0.1378,1.99804 2.16776,3.14653 3.8818,2.44798 4.44909,-1.8132 11.93103,-13.58278 13.4413,-14.18515 -6.97685,9.84354 -7.04537,13.29844 -4.02229,13.83262 2.49715,0.44125 8.94275,-6.11484 14.79986,-15.66638"
114
       style="fill:none;fill-opacity:0.8;stroke:#4eb059;stroke-width:0.132292;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" />
115
  </g>
116
</svg>
1171
D libs/jmathtex.jar
Binary file
D libs/jsymspell-1.0.jar
Binary file
A libs/keenspell.jar
Binary file
A libs/keentex.jar
Binary file
A module-info.txt
1
module keenwrite.main {
2
  requires java.desktop;
3
  requires java.prefs;
4
  requires java.scripting;
5
  requires java.xml;
6
  requires javafx.graphics;
7
  requires javafx.controls;
8
  requires javafx.swing;
9
10
  requires annotations;
11
12
  requires batik.anim;
13
  requires batik.bridge;
14
  requires batik.css;
15
  requires batik.gvt;
16
  requires batik.transcoder;
17
  requires batik.util;
18
19
  requires com.dlsc.formsfx;
20
  requires transitive com.dlsc.preferencesfx;
21
  requires com.fasterxml.jackson.databind;
22
  requires transitive com.fasterxml.jackson.dataformat.yaml;
23
24
  requires flexmark;
25
  requires flexmark.util.data;
26
  requires flexmark.util.sequence;
27
28
  requires keenquotes;
29
  requires keentex;
30
  requires tokenize;
31
32
  requires org.apache.commons.lang3;
33
  requires org.jsoup;
34
  requires org.controlsfx.controls;
35
  requires org.fxmisc.flowless;
36
  requires org.fxmisc.richtext;
37
  requires org.fxmisc.undo;
38
39
  requires commons.io;
40
  requires eventbus.java;
41
  requires flying.saucer.core;
42
  requires info.picocli;
43
  requires jsymspell;
44
  requires plexus.utils;
45
  requires tiwulfx.dock;
46
  requires wellbehavedfx;
47
  requires xml.apis.ext;
48
  requires java.logging;
49
}
50
151
M src/main/java/com/keenwrite/Bootstrap.java
88
import java.util.Properties;
99
10
import static org.apache.batik.util.ParsedURL.setGlobalUserAgent;
11
1210
/**
1311
 * Responsible for loading the bootstrap.properties file, which is
14
 * tactically located outside of the standard resource reverse domain name
12
 * tactically located outside the standard resource reverse domain name
1513
 * namespace to avoid hard-coding the application name in many places.
1614
 * Instead, the application name is located in the bootstrap file, which is
17
 * then used to look-up the remaining settings.
15
 * then used to look up the remaining settings.
1816
 * <p>
1917
 * See {@link Constants#PATH_PROPERTIES_SETTINGS} for details.
...
4038
4139
  static {
40
    // This also sets the user agent for the SVG rendering library.
4241
    System.setProperty( "http.agent", APP_TITLE + " " + APP_VERSION );
43
    setGlobalUserAgent( System.getProperty( "http.agent" ) );
4442
  }
4543
M src/main/java/com/keenwrite/MainPane.java
203203
    register( this );
204204
    initAutosave( workspace );
205
  }
206
207
  @Subscribe
208
  public void handle( final TextEditorFocusEvent event ) {
209
    mTextEditor.set( event.get() );
210
  }
211
212
  @Subscribe
213
  public void handle( final TextDefinitionFocusEvent event ) {
214
    mDefinitionEditor.set( event.get() );
215
  }
216
217
  /**
218
   * Typically called when a file name is clicked in the preview panel.
219
   *
220
   * @param event The event to process, must contain a valid file reference.
221
   */
222
  @Subscribe
223
  public void handle( final FileOpenEvent event ) {
224
    final File eventFile;
225
    final var eventUri = event.getUri();
226
227
    if( eventUri.isAbsolute() ) {
228
      eventFile = new File( eventUri.getPath() );
229
    }
230
    else {
231
      final var activeFile = getTextEditor().getFile();
232
      final var parent = activeFile.getParentFile();
233
234
      if( parent == null ) {
235
        clue( new FileNotFoundException( eventUri.getPath() ) );
236
        return;
237
      }
238
      else {
239
        final var parentPath = parent.getAbsolutePath();
240
        eventFile = Path.of( parentPath, eventUri.getPath() ).toFile();
241
      }
242
    }
243
244
    runLater( () -> open( eventFile ) );
245
  }
246
247
  @Subscribe
248
  public void handle( final CaretNavigationEvent event ) {
249
    runLater( () -> {
250
      final var textArea = getTextEditor().getTextArea();
251
      textArea.moveTo( event.getOffset() );
252
      textArea.requestFollowCaret();
253
      textArea.requestFocus();
254
    } );
255
  }
256
257
  @Subscribe
258
  @SuppressWarnings( "unused" )
259
  public void handle( final ExportFailedEvent event ) {
260
    final var os = getProperty( "os.name" );
261
    final var arch = getProperty( "os.arch" ).toLowerCase();
262
    final var bits = getProperty( "sun.arch.data.model" );
263
264
    final var title = Messages.get( "Alert.typesetter.missing.title" );
265
    final var header = Messages.get( "Alert.typesetter.missing.header" );
266
    final var version = Messages.get(
267
      "Alert.typesetter.missing.version",
268
      os,
269
      arch
270
        .replaceAll( "amd.*|i.*|x86.*", "X86" )
271
        .replaceAll( "mips.*", "MIPS" )
272
        .replaceAll( "armv.*", "ARM" ),
273
      bits );
274
    final var text = Messages.get( "Alert.typesetter.missing.installer.text" );
275
276
    // Download and install ConTeXt for {0} {1} {2}-bit
277
    final var content = format( "%s %s", text, version );
278
    final var flowPane = new FlowPane();
279
    final var link = new Hyperlink( text );
280
    final var label = new Label( version );
281
    flowPane.getChildren().addAll( link, label );
282
283
    final var alert = new Alert( ERROR, content, OK );
284
    alert.setTitle( title );
285
    alert.setHeaderText( header );
286
    alert.getDialogPane().contentProperty().set( flowPane );
287
    alert.setGraphic( ICON_DIALOG_NODE );
288
289
    link.setOnAction( ( e ) -> {
290
      alert.close();
291
      final var url = Messages.get( "Alert.typesetter.missing.installer.url" );
292
      runLater( () -> HyperlinkOpenEvent.fire( url ) );
293
    } );
294
295
    alert.showAndWait();
296
  }
297
298
  @Subscribe
299
  public void handle( final InsertDefinitionEvent<String> event ) {
300
    final var leaf = event.getLeaf();
301
    final var editor = mTextEditor.get();
302
303
    System.out.println( "INJECT: " + leaf.toPath() );
304
305
    mVariableNameInjector.insert( editor, leaf );
306
  }
307
308
  private void initAutosave( final Workspace workspace ) {
309
    final var rate = workspace.integerProperty( KEY_EDITOR_AUTOSAVE );
310
311
    rate.addListener(
312
      ( c, o, n ) -> {
313
        final var taskRef = mSaveTask.get();
314
315
        // Prevent multiple autosaves from running.
316
        if( taskRef != null ) {
317
          taskRef.cancel( false );
318
        }
319
320
        initAutosave( rate );
321
      }
322
    );
323
324
    // Start the save listener (avoids duplicating some code).
325
    initAutosave( rate );
326
  }
327
328
  private void initAutosave( final IntegerProperty rate ) {
329
    mSaveTask.set(
330
      mSaver.scheduleAtFixedRate(
331
        () -> {
332
          if( getTextEditor().isModified() ) {
333
            // Ensure the modified indicator is cleared by running on EDT.
334
            runLater( this::save );
335
          }
336
        }, 0, rate.intValue(), SECONDS
337
      )
338
    );
339
  }
340
341
  /**
342
   * TODO: Load divider positions from exported settings, see
343
   *   {@link #collect(SetProperty)} comment.
344
   */
345
  private double[] calculateDividerPositions() {
346
    final var ratio = 100f / getItems().size() / 100;
347
    final var positions = getDividerPositions();
348
349
    for( int i = 0; i < positions.length; i++ ) {
350
      positions[ i ] = ratio * i;
351
    }
352
353
    return positions;
354
  }
355
356
  /**
357
   * Opens all the files into the application, provided the paths are unique.
358
   * This may only be called for any type of files that a user can edit
359
   * (i.e., update and persist), such as definitions and text files.
360
   *
361
   * @param files The list of files to open.
362
   */
363
  public void open( final List<File> files ) {
364
    files.forEach( this::open );
365
  }
366
367
  /**
368
   * This opens the given file. Since the preview pane is not a file that
369
   * can be opened, it is safe to add a listener to the detachable pane.
370
   * This will exit early if the given file is not a regular file (i.e., a
371
   * directory).
372
   *
373
   * @param inputFile The file to open.
374
   */
375
  private void open( final File inputFile ) {
376
    // Prevent opening directories (a non-existent "untitled.md" is fine).
377
    if( !inputFile.isFile() && inputFile.exists() ) {
378
      return;
379
    }
380
381
    final var tab = createTab( inputFile );
382
    final var node = tab.getContent();
383
    final var mediaType = MediaType.valueFrom( inputFile );
384
    final var tabPane = obtainTabPane( mediaType );
385
386
    tab.setTooltip( createTooltip( inputFile ) );
387
    tabPane.setFocusTraversable( false );
388
    tabPane.setTabClosingPolicy( ALL_TABS );
389
    tabPane.getTabs().add( tab );
390
391
    // Attach the tab scene factory for new tab panes.
392
    if( !getItems().contains( tabPane ) ) {
393
      addTabPane(
394
        node instanceof TextDefinition ? 0 : getItems().size(), tabPane
395
      );
396
    }
397
398
    if( inputFile.isFile() ) {
399
      getRecentFiles().add( inputFile.getAbsolutePath() );
400
    }
401
  }
402
403
  /**
404
   * Opens a new text editor document using the default document file name.
405
   */
406
  public void newTextEditor() {
407
    open( DOCUMENT_DEFAULT );
408
  }
409
410
  /**
411
   * Opens a new definition editor document using the default definition
412
   * file name.
413
   */
414
  public void newDefinitionEditor() {
415
    open( DEFINITION_DEFAULT );
416
  }
417
418
  /**
419
   * Iterates over all tab panes to find all {@link TextEditor}s and request
420
   * that they save themselves.
421
   */
422
  public void saveAll() {
423
    mTabPanes.forEach(
424
      tp -> tp.getTabs().forEach( tab -> {
425
        final var node = tab.getContent();
426
427
        if( node instanceof final TextEditor editor ) {
428
          save( editor );
429
        }
430
      } )
431
    );
432
  }
433
434
  /**
435
   * Requests that the active {@link TextEditor} saves itself. Don't bother
436
   * checking if modified first because if the user swaps external media from
437
   * an external source (e.g., USB thumb drive), save should not second-guess
438
   * the user: save always re-saves. Also, it's less code.
439
   */
440
  public void save() {
441
    save( getTextEditor() );
442
  }
443
444
  /**
445
   * Saves the active {@link TextEditor} under a new name.
446
   *
447
   * @param files The new active editor {@link File} reference, must contain
448
   *              at least one element.
449
   */
450
  public void saveAs( final List<File> files ) {
451
    assert files != null;
452
    assert !files.isEmpty();
453
    final var editor = getTextEditor();
454
    final var tab = getTab( editor );
455
    final var file = files.get( 0 );
456
457
    editor.rename( file );
458
    tab.ifPresent( t -> {
459
      t.setText( editor.getFilename() );
460
      t.setTooltip( createTooltip( file ) );
461
    } );
462
463
    save();
464
  }
465
466
  /**
467
   * Saves the given {@link TextResource} to a file. This is typically used
468
   * to save either an instance of {@link TextEditor} or {@link TextDefinition}.
469
   *
470
   * @param resource The resource to export.
471
   */
472
  private void save( final TextResource resource ) {
473
    try {
474
      resource.save();
475
    } catch( final Exception ex ) {
476
      clue( ex );
477
      sNotifier.alert(
478
        getWindow(), resource.getPath(), "TextResource.saveFailed", ex
479
      );
480
    }
481
  }
482
483
  /**
484
   * Closes all open {@link TextEditor}s; all {@link TextDefinition}s stay open.
485
   *
486
   * @return {@code true} when all editors, modified or otherwise, were
487
   * permitted to close; {@code false} when one or more editors were modified
488
   * and the user requested no closing.
489
   */
490
  public boolean closeAll() {
491
    var closable = true;
492
493
    for( final var tabPane : mTabPanes ) {
494
      final var tabIterator = tabPane.getTabs().iterator();
495
496
      while( tabIterator.hasNext() ) {
497
        final var tab = tabIterator.next();
498
        final var resource = tab.getContent();
499
500
        // The definition panes auto-save, so being specific here prevents
501
        // closing the definitions in the situation where the user wants to
502
        // continue editing (i.e., possibly save unsaved work).
503
        if( !(resource instanceof TextEditor) ) {
504
          continue;
505
        }
506
507
        if( canClose( (TextEditor) resource ) ) {
508
          tabIterator.remove();
509
          close( tab );
510
        }
511
        else {
512
          closable = false;
513
        }
514
      }
515
    }
516
517
    return closable;
518
  }
519
520
  /**
521
   * Calls the tab's {@link Tab#getOnClosed()} handler to carry out a close
522
   * event.
523
   *
524
   * @param tab The {@link Tab} that was closed.
525
   */
526
  private void close( final Tab tab ) {
527
    assert tab != null;
528
529
    final var handler = tab.getOnClosed();
530
531
    if( handler != null ) {
532
      handler.handle( new ActionEvent() );
533
    }
534
  }
535
536
  /**
537
   * Closes the active tab; delegates to {@link #canClose(TextResource)}.
538
   */
539
  public void close() {
540
    final var editor = getTextEditor();
541
542
    if( canClose( editor ) ) {
543
      close( editor );
544
    }
545
  }
546
547
  /**
548
   * Closes the given {@link TextResource}. This must not be called from within
549
   * a loop that iterates over the tab panes using {@code forEach}, lest a
550
   * concurrent modification exception be thrown.
551
   *
552
   * @param resource The {@link TextResource} to close, without confirming with
553
   *                 the user.
554
   */
555
  private void close( final TextResource resource ) {
556
    getTab( resource ).ifPresent(
557
      ( tab ) -> {
558
        close( tab );
559
        tab.getTabPane().getTabs().remove( tab );
560
      }
561
    );
562
  }
563
564
  /**
565
   * Answers whether the given {@link TextResource} may be closed.
566
   *
567
   * @param editor The {@link TextResource} to try closing.
568
   * @return {@code true} when the editor may be closed; {@code false} when
569
   * the user has requested to keep the editor open.
570
   */
571
  private boolean canClose( final TextResource editor ) {
572
    final var editorTab = getTab( editor );
573
    final var canClose = new AtomicBoolean( true );
574
575
    if( editor.isModified() ) {
576
      final var filename = new StringBuilder();
577
      editorTab.ifPresent( ( tab ) -> filename.append( tab.getText() ) );
578
579
      final var message = sNotifier.createNotification(
580
        Messages.get( "Alert.file.close.title" ),
581
        Messages.get( "Alert.file.close.text" ),
582
        filename.toString()
583
      );
584
585
      final var dialog = sNotifier.createConfirmation( getWindow(), message );
586
587
      dialog.showAndWait().ifPresent(
588
        save -> canClose.set( save == YES ? editor.save() : save == NO )
589
      );
590
    }
591
592
    return canClose.get();
593
  }
594
595
  private ObjectProperty<TextEditor> createActiveTextEditor() {
596
    final var editor = new SimpleObjectProperty<TextEditor>();
597
598
    editor.addListener( ( c, o, n ) -> {
599
      if( n != null ) {
600
        mPreview.setBaseUri( n.getPath() );
601
        process( n );
602
      }
603
    } );
604
605
    return editor;
606
  }
607
608
  /**
609
   * Adds the HTML preview tab to its own, singular tab pane.
610
   */
611
  public void viewPreview() {
612
    viewTab( mPreview, TEXT_HTML, "Pane.preview.title" );
613
  }
614
615
  /**
616
   * Adds the document outline tab to its own, singular tab pane.
617
   */
618
  public void viewOutline() {
619
    viewTab( mOutline, APP_DOCUMENT_OUTLINE, "Pane.outline.title" );
620
  }
621
622
  public void viewStatistics() {
623
    viewTab( mStatistics, APP_DOCUMENT_STATISTICS, "Pane.statistics.title" );
624
  }
625
626
  public void viewFiles() {
627
    try {
628
      final var factory = new FilePickerFactory( getWorkspace() );
629
      final var fileManager = factory.createModeless();
630
      viewTab( fileManager, APP_FILE_MANAGER, "Pane.files.title" );
631
    } catch( final Exception ex ) {
632
      clue( ex );
633
    }
634
  }
635
636
  private void viewTab(
637
    final Node node, final MediaType mediaType, final String key ) {
638
    final var tabPane = obtainTabPane( mediaType );
639
640
    for( final var tab : tabPane.getTabs() ) {
641
      if( tab.getContent() == node ) {
642
        return;
643
      }
644
    }
645
646
    tabPane.getTabs().add( createTab( get( key ), node ) );
647
    addTabPane( tabPane );
648
  }
649
650
  public void viewRefresh() {
651
    mPreview.refresh();
652
    Engine.clear();
653
    mRBootstrapController.update();
654
  }
655
656
  /**
657
   * Returns the tab that contains the given {@link TextEditor}.
658
   *
659
   * @param editor The {@link TextEditor} instance to find amongst the tabs.
660
   * @return The first tab having content that matches the given tab.
661
   */
662
  private Optional<Tab> getTab( final TextResource editor ) {
663
    return mTabPanes.stream()
664
                    .flatMap( pane -> pane.getTabs().stream() )
665
                    .filter( tab -> editor.equals( tab.getContent() ) )
666
                    .findFirst();
667
  }
668
669
  /**
670
   * Creates a new {@link DefinitionEditor} wrapped in a listener that
671
   * is used to detect when the active {@link DefinitionEditor} has changed.
672
   * Upon changing, the variables are interpolated and the active text editor
673
   * is refreshed.
674
   *
675
   * @param textEditor Text editor to update with the revised resolved map.
676
   * @return A newly configured property that represents the active
677
   * {@link DefinitionEditor}, never null.
678
   */
679
  private ObjectProperty<TextDefinition> createActiveDefinitionEditor(
680
    final ObjectProperty<TextEditor> textEditor ) {
681
    final var defEditor = new SimpleObjectProperty<>(
682
      createDefinitionEditor()
683
    );
684
685
    defEditor.addListener( ( c, o, n ) -> {
686
      final var editor = textEditor.get();
687
688
      if( editor.isMediaType( TEXT_R_MARKDOWN ) ) {
689
        // Initialize R before the editor is added.
690
        mRBootstrapController.update();
691
      }
692
693
      process( editor );
694
    } );
695
696
    return defEditor;
697
  }
698
699
  private Tab createTab( final String filename, final Node node ) {
700
    return new DetachableTab( filename, node );
701
  }
702
703
  private Tab createTab( final File file ) {
704
    final var r = createTextResource( file );
705
    final var tab = createTab( r.getFilename(), r.getNode() );
706
707
    r.modifiedProperty().addListener(
708
      ( c, o, n ) -> tab.setText( r.getFilename() + (n ? "*" : "") )
709
    );
710
711
    // This is called when either the tab is closed by the user clicking on
712
    // the tab's close icon or when closing (all) from the file menu.
713
    tab.setOnClosed(
714
      ( __ ) -> getRecentFiles().remove( file.getAbsolutePath() )
715
    );
716
717
    // When closing a tab, give focus to the newly revealed tab.
718
    tab.selectedProperty().addListener( ( c, o, n ) -> {
719
      if( n != null && n ) {
720
        final var pane = tab.getTabPane();
721
722
        if( pane != null ) {
723
          pane.requestFocus();
724
        }
725
      }
726
    } );
727
728
    tab.tabPaneProperty().addListener( ( cPane, oPane, nPane ) -> {
729
      if( nPane != null ) {
730
        nPane.focusedProperty().addListener( ( c, o, n ) -> {
731
          if( n != null && n ) {
732
            final var selected = nPane.getSelectionModel().getSelectedItem();
733
            final var node = selected.getContent();
734
            node.requestFocus();
735
          }
736
        } );
737
      }
738
    } );
739
740
    return tab;
741
  }
742
743
  /**
744
   * Creates bins for the different {@link MediaType}s, which eventually are
745
   * added to the UI as separate tab panes. If ever a general-purpose scene
746
   * exporter is developed to serialize a scene to an FXML file, this could
747
   * be replaced by such a class.
748
   * <p>
749
   * When binning the files, this makes sure that at least one file exists
750
   * for every type. If the user has opted to close a particular type (such
751
   * as the definition pane), the view will suppressed elsewhere.
752
   * </p>
753
   * <p>
754
   * The order that the binned files are returned will be reflected in the
755
   * order that the corresponding panes are rendered in the UI.
756
   * </p>
757
   *
758
   * @param paths The file paths to bin according to their type.
759
   * @return An in-order list of files, first by structured definition files,
760
   * then by plain text documents.
761
   */
762
  private List<File> collect( final SetProperty<String> paths ) {
763
    // Treat all files destined for the text editor as plain text documents
764
    // so that they are added to the same pane. Grouping by TEXT_PLAIN is a
765
    // bit arbitrary, but means explicitly capturing TEXT_PLAIN isn't needed.
766
    final Function<MediaType, MediaType> bin =
767
      m -> PLAIN_TEXT_FORMAT.contains( m ) ? TEXT_PLAIN : m;
768
769
    // Create two groups: YAML files and plain text files. The order that
770
    // the elements are listed in the enumeration for media types determines
771
    // what files are loaded first. Variable definitions come before all other
772
    // plain text documents.
773
    final var bins = paths
774
      .stream()
775
      .collect(
776
        groupingBy(
777
          path -> bin.apply( MediaType.fromFilename( path ) ),
778
          () -> new TreeMap<>( Enum::compareTo ),
779
          Collectors.toList()
780
        )
781
      );
782
783
    bins.putIfAbsent( TEXT_YAML, List.of( DEFINITION_DEFAULT.toString() ) );
784
    bins.putIfAbsent( TEXT_PLAIN, List.of( DOCUMENT_DEFAULT.toString() ) );
785
786
    final var result = new LinkedList<File>();
787
788
    // Ensure that the same types are listed together (keep insertion order).
789
    bins.forEach( ( mediaType, files ) -> result.addAll(
790
      files.stream().map( File::new ).toList() )
791
    );
792
793
    return result;
794
  }
795
796
  /**
797
   * Force the active editor to update, which will cause the processor
798
   * to re-evaluate the interpolated definition map thereby updating the
799
   * preview pane.
800
   *
801
   * @param editor Contains the source document to update in the preview pane.
802
   */
803
  private void process( final TextEditor editor ) {
804
    // Ensure processing does not run on the JavaFX thread, which frees the
805
    // text editor immediately for caret movement. The preview will have a
806
    // slight delay when catching up to the caret position.
807
    final var task = new Task<Void>() {
808
      @Override
809
      public Void call() {
810
        try {
811
          final var p = mProcessors.getOrDefault( editor, IDENTITY );
812
          p.apply( editor == null ? "" : editor.getText() );
813
        } catch( final Exception ex ) {
814
          clue( ex );
815
        }
816
817
        return null;
818
      }
819
    };
820
821
    // TODO: Each time the editor successfully runs the processor the task is
822
    //   considered successful. Due to the rapid-fire nature of processing
823
    //   (e.g., keyboard navigation, fast typing), it isn't necessary to
824
    //   scroll each time.
825
    //   The algorithm:
826
    //   1. Peek at the oldest time.
827
    //   2. If the difference between the oldest time and current time exceeds
828
    //      250 milliseconds, then invoke the scrolling.
829
    //   3. Insert the current time into the circular queue.
830
    task.setOnSucceeded(
831
      e -> invokeLater( () -> mPreview.scrollTo( CARET_ID ) )
832
    );
833
834
    // Prevents multiple process requests from executing simultaneously (due
835
    // to having a restricted queue size).
836
    sExecutor.execute( task );
837
  }
838
839
  /**
840
   * Lazily creates a {@link TabPane} configured to listen for tab select
841
   * events. The tab pane is associated with a given media type so that
842
   * similar files can be grouped together.
843
   *
844
   * @param mediaType The media type to associate with the tab pane.
845
   * @return An instance of {@link TabPane} that will handle tab docking.
846
   */
847
  private TabPane obtainTabPane( final MediaType mediaType ) {
848
    for( final var pane : mTabPanes ) {
849
      for( final var tab : pane.getTabs() ) {
850
        final var node = tab.getContent();
851
852
        if( node instanceof TextResource r && r.supports( mediaType ) ) {
853
          return pane;
854
        }
855
      }
856
    }
857
858
    final var pane = createTabPane();
859
    mTabPanes.add( pane );
860
    return pane;
861
  }
862
863
  /**
864
   * Creates an initialized {@link TabPane} instance.
865
   *
866
   * @return A new {@link TabPane} with all listeners configured.
867
   */
868
  private TabPane createTabPane() {
869
    final var tabPane = new DetachableTabPane();
870
871
    initStageOwnerFactory( tabPane );
872
    initTabListener( tabPane );
873
874
    return tabPane;
875
  }
876
877
  /**
878
   * When any {@link DetachableTabPane} is detached from the main window,
879
   * the stage owner factory must be given its parent window, which will
880
   * own the child window. The parent window is the {@link MainPane}'s
881
   * {@link Scene}'s {@link Window} instance.
882
   *
883
   * <p>
884
   * This will derives the new title from the main window title, incrementing
885
   * the window count to help uniquely identify the child windows.
886
   * </p>
887
   *
888
   * @param tabPane A new {@link DetachableTabPane} to configure.
889
   */
890
  private void initStageOwnerFactory( final DetachableTabPane tabPane ) {
891
    tabPane.setStageOwnerFactory( ( stage ) -> {
892
      final var title = get(
893
        "Detach.tab.title",
894
        ((Stage) getWindow()).getTitle(), ++mWindowCount
895
      );
896
      stage.setTitle( title );
897
898
      return getScene().getWindow();
899
    } );
900
  }
901
902
  /**
903
   * Responsible for configuring the content of each {@link DetachableTab} when
904
   * it is added to the given {@link DetachableTabPane} instance.
905
   * <p>
906
   * For {@link TextEditor} contents, an instance of {@link ScrollEventHandler}
907
   * is initialized to perform synchronized scrolling between the editor and
908
   * its preview window. Additionally, the last tab in the tab pane's list of
909
   * tabs is given focus.
910
   * </p>
911
   * <p>
912
   * Note that multiple tabs can be added simultaneously.
913
   * </p>
914
   *
915
   * @param tabPane A new {@link TabPane} to configure.
916
   */
917
  private void initTabListener( final TabPane tabPane ) {
918
    tabPane.getTabs().addListener(
919
      ( final ListChangeListener.Change<? extends Tab> listener ) -> {
920
        while( listener.next() ) {
921
          if( listener.wasAdded() ) {
922
            final var tabs = listener.getAddedSubList();
923
924
            tabs.forEach( tab -> {
925
              final var node = tab.getContent();
926
927
              if( node instanceof TextEditor ) {
928
                initScrollEventListener( tab );
929
              }
930
            } );
931
932
            // Select and give focus to the last tab opened.
933
            final var index = tabs.size() - 1;
934
            if( index >= 0 ) {
935
              final var tab = tabs.get( index );
936
              tabPane.getSelectionModel().select( tab );
937
              tab.getContent().requestFocus();
938
            }
939
          }
940
        }
941
      }
942
    );
943
  }
944
945
  /**
946
   * Synchronizes scrollbar positions between the given {@link Tab} that
947
   * contains an instance of {@link TextEditor} and {@link HtmlPreview} pane.
948
   *
949
   * @param tab The container for an instance of {@link TextEditor}.
950
   */
951
  private void initScrollEventListener( final Tab tab ) {
952
    final var editor = (TextEditor) tab.getContent();
953
    final var scrollPane = editor.getScrollPane();
954
    final var scrollBar = mPreview.getVerticalScrollBar();
955
    final var handler = new ScrollEventHandler( scrollPane, scrollBar );
956
957
    handler.enabledProperty().bind( tab.selectedProperty() );
958
  }
959
960
  private void addTabPane( final int index, final TabPane tabPane ) {
961
    final var items = getItems();
962
963
    if( !items.contains( tabPane ) ) {
964
      items.add( index, tabPane );
965
    }
966
  }
967
968
  private void addTabPane( final TabPane tabPane ) {
969
    addTabPane( getItems().size(), tabPane );
970
  }
971
972
  private GenericBuilder<Mutator, ProcessorContext> createProcessorContextBuilder() {
973
    final var w = getWorkspace();
974
975
    return builder()
976
      .with( Mutator::setDefinitions, this::getDefinitions )
977
      .with( Mutator::setLocale, w::getLocale )
978
      .with( Mutator::setMetadata, w::getMetadata )
979
      .with( Mutator::setThemePath, w::getThemePath )
980
      .with( Mutator::setCaret,
981
             () -> getTextEditor().getCaret() )
982
      .with( Mutator::setImageDir,
983
             () -> w.getFile( KEY_IMAGES_DIR ) )
984
      .with( Mutator::setImageOrder,
985
             () -> w.getString( KEY_IMAGES_ORDER ) )
986
      .with( Mutator::setImageServer,
987
             () -> w.getString( KEY_IMAGES_SERVER ) )
988
      .with( Mutator::setSigilBegan,
989
             () -> w.getString( KEY_DEF_DELIM_BEGAN ) )
990
      .with( Mutator::setSigilEnded,
991
             () -> w.getString( KEY_DEF_DELIM_ENDED ) )
992
      .with( Mutator::setRScript,
993
             () -> w.getString( KEY_R_SCRIPT ) )
994
      .with( Mutator::setRWorkingDir,
995
             () -> w.getFile( KEY_R_DIR ).toPath() )
996
      .with( Mutator::setCurlQuotes,
997
             () -> w.getBoolean( KEY_TYPESET_TYPOGRAPHY_QUOTES ) )
998
      .with( Mutator::setAutoClean,
999
             () -> w.getBoolean( KEY_TYPESET_CONTEXT_CLEAN ) );
1000
  }
1001
1002
  public ProcessorContext createProcessorContext() {
1003
    return createProcessorContext( null, NONE );
1004
  }
1005
1006
  /**
1007
   * @param outputPath Used when exporting to a PDF file (binary).
1008
   * @param format     Used when processors export to a new text format.
1009
   * @return A new {@link ProcessorContext} to use when creating an instance of
1010
   * {@link Processor}.
1011
   */
1012
  public ProcessorContext createProcessorContext(
1013
    final Path outputPath, final ExportFormat format ) {
1014
    final var textEditor = getTextEditor();
1015
    final var inputPath = textEditor.getPath();
1016
1017
    return createProcessorContextBuilder()
1018
      .with( Mutator::setInputPath, inputPath )
1019
      .with( Mutator::setOutputPath, outputPath )
1020
      .with( Mutator::setExportFormat, format )
1021
      .build();
1022
  }
1023
1024
  /**
1025
   * @param inputPath Used by {@link ProcessorFactory} to determine
1026
   *                  {@link Processor} type to create based on file type.
1027
   * @return A new {@link ProcessorContext} to use when creating an instance of
1028
   * {@link Processor}.
1029
   */
1030
  private ProcessorContext createProcessorContext( final Path inputPath ) {
1031
    return createProcessorContextBuilder()
1032
      .with( Mutator::setInputPath, inputPath )
1033
      .with( Mutator::setExportFormat, NONE )
1034
      .build();
1035
  }
1036
1037
  private TextResource createTextResource( final File file ) {
1038
    // TODO: Create PlainTextEditor that's returned by default.
1039
    return MediaType.valueFrom( file ) == TEXT_YAML
1040
      ? createDefinitionEditor( file )
1041
      : createMarkdownEditor( file );
1042
  }
1043
1044
  /**
1045
   * Creates an instance of {@link MarkdownEditor} that listens for both
1046
   * caret change events and text change events. Text change events must
1047
   * take priority over caret change events because it's possible to change
1048
   * the text without moving the caret (e.g., delete selected text).
1049
   *
1050
   * @param inputFile The file containing contents for the text editor.
1051
   * @return A non-null text editor.
1052
   */
1053
  private TextResource createMarkdownEditor( final File inputFile ) {
1054
    final var editor = new MarkdownEditor( inputFile, getWorkspace() );
1055
1056
    mProcessors.computeIfAbsent(
1057
      editor, p -> createProcessors(
1058
        createProcessorContext( inputFile.toPath() ),
1059
        createHtmlPreviewProcessor()
1060
      )
1061
    );
1062
1063
    // Listener for editor modifications or caret position changes.
1064
    editor.addDirtyListener( ( c, o, n ) -> {
1065
      if( n ) {
1066
        // Reset the status bar after changing the text.
1067
        clue();
1068
1069
        // Processing the text may update the status bar.
1070
        process( getTextEditor() );
1071
1072
        // Update the caret position in the status bar.
1073
        CaretMovedEvent.fire( editor.getCaret() );
1074
      }
1075
    } );
1076
1077
    editor.addEventListener(
1078
      keyPressed( SPACE, CONTROL_DOWN ), this::autoinsert
1079
    );
205
206
    restoreSession();
207
    runLater( this::restoreFocus );
208
  }
209
210
  @Subscribe
211
  public void handle( final TextEditorFocusEvent event ) {
212
    mTextEditor.set( event.get() );
213
  }
214
215
  @Subscribe
216
  public void handle( final TextDefinitionFocusEvent event ) {
217
    mDefinitionEditor.set( event.get() );
218
  }
219
220
  /**
221
   * Typically called when a file name is clicked in the preview panel.
222
   *
223
   * @param event The event to process, must contain a valid file reference.
224
   */
225
  @Subscribe
226
  public void handle( final FileOpenEvent event ) {
227
    final File eventFile;
228
    final var eventUri = event.getUri();
229
230
    if( eventUri.isAbsolute() ) {
231
      eventFile = new File( eventUri.getPath() );
232
    }
233
    else {
234
      final var activeFile = getTextEditor().getFile();
235
      final var parent = activeFile.getParentFile();
236
237
      if( parent == null ) {
238
        clue( new FileNotFoundException( eventUri.getPath() ) );
239
        return;
240
      }
241
      else {
242
        final var parentPath = parent.getAbsolutePath();
243
        eventFile = Path.of( parentPath, eventUri.getPath() ).toFile();
244
      }
245
    }
246
247
    runLater( () -> open( eventFile ) );
248
  }
249
250
  @Subscribe
251
  public void handle( final CaretNavigationEvent event ) {
252
    runLater( () -> {
253
      final var textArea = getTextEditor();
254
      textArea.moveTo( event.getOffset() );
255
      textArea.requestFocus();
256
    } );
257
  }
258
259
  @Subscribe
260
  @SuppressWarnings( "unused" )
261
  public void handle( final ExportFailedEvent event ) {
262
    final var os = getProperty( "os.name" );
263
    final var arch = getProperty( "os.arch" ).toLowerCase();
264
    final var bits = getProperty( "sun.arch.data.model" );
265
266
    final var title = Messages.get( "Alert.typesetter.missing.title" );
267
    final var header = Messages.get( "Alert.typesetter.missing.header" );
268
    final var version = Messages.get(
269
      "Alert.typesetter.missing.version",
270
      os,
271
      arch
272
        .replaceAll( "amd.*|i.*|x86.*", "X86" )
273
        .replaceAll( "mips.*", "MIPS" )
274
        .replaceAll( "armv.*", "ARM" ),
275
      bits );
276
    final var text = Messages.get( "Alert.typesetter.missing.installer.text" );
277
278
    // Download and install ConTeXt for {0} {1} {2}-bit
279
    final var content = format( "%s %s", text, version );
280
    final var flowPane = new FlowPane();
281
    final var link = new Hyperlink( text );
282
    final var label = new Label( version );
283
    flowPane.getChildren().addAll( link, label );
284
285
    final var alert = new Alert( ERROR, content, OK );
286
    alert.setTitle( title );
287
    alert.setHeaderText( header );
288
    alert.getDialogPane().contentProperty().set( flowPane );
289
    alert.setGraphic( ICON_DIALOG_NODE );
290
291
    link.setOnAction( e -> {
292
      alert.close();
293
      final var url = Messages.get( "Alert.typesetter.missing.installer.url" );
294
      runLater( () -> HyperlinkOpenEvent.fire( url ) );
295
    } );
296
297
    alert.showAndWait();
298
  }
299
300
  @Subscribe
301
  public void handle( final InsertDefinitionEvent<String> event ) {
302
    final var leaf = event.getLeaf();
303
    final var editor = mTextEditor.get();
304
305
    mVariableNameInjector.insert( editor, leaf );
306
  }
307
308
  private void initAutosave( final Workspace workspace ) {
309
    final var rate = workspace.integerProperty( KEY_EDITOR_AUTOSAVE );
310
311
    rate.addListener(
312
      ( c, o, n ) -> {
313
        final var taskRef = mSaveTask.get();
314
315
        // Prevent multiple autosaves from running.
316
        if( taskRef != null ) {
317
          taskRef.cancel( false );
318
        }
319
320
        initAutosave( rate );
321
      }
322
    );
323
324
    // Start the save listener (avoids duplicating some code).
325
    initAutosave( rate );
326
  }
327
328
  private void initAutosave( final IntegerProperty rate ) {
329
    mSaveTask.set(
330
      mSaver.scheduleAtFixedRate(
331
        () -> {
332
          if( getTextEditor().isModified() ) {
333
            // Ensure the modified indicator is cleared by running on EDT.
334
            runLater( this::save );
335
          }
336
        }, 0, rate.intValue(), SECONDS
337
      )
338
    );
339
  }
340
341
  /**
342
   * TODO: Load divider positions from exported settings, see
343
   *   {@link #collect(SetProperty)} comment.
344
   */
345
  private double[] calculateDividerPositions() {
346
    final var ratio = 100f / getItems().size() / 100;
347
    final var positions = getDividerPositions();
348
349
    for( int i = 0; i < positions.length; i++ ) {
350
      positions[ i ] = ratio * i;
351
    }
352
353
    return positions;
354
  }
355
356
  /**
357
   * Opens all the files into the application, provided the paths are unique.
358
   * This may only be called for any type of files that a user can edit
359
   * (i.e., update and persist), such as definitions and text files.
360
   *
361
   * @param files The list of files to open.
362
   */
363
  public void open( final List<File> files ) {
364
    files.forEach( this::open );
365
  }
366
367
  /**
368
   * This opens the given file. Since the preview pane is not a file that
369
   * can be opened, it is safe to add a listener to the detachable pane.
370
   * This will exit early if the given file is not a regular file (i.e., a
371
   * directory).
372
   *
373
   * @param inputFile The file to open.
374
   */
375
  private void open( final File inputFile ) {
376
    // Prevent opening directories (a non-existent "untitled.md" is fine).
377
    if( !inputFile.isFile() && inputFile.exists() ) {
378
      return;
379
    }
380
381
    final var tab = createTab( inputFile );
382
    final var node = tab.getContent();
383
    final var mediaType = MediaType.valueFrom( inputFile );
384
    final var tabPane = obtainTabPane( mediaType );
385
386
    tab.setTooltip( createTooltip( inputFile ) );
387
    tabPane.setFocusTraversable( false );
388
    tabPane.setTabClosingPolicy( ALL_TABS );
389
    tabPane.getTabs().add( tab );
390
391
    // Attach the tab scene factory for new tab panes.
392
    if( !getItems().contains( tabPane ) ) {
393
      addTabPane(
394
        node instanceof TextDefinition ? 0 : getItems().size(), tabPane
395
      );
396
    }
397
398
    if( inputFile.isFile() ) {
399
      getRecentFiles().add( inputFile.getAbsolutePath() );
400
    }
401
  }
402
403
  /**
404
   * Gives focus to the most recently edited document and attempts to move
405
   * the caret to the most recently known offset into said document.
406
   */
407
  private void restoreSession() {
408
    final var workspace = getWorkspace();
409
    final var file = workspace.fileProperty( KEY_UI_RECENT_DOCUMENT );
410
    final var offset = workspace.integerProperty( KEY_UI_RECENT_OFFSET );
411
412
    for( final var pane : mTabPanes ) {
413
      for( final var tab : pane.getTabs() ) {
414
        final var tooltip = tab.getTooltip();
415
416
        if( tooltip != null ) {
417
          final var tabName = tooltip.getText();
418
          final var fileName = file.getValue().toString();
419
420
          if( tabName.equalsIgnoreCase( fileName ) ) {
421
            final var node = tab.getContent();
422
423
            pane.getSelectionModel().select( tab );
424
            node.requestFocus();
425
426
            if( node instanceof TextEditor editor ) {
427
              editor.moveTo( offset.getValue() );
428
            }
429
430
            break;
431
          }
432
        }
433
      }
434
    }
435
  }
436
437
  /**
438
   * Sets the focus to the middle pane, which contains the text editor tabs.
439
   */
440
  private void restoreFocus() {
441
    // Work around a bug where focusing directly on the middle pane results
442
    // in the R engine not loading variables properly.
443
    mTabPanes.get( 0 ).requestFocus();
444
445
    // This is the only line that should be required.
446
    mTabPanes.get( 1 ).requestFocus();
447
  }
448
449
  /**
450
   * Opens a new text editor document using the default document file name.
451
   */
452
  public void newTextEditor() {
453
    open( DOCUMENT_DEFAULT );
454
  }
455
456
  /**
457
   * Opens a new definition editor document using the default definition
458
   * file name.
459
   */
460
  public void newDefinitionEditor() {
461
    open( DEFINITION_DEFAULT );
462
  }
463
464
  /**
465
   * Iterates over all tab panes to find all {@link TextEditor}s and request
466
   * that they save themselves.
467
   */
468
  public void saveAll() {
469
    mTabPanes.forEach(
470
      tp -> tp.getTabs().forEach( tab -> {
471
        final var node = tab.getContent();
472
473
        if( node instanceof final TextEditor editor ) {
474
          save( editor );
475
        }
476
      } )
477
    );
478
  }
479
480
  /**
481
   * Requests that the active {@link TextEditor} saves itself. Don't bother
482
   * checking if modified first because if the user swaps external media from
483
   * an external source (e.g., USB thumb drive), save should not second-guess
484
   * the user: save always re-saves. Also, it's less code.
485
   */
486
  public void save() {
487
    save( getTextEditor() );
488
  }
489
490
  /**
491
   * Saves the active {@link TextEditor} under a new name.
492
   *
493
   * @param files The new active editor {@link File} reference, must contain
494
   *              at least one element.
495
   */
496
  public void saveAs( final List<File> files ) {
497
    assert files != null;
498
    assert !files.isEmpty();
499
    final var editor = getTextEditor();
500
    final var tab = getTab( editor );
501
    final var file = files.get( 0 );
502
503
    editor.rename( file );
504
    tab.ifPresent( t -> {
505
      t.setText( editor.getFilename() );
506
      t.setTooltip( createTooltip( file ) );
507
    } );
508
509
    save();
510
  }
511
512
  /**
513
   * Saves the given {@link TextResource} to a file. This is typically used
514
   * to save either an instance of {@link TextEditor} or {@link TextDefinition}.
515
   *
516
   * @param resource The resource to export.
517
   */
518
  private void save( final TextResource resource ) {
519
    try {
520
      resource.save();
521
    } catch( final Exception ex ) {
522
      clue( ex );
523
      sNotifier.alert(
524
        getWindow(), resource.getPath(), "TextResource.saveFailed", ex
525
      );
526
    }
527
  }
528
529
  /**
530
   * Closes all open {@link TextEditor}s; all {@link TextDefinition}s stay open.
531
   *
532
   * @return {@code true} when all editors, modified or otherwise, were
533
   * permitted to close; {@code false} when one or more editors were modified
534
   * and the user requested no closing.
535
   */
536
  public boolean closeAll() {
537
    var closable = true;
538
539
    for( final var tabPane : mTabPanes ) {
540
      final var tabIterator = tabPane.getTabs().iterator();
541
542
      while( tabIterator.hasNext() ) {
543
        final var tab = tabIterator.next();
544
        final var resource = tab.getContent();
545
546
        // The definition panes auto-save, so being specific here prevents
547
        // closing the definitions in the situation where the user wants to
548
        // continue editing (i.e., possibly save unsaved work).
549
        if( !(resource instanceof TextEditor) ) {
550
          continue;
551
        }
552
553
        if( canClose( (TextEditor) resource ) ) {
554
          tabIterator.remove();
555
          close( tab );
556
        }
557
        else {
558
          closable = false;
559
        }
560
      }
561
    }
562
563
    return closable;
564
  }
565
566
  /**
567
   * Calls the tab's {@link Tab#getOnClosed()} handler to carry out a close
568
   * event.
569
   *
570
   * @param tab The {@link Tab} that was closed.
571
   */
572
  private void close( final Tab tab ) {
573
    assert tab != null;
574
575
    final var handler = tab.getOnClosed();
576
577
    if( handler != null ) {
578
      handler.handle( new ActionEvent() );
579
    }
580
  }
581
582
  /**
583
   * Closes the active tab; delegates to {@link #canClose(TextResource)}.
584
   */
585
  public void close() {
586
    final var editor = getTextEditor();
587
588
    if( canClose( editor ) ) {
589
      close( editor );
590
    }
591
  }
592
593
  /**
594
   * Closes the given {@link TextResource}. This must not be called from within
595
   * a loop that iterates over the tab panes using {@code forEach}, lest a
596
   * concurrent modification exception be thrown.
597
   *
598
   * @param resource The {@link TextResource} to close, without confirming with
599
   *                 the user.
600
   */
601
  private void close( final TextResource resource ) {
602
    getTab( resource ).ifPresent(
603
      tab -> {
604
        close( tab );
605
        tab.getTabPane().getTabs().remove( tab );
606
      }
607
    );
608
  }
609
610
  /**
611
   * Answers whether the given {@link TextResource} may be closed.
612
   *
613
   * @param editor The {@link TextResource} to try closing.
614
   * @return {@code true} when the editor may be closed; {@code false} when
615
   * the user has requested to keep the editor open.
616
   */
617
  private boolean canClose( final TextResource editor ) {
618
    final var editorTab = getTab( editor );
619
    final var canClose = new AtomicBoolean( true );
620
621
    if( editor.isModified() ) {
622
      final var filename = new StringBuilder();
623
      editorTab.ifPresent( tab -> filename.append( tab.getText() ) );
624
625
      final var message = sNotifier.createNotification(
626
        Messages.get( "Alert.file.close.title" ),
627
        Messages.get( "Alert.file.close.text" ),
628
        filename.toString()
629
      );
630
631
      final var dialog = sNotifier.createConfirmation( getWindow(), message );
632
633
      dialog.showAndWait().ifPresent(
634
        save -> canClose.set( save == YES ? editor.save() : save == NO )
635
      );
636
    }
637
638
    return canClose.get();
639
  }
640
641
  private ObjectProperty<TextEditor> createActiveTextEditor() {
642
    final var editor = new SimpleObjectProperty<TextEditor>();
643
644
    editor.addListener( ( c, o, n ) -> {
645
      if( n != null ) {
646
        mPreview.setBaseUri( n.getPath() );
647
        process( n );
648
      }
649
    } );
650
651
    return editor;
652
  }
653
654
  /**
655
   * Adds the HTML preview tab to its own, singular tab pane.
656
   */
657
  public void viewPreview() {
658
    viewTab( mPreview, TEXT_HTML, "Pane.preview.title" );
659
  }
660
661
  /**
662
   * Adds the document outline tab to its own, singular tab pane.
663
   */
664
  public void viewOutline() {
665
    viewTab( mOutline, APP_DOCUMENT_OUTLINE, "Pane.outline.title" );
666
  }
667
668
  public void viewStatistics() {
669
    viewTab( mStatistics, APP_DOCUMENT_STATISTICS, "Pane.statistics.title" );
670
  }
671
672
  public void viewFiles() {
673
    try {
674
      final var factory = new FilePickerFactory( getWorkspace() );
675
      final var fileManager = factory.createModeless();
676
      viewTab( fileManager, APP_FILE_MANAGER, "Pane.files.title" );
677
    } catch( final Exception ex ) {
678
      clue( ex );
679
    }
680
  }
681
682
  private void viewTab(
683
    final Node node, final MediaType mediaType, final String key ) {
684
    final var tabPane = obtainTabPane( mediaType );
685
686
    for( final var tab : tabPane.getTabs() ) {
687
      if( tab.getContent() == node ) {
688
        return;
689
      }
690
    }
691
692
    tabPane.getTabs().add( createTab( get( key ), node ) );
693
    addTabPane( tabPane );
694
  }
695
696
  public void viewRefresh() {
697
    mPreview.refresh();
698
    Engine.clear();
699
    mRBootstrapController.update();
700
  }
701
702
  /**
703
   * Returns the tab that contains the given {@link TextEditor}.
704
   *
705
   * @param editor The {@link TextEditor} instance to find amongst the tabs.
706
   * @return The first tab having content that matches the given tab.
707
   */
708
  private Optional<Tab> getTab( final TextResource editor ) {
709
    return mTabPanes.stream()
710
                    .flatMap( pane -> pane.getTabs().stream() )
711
                    .filter( tab -> editor.equals( tab.getContent() ) )
712
                    .findFirst();
713
  }
714
715
  /**
716
   * Creates a new {@link DefinitionEditor} wrapped in a listener that
717
   * is used to detect when the active {@link DefinitionEditor} has changed.
718
   * Upon changing, the variables are interpolated and the active text editor
719
   * is refreshed.
720
   *
721
   * @param textEditor Text editor to update with the revised resolved map.
722
   * @return A newly configured property that represents the active
723
   * {@link DefinitionEditor}, never null.
724
   */
725
  private ObjectProperty<TextDefinition> createActiveDefinitionEditor(
726
    final ObjectProperty<TextEditor> textEditor ) {
727
    final var defEditor = new SimpleObjectProperty<>(
728
      createDefinitionEditor()
729
    );
730
731
    defEditor.addListener( ( c, o, n ) -> {
732
      final var editor = textEditor.get();
733
734
      if( editor.isMediaType( TEXT_R_MARKDOWN ) ) {
735
        // Initialize R before the editor is added.
736
        mRBootstrapController.update();
737
      }
738
739
      process( editor );
740
    } );
741
742
    return defEditor;
743
  }
744
745
  private Tab createTab( final String filename, final Node node ) {
746
    return new DetachableTab( filename, node );
747
  }
748
749
  private Tab createTab( final File file ) {
750
    final var r = createTextResource( file );
751
    final var tab = createTab( r.getFilename(), r.getNode() );
752
753
    r.modifiedProperty().addListener(
754
      ( c, o, n ) -> tab.setText( r.getFilename() + (n ? "*" : "") )
755
    );
756
757
    // This is called when either the tab is closed by the user clicking on
758
    // the tab's close icon or when closing (all) from the file menu.
759
    tab.setOnClosed(
760
      __ -> getRecentFiles().remove( file.getAbsolutePath() )
761
    );
762
763
    // When closing a tab, give focus to the newly revealed tab.
764
    tab.selectedProperty().addListener( ( c, o, n ) -> {
765
      if( n != null && n ) {
766
        final var pane = tab.getTabPane();
767
768
        if( pane != null ) {
769
          pane.requestFocus();
770
        }
771
      }
772
    } );
773
774
    tab.tabPaneProperty().addListener( ( cPane, oPane, nPane ) -> {
775
      if( nPane != null ) {
776
        nPane.focusedProperty().addListener( ( c, o, n ) -> {
777
          if( n != null && n ) {
778
            final var selected = nPane.getSelectionModel().getSelectedItem();
779
            final var node = selected.getContent();
780
            node.requestFocus();
781
          }
782
        } );
783
      }
784
    } );
785
786
    return tab;
787
  }
788
789
  /**
790
   * Creates bins for the different {@link MediaType}s, which eventually are
791
   * added to the UI as separate tab panes. If ever a general-purpose scene
792
   * exporter is developed to serialize a scene to an FXML file, this could
793
   * be replaced by such a class.
794
   * <p>
795
   * When binning the files, this makes sure that at least one file exists
796
   * for every type. If the user has opted to close a particular type (such
797
   * as the definition pane), the view will suppressed elsewhere.
798
   * </p>
799
   * <p>
800
   * The order that the binned files are returned will be reflected in the
801
   * order that the corresponding panes are rendered in the UI.
802
   * </p>
803
   *
804
   * @param paths The file paths to bin according to their type.
805
   * @return An in-order list of files, first by structured definition files,
806
   * then by plain text documents.
807
   */
808
  private List<File> collect( final SetProperty<String> paths ) {
809
    // Treat all files destined for the text editor as plain text documents
810
    // so that they are added to the same pane. Grouping by TEXT_PLAIN is a
811
    // bit arbitrary, but means explicitly capturing TEXT_PLAIN isn't needed.
812
    final Function<MediaType, MediaType> bin =
813
      m -> PLAIN_TEXT_FORMAT.contains( m ) ? TEXT_PLAIN : m;
814
815
    // Create two groups: YAML files and plain text files. The order that
816
    // the elements are listed in the enumeration for media types determines
817
    // what files are loaded first. Variable definitions come before all other
818
    // plain text documents.
819
    final var bins = paths
820
      .stream()
821
      .collect(
822
        groupingBy(
823
          path -> bin.apply( MediaType.fromFilename( path ) ),
824
          () -> new TreeMap<>( Enum::compareTo ),
825
          Collectors.toList()
826
        )
827
      );
828
829
    bins.putIfAbsent( TEXT_YAML, List.of( DEFINITION_DEFAULT.toString() ) );
830
    bins.putIfAbsent( TEXT_PLAIN, List.of( DOCUMENT_DEFAULT.toString() ) );
831
832
    final var result = new LinkedList<File>();
833
834
    // Ensure that the same types are listed together (keep insertion order).
835
    bins.forEach( ( mediaType, files ) -> result.addAll(
836
      files.stream().map( File::new ).toList() )
837
    );
838
839
    return result;
840
  }
841
842
  /**
843
   * Force the active editor to update, which will cause the processor
844
   * to re-evaluate the interpolated definition map thereby updating the
845
   * preview pane.
846
   *
847
   * @param editor Contains the source document to update in the preview pane.
848
   */
849
  private void process( final TextEditor editor ) {
850
    // Ensure processing does not run on the JavaFX thread, which frees the
851
    // text editor immediately for caret movement. The preview will have a
852
    // slight delay when catching up to the caret position.
853
    final var task = new Task<Void>() {
854
      @Override
855
      public Void call() {
856
        try {
857
          final var p = mProcessors.getOrDefault( editor, IDENTITY );
858
          p.apply( editor == null ? "" : editor.getText() );
859
        } catch( final Exception ex ) {
860
          clue( ex );
861
        }
862
863
        return null;
864
      }
865
    };
866
867
    // TODO: Each time the editor successfully runs the processor the task is
868
    //   considered successful. Due to the rapid-fire nature of processing
869
    //   (e.g., keyboard navigation, fast typing), it isn't necessary to
870
    //   scroll each time.
871
    //   The algorithm:
872
    //   1. Peek at the oldest time.
873
    //   2. If the difference between the oldest time and current time exceeds
874
    //      250 milliseconds, then invoke the scrolling.
875
    //   3. Insert the current time into the circular queue.
876
    task.setOnSucceeded(
877
      e -> invokeLater( () -> mPreview.scrollTo( CARET_ID ) )
878
    );
879
880
    // Prevents multiple process requests from executing simultaneously (due
881
    // to having a restricted queue size).
882
    sExecutor.execute( task );
883
  }
884
885
  /**
886
   * Lazily creates a {@link TabPane} configured to listen for tab select
887
   * events. The tab pane is associated with a given media type so that
888
   * similar files can be grouped together.
889
   *
890
   * @param mediaType The media type to associate with the tab pane.
891
   * @return An instance of {@link TabPane} that will handle tab docking.
892
   */
893
  private TabPane obtainTabPane( final MediaType mediaType ) {
894
    for( final var pane : mTabPanes ) {
895
      for( final var tab : pane.getTabs() ) {
896
        final var node = tab.getContent();
897
898
        if( node instanceof TextResource r && r.supports( mediaType ) ) {
899
          return pane;
900
        }
901
      }
902
    }
903
904
    final var pane = createTabPane();
905
    mTabPanes.add( pane );
906
    return pane;
907
  }
908
909
  /**
910
   * Creates an initialized {@link TabPane} instance.
911
   *
912
   * @return A new {@link TabPane} with all listeners configured.
913
   */
914
  private TabPane createTabPane() {
915
    final var tabPane = new DetachableTabPane();
916
917
    initStageOwnerFactory( tabPane );
918
    initTabListener( tabPane );
919
920
    return tabPane;
921
  }
922
923
  /**
924
   * When any {@link DetachableTabPane} is detached from the main window,
925
   * the stage owner factory must be given its parent window, which will
926
   * own the child window. The parent window is the {@link MainPane}'s
927
   * {@link Scene}'s {@link Window} instance.
928
   *
929
   * <p>
930
   * This will derives the new title from the main window title, incrementing
931
   * the window count to help uniquely identify the child windows.
932
   * </p>
933
   *
934
   * @param tabPane A new {@link DetachableTabPane} to configure.
935
   */
936
  private void initStageOwnerFactory( final DetachableTabPane tabPane ) {
937
    tabPane.setStageOwnerFactory( stage -> {
938
      final var title = get(
939
        "Detach.tab.title",
940
        ((Stage) getWindow()).getTitle(), ++mWindowCount
941
      );
942
      stage.setTitle( title );
943
944
      return getScene().getWindow();
945
    } );
946
  }
947
948
  /**
949
   * Responsible for configuring the content of each {@link DetachableTab} when
950
   * it is added to the given {@link DetachableTabPane} instance.
951
   * <p>
952
   * For {@link TextEditor} contents, an instance of {@link ScrollEventHandler}
953
   * is initialized to perform synchronized scrolling between the editor and
954
   * its preview window. Additionally, the last tab in the tab pane's list of
955
   * tabs is given focus.
956
   * </p>
957
   * <p>
958
   * Note that multiple tabs can be added simultaneously.
959
   * </p>
960
   *
961
   * @param tabPane A new {@link TabPane} to configure.
962
   */
963
  private void initTabListener( final TabPane tabPane ) {
964
    tabPane.getTabs().addListener(
965
      ( final ListChangeListener.Change<? extends Tab> listener ) -> {
966
        while( listener.next() ) {
967
          if( listener.wasAdded() ) {
968
            final var tabs = listener.getAddedSubList();
969
970
            tabs.forEach( tab -> {
971
              final var node = tab.getContent();
972
973
              if( node instanceof TextEditor ) {
974
                initScrollEventListener( tab );
975
              }
976
            } );
977
978
            // Select and give focus to the last tab opened.
979
            final var index = tabs.size() - 1;
980
            if( index >= 0 ) {
981
              final var tab = tabs.get( index );
982
              tabPane.getSelectionModel().select( tab );
983
              tab.getContent().requestFocus();
984
            }
985
          }
986
        }
987
      }
988
    );
989
  }
990
991
  /**
992
   * Synchronizes scrollbar positions between the given {@link Tab} that
993
   * contains an instance of {@link TextEditor} and {@link HtmlPreview} pane.
994
   *
995
   * @param tab The container for an instance of {@link TextEditor}.
996
   */
997
  private void initScrollEventListener( final Tab tab ) {
998
    final var editor = (TextEditor) tab.getContent();
999
    final var scrollPane = editor.getScrollPane();
1000
    final var scrollBar = mPreview.getVerticalScrollBar();
1001
    final var handler = new ScrollEventHandler( scrollPane, scrollBar );
1002
1003
    handler.enabledProperty().bind( tab.selectedProperty() );
1004
  }
1005
1006
  private void addTabPane( final int index, final TabPane tabPane ) {
1007
    final var items = getItems();
1008
1009
    if( !items.contains( tabPane ) ) {
1010
      items.add( index, tabPane );
1011
    }
1012
  }
1013
1014
  private void addTabPane( final TabPane tabPane ) {
1015
    addTabPane( getItems().size(), tabPane );
1016
  }
1017
1018
  private GenericBuilder<Mutator, ProcessorContext> createProcessorContextBuilder() {
1019
    final var w = getWorkspace();
1020
1021
    return builder()
1022
      .with( Mutator::setDefinitions, this::getDefinitions )
1023
      .with( Mutator::setLocale, w::getLocale )
1024
      .with( Mutator::setMetadata, w::getMetadata )
1025
      .with( Mutator::setThemePath, w::getThemePath )
1026
      .with( Mutator::setCaret,
1027
             () -> getTextEditor().getCaret() )
1028
      .with( Mutator::setImageDir,
1029
             () -> w.getFile( KEY_IMAGES_DIR ) )
1030
      .with( Mutator::setImageOrder,
1031
             () -> w.getString( KEY_IMAGES_ORDER ) )
1032
      .with( Mutator::setImageServer,
1033
             () -> w.getString( KEY_IMAGES_SERVER ) )
1034
      .with( Mutator::setSigilBegan,
1035
             () -> w.getString( KEY_DEF_DELIM_BEGAN ) )
1036
      .with( Mutator::setSigilEnded,
1037
             () -> w.getString( KEY_DEF_DELIM_ENDED ) )
1038
      .with( Mutator::setRScript,
1039
             () -> w.getString( KEY_R_SCRIPT ) )
1040
      .with( Mutator::setRWorkingDir,
1041
             () -> w.getFile( KEY_R_DIR ).toPath() )
1042
      .with( Mutator::setCurlQuotes,
1043
             () -> w.getBoolean( KEY_TYPESET_TYPOGRAPHY_QUOTES ) )
1044
      .with( Mutator::setAutoClean,
1045
             () -> w.getBoolean( KEY_TYPESET_CONTEXT_CLEAN ) );
1046
  }
1047
1048
  public ProcessorContext createProcessorContext() {
1049
    return createProcessorContext( null, NONE );
1050
  }
1051
1052
  /**
1053
   * @param outputPath Used when exporting to a PDF file (binary).
1054
   * @param format     Used when processors export to a new text format.
1055
   * @return A new {@link ProcessorContext} to use when creating an instance of
1056
   * {@link Processor}.
1057
   */
1058
  public ProcessorContext createProcessorContext(
1059
    final Path outputPath, final ExportFormat format ) {
1060
    final var textEditor = getTextEditor();
1061
    final var inputPath = textEditor.getPath();
1062
1063
    return createProcessorContextBuilder()
1064
      .with( Mutator::setInputPath, inputPath )
1065
      .with( Mutator::setOutputPath, outputPath )
1066
      .with( Mutator::setExportFormat, format )
1067
      .build();
1068
  }
1069
1070
  /**
1071
   * @param inputPath Used by {@link ProcessorFactory} to determine
1072
   *                  {@link Processor} type to create based on file type.
1073
   * @return A new {@link ProcessorContext} to use when creating an instance of
1074
   * {@link Processor}.
1075
   */
1076
  private ProcessorContext createProcessorContext( final Path inputPath ) {
1077
    return createProcessorContextBuilder()
1078
      .with( Mutator::setInputPath, inputPath )
1079
      .with( Mutator::setExportFormat, NONE )
1080
      .build();
1081
  }
1082
1083
  private TextResource createTextResource( final File file ) {
1084
    // TODO: Create PlainTextEditor that's returned by default.
1085
    return MediaType.valueFrom( file ) == TEXT_YAML
1086
      ? createDefinitionEditor( file )
1087
      : createMarkdownEditor( file );
1088
  }
1089
1090
  /**
1091
   * Creates an instance of {@link MarkdownEditor} that listens for both
1092
   * caret change events and text change events. Text change events must
1093
   * take priority over caret change events because it's possible to change
1094
   * the text without moving the caret (e.g., delete selected text).
1095
   *
1096
   * @param inputFile The file containing contents for the text editor.
1097
   * @return A non-null text editor.
1098
   */
1099
  private TextResource createMarkdownEditor( final File inputFile ) {
1100
    final var editor = new MarkdownEditor( inputFile, getWorkspace() );
1101
1102
    mProcessors.computeIfAbsent(
1103
      editor, p -> createProcessors(
1104
        createProcessorContext( inputFile.toPath() ),
1105
        createHtmlPreviewProcessor()
1106
      )
1107
    );
1108
1109
    // Listener for editor modifications or caret position changes.
1110
    editor.addDirtyListener( ( c, o, n ) -> {
1111
      if( n ) {
1112
        // Reset the status bar after changing the text.
1113
        clue();
1114
1115
        // Processing the text may update the status bar.
1116
        process( getTextEditor() );
1117
1118
        // Update the caret position in the status bar.
1119
        CaretMovedEvent.fire( editor.getCaret() );
1120
      }
1121
    } );
1122
1123
    editor.addEventListener(
1124
      keyPressed( SPACE, CONTROL_DOWN ), this::autoinsert
1125
    );
1126
1127
    // Track the caret to restore its position later.
1128
    editor.getTextArea().caretPositionProperty().addListener( ( c, o, n ) -> {
1129
      getWorkspace().integerProperty( KEY_UI_RECENT_OFFSET ).setValue( n );
1130
    } );
10801131
10811132
    // Set the active editor, which refreshes the preview panel.
M src/main/java/com/keenwrite/MainScene.java
3131
3232
/**
33
 * Responsible for creating the bar scene: menu bar, tool bar, and status bar.
33
 * Responsible for creating the bar scene: menu bar, toolbar, and status bar.
3434
 */
3535
public final class MainScene {
...
168168
  }
169169
170
  private GuiCommands createApplicationActions(
171
    final MainPane mainPane ) {
170
  private GuiCommands createApplicationActions( final MainPane mainPane ) {
172171
    return new GuiCommands( this, mainPane );
173172
  }
...
221220
   * Binds the visible property of the node to the managed property so that
222221
   * hiding the node also removes the screen real estate that it occupies.
223
   * This allows the user to hide the menu bar, tool bar, etc.
222
   * This allows the user to hide the menu bar, toolbar, etc.
224223
   *
225224
   * @param node The node to have its real estate bound to visibility.
M src/main/java/com/keenwrite/constants/Constants.java
4444
4545
  public static final File DOCUMENT_DEFAULT = getFile( "document" );
46
  public static final int DOCUMENT_OFFSET = 0;
4647
  public static final File DEFINITION_DEFAULT = getFile( "definition" );
4748
  public static final File PDF_DEFAULT = getFile( "pdf" );
M src/main/java/com/keenwrite/editors/TextEditor.java
2727
2828
  /**
29
   * Delegates requesting focus to the internal {@link StyleClassedTextArea}.
30
   */
31
  default void requestFocus() {
32
    getTextArea().requestFocus();
33
  }
34
35
  /**
2936
   * Returns the complete text for the specified paragraph index.
3037
   *
M src/main/java/com/keenwrite/editors/markdown/HyperlinkModel.java
7070
  }
7171
72
  public final void setText( final String text ) {
72
  public void setText( final String text ) {
7373
    this.text = sanitize( text );
7474
  }
7575
76
  public final void setUrl( final String url ) {
76
  public void setUrl( final String url ) {
7777
    this.url = sanitize( url );
7878
  }
7979
80
  public final void setTitle( final String title ) {
80
  public void setTitle( final String title ) {
8181
    this.title = sanitize( title );
8282
  }
M src/main/java/com/keenwrite/editors/markdown/MarkdownEditor.java
439439
    // This won't remove the highlighting if the caret position moves by mouse.
440440
    final var handler = mTextArea.getOnKeyPressed();
441
    mTextArea.setOnKeyPressed( ( event ) -> {
441
    mTextArea.setOnKeyPressed( event -> {
442442
      mTextArea.setOnKeyPressed( handler );
443443
      unstylize( style );
...
616616
617617
      selection.lines().forEach(
618
        ( l ) -> sb.append( "\t" ).append( l ).append( NEWLINE )
618
        l -> sb.append( "\t" ).append( l ).append( NEWLINE )
619619
      );
620620
    }
...
634634
635635
      selection.lines().forEach(
636
        ( l ) -> sb.append( l.startsWith( "\t" ) ? l.substring( 1 ) : l )
637
                   .append( NEWLINE )
636
        l -> sb.append( l.startsWith( "\t" ) ? l.substring( 1 ) : l )
637
               .append( NEWLINE )
638638
      );
639639
...
684684
    int length = range.getLength();
685685
    text = stripStart( text, null );
686
    final int beganIndex = range.getStart() + (length - text.length());
686
    final int beganIndex = range.getStart() + length - text.length();
687687
688688
    length = text.length();
M src/main/java/com/keenwrite/io/FileWatchService.java
169169
   *
170170
   * @param listener The {@link FileModifiedListener} to remove.
171
   * @return {@code true} if this contained the given listener.
172171
   */
173
  public boolean removeListener( final FileModifiedListener listener ) {
174
    return mListeners.remove( listener );
172
  public void removeListener( final FileModifiedListener listener ) {
173
    mListeners.remove( listener );
175174
  }
176175
M src/main/java/com/keenwrite/preferences/AppKeys.java
4646
  public static final Key KEY_UI_RECENT_DIR = key( KEY_UI_RECENT, "dir" );
4747
  public static final Key KEY_UI_RECENT_DOCUMENT = key( KEY_UI_RECENT, "document" );
48
  public static final Key KEY_UI_RECENT_OFFSET = key( KEY_UI_RECENT, "offset" );
4849
  public static final Key KEY_UI_RECENT_DEFINITION = key( KEY_UI_RECENT, "definition" );
4950
  public static final Key KEY_UI_RECENT_EXPORT = key( KEY_UI_RECENT, "export" );
M src/main/java/com/keenwrite/preferences/SimpleFontControl.java
6666
      createFontSelectorDialog( initialFont )
6767
        .showAndWait()
68
        .ifPresent( ( font ) -> {
68
        .ifPresent( font -> {
6969
          mFontName.setText( font.getFamily() );
7070
          mFontSize.set( font.getSize() );
...
109109
    final var dialog = new FontSelectorDialog( font );
110110
    final var pane = dialog.getDialogPane();
111
    final var buttonOk = ((Button) pane.lookupButton( OK ));
112
    final var buttonCancel = ((Button) pane.lookupButton( CANCEL ));
111
    final var buttonOk = (Button) pane.lookupButton( OK );
112
    final var buttonCancel = (Button) pane.lookupButton( CANCEL );
113113
114114
    buttonOk.setDefaultButton( true );
115115
    buttonCancel.setCancelButton( true );
116
    pane.setOnKeyReleased( ( keyEvent ) -> {
116
    pane.setOnKeyReleased( keyEvent -> {
117117
      switch( keyEvent.getCode() ) {
118118
        case ENTER -> buttonOk.fire();
M src/main/java/com/keenwrite/preferences/SimpleTableControl.java
110110
111111
    table.widthProperty().addListener( ( c, o, n ) -> {
112
      if( (o != null && n != null)
112
      if( o != null && n != null
113113
        && o.intValue() == n.intValue() - 2
114114
        && inserted.getAndSet( false ) ) {
M src/main/java/com/keenwrite/preferences/Workspace.java
8181
8282
    entry( KEY_UI_RECENT_DIR, asFileProperty( USER_DIRECTORY ) ),
83
    entry( KEY_UI_RECENT_OFFSET, asIntegerProperty( DOCUMENT_OFFSET ) ),
8384
    entry( KEY_UI_RECENT_DOCUMENT, asFileProperty( DOCUMENT_DEFAULT ) ),
8485
    entry( KEY_UI_RECENT_DEFINITION, asFileProperty( DEFINITION_DEFAULT ) ),
M src/main/java/com/keenwrite/preferences/XmlStore.java
213213
214214
    for( final var item : list ) {
215
      if( item instanceof Entry entry ) {
215
      if( item instanceof Entry<?, ?> entry ) {
216216
        try {
217217
          final var child = Key.key( key, entry.getKey().toString() );
M src/main/java/com/keenwrite/preview/FlyingSaucerPanel.java
173173
174174
    // Scroll back up by half the height of the scroll bar to keep the typing
175
    // area within the view port. Otherwise the view port will have jumped too
175
    // area within the view port; otherwise, the view port will have jumped too
176176
    // high up and the most recently typed letters won't be visible.
177177
    int y = max( box.getAbsY() - scrollPane.getVerticalScrollBar()
M src/main/java/com/keenwrite/preview/MathRenderer.java
6464
  /**
6565
   * Tries to instantiate a given object, returning {@code null} on failure.
66
   * The failure message is bubbled up to to the user interface.
66
   * The failure message is bubbled up to the user interface.
6767
   *
6868
   * @param supplier Creates an instance.
M src/main/java/com/keenwrite/preview/SvgRasterizer.java
22
package com.keenwrite.preview;
33
4
import org.apache.batik.anim.dom.SAXSVGDocumentFactory;
5
import org.apache.batik.bridge.BridgeContext;
6
import org.apache.batik.bridge.DocumentLoader;
7
import org.apache.batik.bridge.UserAgent;
8
import org.apache.batik.bridge.UserAgentAdapter;
9
import org.apache.batik.css.parser.Parser;
10
import org.apache.batik.gvt.renderer.ImageRenderer;
11
import org.apache.batik.transcoder.*;
12
import org.apache.batik.transcoder.image.ImageTranscoder;
13
import org.apache.batik.util.XMLResourceDescriptor;
14
import org.w3c.css.sac.CSSException;
4
import io.sf.carte.echosvg.anim.dom.SAXSVGDocumentFactory;
5
import io.sf.carte.echosvg.bridge.BridgeContext;
6
import io.sf.carte.echosvg.bridge.DocumentLoader;
7
import io.sf.carte.echosvg.bridge.UserAgent;
8
import io.sf.carte.echosvg.bridge.UserAgentAdapter;
9
import io.sf.carte.echosvg.gvt.renderer.ImageRenderer;
10
import io.sf.carte.echosvg.transcoder.ErrorHandler;
11
import io.sf.carte.echosvg.transcoder.TranscoderException;
12
import io.sf.carte.echosvg.transcoder.TranscoderInput;
13
import io.sf.carte.echosvg.transcoder.TranscoderOutput;
14
import io.sf.carte.echosvg.transcoder.image.ImageTranscoder;
1515
import org.w3c.dom.Document;
1616
import org.w3c.dom.Element;
1717
1818
import java.awt.*;
1919
import java.awt.image.BufferedImage;
2020
import java.io.File;
21
import java.io.IOException;
2221
import java.io.InputStream;
2322
import java.io.StringReader;
...
3029
import static com.keenwrite.events.StatusEvent.clue;
3130
import static com.keenwrite.preview.HighQualityRenderingHints.RENDERING_HINTS;
31
import static io.sf.carte.echosvg.bridge.UnitProcessor.createContext;
32
import static io.sf.carte.echosvg.bridge.UnitProcessor.svgHorizontalLengthToUserSpace;
33
import static io.sf.carte.echosvg.transcoder.SVGAbstractTranscoder.KEY_WIDTH;
34
import static io.sf.carte.echosvg.transcoder.TranscodingHints.Key;
35
import static io.sf.carte.echosvg.transcoder.image.ImageTranscoder.KEY_PIXEL_UNIT_TO_MILLIMETER;
36
import static io.sf.carte.echosvg.util.SVGConstants.SVG_WIDTH_ATTRIBUTE;
3237
import static java.awt.image.BufferedImage.TYPE_INT_RGB;
3338
import static java.text.NumberFormat.getIntegerInstance;
34
import static org.apache.batik.bridge.UnitProcessor.createContext;
35
import static org.apache.batik.bridge.UnitProcessor.svgHorizontalLengthToUserSpace;
36
import static org.apache.batik.transcoder.SVGAbstractTranscoder.KEY_WIDTH;
37
import static org.apache.batik.transcoder.TranscodingHints.Key;
38
import static org.apache.batik.transcoder.image.ImageTranscoder.KEY_PIXEL_UNIT_TO_MILLIMETER;
39
import static org.apache.batik.util.SVGConstants.SVG_WIDTH_ATTRIBUTE;
40
import static org.apache.batik.util.XMLResourceDescriptor.getXMLParserClassName;
4139
4240
/**
4341
 * Responsible for converting SVG images into rasterized PNG images.
4442
 */
4543
public final class SvgRasterizer {
46
  /**
47
   * <a href="https://issues.apache.org/jira/browse/BATIK-1112">Bug fix</a>
48
   */
49
  public static final class InkscapeCssParser extends Parser {
50
    public void parseStyleDeclaration( final String source )
51
      throws CSSException, IOException {
52
      super.parseStyleDeclaration(
53
        source.replaceAll( "-inkscape-font-specification:[^;\"]*;", "" )
54
      );
55
    }
56
  }
5744
5845
  /**
...
7461
      clue( ex );
7562
    }
76
  }
77
78
  static {
79
    XMLResourceDescriptor.setCSSParserClassName(
80
      InkscapeCssParser.class.getName()
81
    );
8263
  }
8364
8465
  private static final UserAgent USER_AGENT = new UserAgentAdapter();
8566
  private static final BridgeContext BRIDGE_CONTEXT = new BridgeContext(
8667
    USER_AGENT, new DocumentLoader( USER_AGENT )
8768
  );
8869
  private static final ErrorHandler sErrorHandler = new SvgErrorHandler();
8970
9071
  private static final SAXSVGDocumentFactory FACTORY_DOM =
91
    new SAXSVGDocumentFactory( getXMLParserClassName() );
72
    new SAXSVGDocumentFactory();
9273
9374
  private static final NumberFormat INT_FORMAT = getIntegerInstance();
...
319300
    final var root = document.getDocumentElement();
320301
    final var width = root.getAttribute( SVG_WIDTH_ATTRIBUTE );
302
    
321303
    return rasterizeString( xml, INT_FORMAT.parse( width ).intValue() );
322304
  }
M src/main/java/com/keenwrite/preview/images/ImageUtils.java
4141
   * returns one row (height == 1) of byte packed image data in BGR or AGBR form
4242
   *
43
   * @param temp must be either null or a array with length of w*h
43
   * @param temp must be either null or an array with length of w*h
4444
   */
4545
  static void getPixelsBGR(
4646
    BufferedImage img, int y, int w, byte[] array, int[] temp ) {
4747
    final int x = 0;
4848
    final int h = 1;
4949
5050
    assert array.length == temp.length * nrChannels( img );
51
    assert (temp.length == w);
51
    assert temp.length == w;
5252
5353
    final Raster raster;
M src/main/java/com/keenwrite/preview/images/Lanczos3.java
1111
    x *= Math.PI;
1212
13
    if( (x < 0.01f) && (x > -0.01f) ) {
13
    if( x < 0.01f && x > -0.01f ) {
1414
      return 1.0f + x * x * (-1.0f / 6.0f + x * x * 1.0f / 120.0f);
1515
    }
...
3030
3131
    if( t < 3.0f ) { return clip( sinc( t ) * sinc( t / 3.0f ) ); }
32
    else { return (0.0f); }
32
    else { return 0.0f; }
3333
  }
3434
...
105105
    dst_cols = dst.cols;
106106
107
    xratio = (float) (dst_cols) / (float) src_cols;
108
    yratio = (float) (dst_rows) / (float) src_rows;
107
    xratio = (float) dst_cols / (float) src_cols;
108
    yratio = (float) dst_rows / (float) src_rows;
109109
110110
    float scale;
M src/main/java/com/keenwrite/preview/images/Lanczos3Filter.java
1414
  }
1515
16
  public final float apply( float value ) {
16
  public float apply( float value ) {
1717
    if( value == 0 ) {
1818
      return 1.0f;
M src/main/java/com/keenwrite/preview/images/ResampleOp.java
5353
    }
5454
55
    public int getNumContributors() {
56
      return numContributors;
57
    }
58
5955
    public int[] getArrN() {
6056
      return arrN;
61
    }
62
63
    public float[] getArrWeight() {
64
      return arrWeight;
6557
    }
6658
  }
M src/main/java/com/keenwrite/processors/markdown/extensions/fences/FencedBlockExtension.java
1010
import com.keenwrite.processors.r.RChunkEvaluator;
1111
import com.keenwrite.processors.r.RVariableProcessor;
12
import com.keenwrite.util.Pair;
1312
import com.vladsch.flexmark.ast.FencedCodeBlock;
1413
import com.vladsch.flexmark.html.HtmlRendererOptions;
1514
import com.vladsch.flexmark.html.HtmlWriter;
1615
import com.vladsch.flexmark.html.renderer.*;
1716
import com.vladsch.flexmark.util.data.DataHolder;
1817
import com.vladsch.flexmark.util.sequence.BasedSequence;
18
import com.whitemagicsoftware.keenquotes.util.Tuple;
1919
import org.jetbrains.annotations.NotNull;
2020
...
141141
        final var style = sanitize( node.getInfo() );
142142
143
        Pair<String, ResolvedLink> imagePair;
143
        Tuple<String, ResolvedLink> imagePair;
144144
145145
        if( style.startsWith( STYLE_DIAGRAM ) ) {
146146
          imagePair = importTextDiagram( style, node, context );
147147
148
          html.attr( "src", imagePair.getKey() );
149
          html.withAttr( imagePair.getValue() );
148
          html.attr( "src", imagePair.item1() );
149
          html.withAttr( imagePair.item2() );
150150
          html.tagVoid( "img" );
151151
        }
152152
        else if( style.startsWith( STYLE_R_CHUNK ) ) {
153153
          imagePair = evaluateRChunk( node, context );
154154
155
          html.attr( "src", imagePair.getKey() );
156
          html.withAttr( imagePair.getValue() );
155
          html.attr( "src", imagePair.item1() );
156
          html.withAttr( imagePair.item2() );
157157
          html.tagVoid( "img" );
158158
        }
...
167167
    }
168168
169
    private Pair<String, ResolvedLink> importTextDiagram(
169
    private Tuple<String, ResolvedLink> importTextDiagram(
170170
      final String style,
171171
      final FencedCodeBlock node,
...
179179
      final var link = context.resolveLink( LINK, source, false );
180180
181
      return new Pair<>( source, link );
181
      return new Tuple<>( source, link );
182182
    }
183183
184
    private Pair<String, ResolvedLink> evaluateRChunk(
184
    private Tuple<String, ResolvedLink> evaluateRChunk(
185185
      final FencedCodeBlock node,
186186
      final NodeRendererContext context ) {
...
194194
      final var result = mRChunkEvaluator.apply( r );
195195
196
      return new Pair<>( svg, link );
196
      return new Tuple<>( svg, link );
197197
    }
198198
M src/main/java/com/keenwrite/processors/r/Engine.java
2727
   */
2828
  private static final ScriptEngine sEngine =
29
    (new ScriptEngineManager()).getEngineByName( "Renjin" );
29
    new ScriptEngineManager().getEngineByName( "Renjin" );
3030
3131
  /**
M src/main/java/com/keenwrite/spelling/impl/TextEditorSpeller.java
8989
    // This allows Markdown, R Markdown, XML, and R XML documents to return
9090
    // sets of words to check.
91
9291
    final var node = mParser.parse( text );
9392
    final var visitor = new TextVisitor( ( visited, bIndex, eIndex ) -> {
M src/main/java/com/keenwrite/typesetting/Typesetter.java
237237
238238
      // Exit value for a successful invocation of the typesetter. This value
239
      // value is returned when creating the cache on the first run as well as
239
      // is returned when creating the cache on the first run as well as
240240
      // creating PDFs on subsequent runs (after the cache has been created).
241241
      // Users don't care about exit codes, only whether the PDF was generated.
...
318318
   * }</pre>
319319
   * <p>
320
   * The lines are parsed; the first number is displayed in a status bar
320
   * The lines are parsed; the first number is displayed as a status bar
321321
   * message.
322322
   * </p>
M src/main/java/com/keenwrite/ui/actions/GuiCommands.java
55
import com.keenwrite.MainPane;
66
import com.keenwrite.MainScene;
7
import com.keenwrite.constants.Constants;
8
import com.keenwrite.editors.TextDefinition;
9
import com.keenwrite.editors.TextEditor;
10
import com.keenwrite.editors.markdown.HyperlinkModel;
11
import com.keenwrite.editors.markdown.LinkVisitor;
12
import com.keenwrite.events.CaretMovedEvent;
13
import com.keenwrite.events.ExportFailedEvent;
14
import com.keenwrite.preferences.Key;
15
import com.keenwrite.preferences.PreferencesController;
16
import com.keenwrite.preferences.Workspace;
17
import com.keenwrite.processors.markdown.MarkdownProcessor;
18
import com.keenwrite.search.SearchModel;
19
import com.keenwrite.typesetting.Typesetter;
20
import com.keenwrite.ui.controls.SearchBar;
21
import com.keenwrite.ui.dialogs.ExportDialog;
22
import com.keenwrite.ui.dialogs.ExportSettings;
23
import com.keenwrite.ui.dialogs.ImageDialog;
24
import com.keenwrite.ui.dialogs.LinkDialog;
25
import com.keenwrite.ui.explorer.FilePicker;
26
import com.keenwrite.ui.explorer.FilePickerFactory;
27
import com.keenwrite.ui.logging.LogView;
28
import com.keenwrite.util.AlphanumComparator;
29
import com.keenwrite.util.RangeValidator;
30
import com.vladsch.flexmark.ast.Link;
31
import javafx.concurrent.Task;
32
import javafx.scene.control.Alert;
33
import javafx.scene.control.Dialog;
34
import javafx.stage.Window;
35
import javafx.stage.WindowEvent;
36
37
import java.io.File;
38
import java.io.IOException;
39
import java.nio.file.Path;
40
import java.util.ArrayList;
41
import java.util.List;
42
import java.util.Optional;
43
import java.util.concurrent.ExecutorService;
44
import java.util.concurrent.atomic.AtomicInteger;
45
46
import static com.keenwrite.Bootstrap.*;
47
import static com.keenwrite.ExportFormat.*;
48
import static com.keenwrite.Messages.get;
49
import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG_NODE;
50
import static com.keenwrite.events.StatusEvent.clue;
51
import static com.keenwrite.preferences.AppKeys.*;
52
import static com.keenwrite.processors.ProcessorFactory.createProcessors;
53
import static com.keenwrite.ui.explorer.FilePickerFactory.SelectionType;
54
import static com.keenwrite.ui.explorer.FilePickerFactory.SelectionType.*;
55
import static com.keenwrite.util.FileWalker.walk;
56
import static java.nio.file.Files.readString;
57
import static java.nio.file.Files.writeString;
58
import static java.util.concurrent.Executors.newFixedThreadPool;
59
import static javafx.application.Platform.runLater;
60
import static javafx.event.Event.fireEvent;
61
import static javafx.scene.control.Alert.AlertType.INFORMATION;
62
import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST;
63
import static org.apache.commons.io.FilenameUtils.getExtension;
64
65
/**
66
 * Responsible for abstracting how functionality is mapped to the application.
67
 * This allows users to customize accelerator keys and will provide pluggable
68
 * functionality so that different text markup languages can change documents
69
 * using their respective syntax.
70
 */
71
public final class GuiCommands {
72
  private static final ExecutorService sExecutor = newFixedThreadPool( 1 );
73
74
  private static final String STYLE_SEARCH = "search";
75
76
  /**
77
   * Sci-fi genres, which are can be longer than other genres, typically fall
78
   * below 150,000 words at 6 chars per word. This reduces re-allocations of
79
   * memory when concatenating files together when exporting novels.
80
   */
81
  private static final int DOCUMENT_LENGTH = 150_000 * 6;
82
83
  /**
84
   * When an action is executed, this is one of the recipients.
85
   */
86
  private final MainPane mMainPane;
87
88
  private final MainScene mMainScene;
89
90
  private final LogView mLogView;
91
92
  /**
93
   * Tracks finding text in the active document.
94
   */
95
  private final SearchModel mSearchModel;
96
97
  public GuiCommands( final MainScene scene, final MainPane pane ) {
98
    mMainScene = scene;
99
    mMainPane = pane;
100
    mLogView = new LogView();
101
    mSearchModel = new SearchModel();
102
    mSearchModel.matchOffsetProperty().addListener( ( c, o, n ) -> {
103
      final var editor = getActiveTextEditor();
104
105
      // Clear highlighted areas before highlighting a new region.
106
      if( o != null ) {
107
        editor.unstylize( STYLE_SEARCH );
108
      }
109
110
      if( n != null ) {
111
        editor.moveTo( n.getStart() );
112
        editor.stylize( n, STYLE_SEARCH );
113
      }
114
    } );
115
116
    // When the active text editor changes ...
117
    mMainPane.textEditorProperty().addListener(
118
      ( c, o, n ) -> {
119
        // ... update the haystack.
120
        mSearchModel.search( getActiveTextEditor().getText() );
121
122
        // ... update the status bar with the current caret position.
123
        if( n != null ) {
124
          CaretMovedEvent.fire( n.getCaret() );
125
        }
126
      }
127
    );
128
  }
129
130
  public void file_new() {
131
    getMainPane().newTextEditor();
132
  }
133
134
  public void file_open() {
135
    pickFiles( FILE_OPEN_MULTIPLE ).ifPresent( l -> getMainPane().open( l ) );
136
  }
137
138
  public void file_close() {
139
    getMainPane().close();
140
  }
141
142
  public void file_close_all() {
143
    getMainPane().closeAll();
144
  }
145
146
  public void file_save() {
147
    getMainPane().save();
148
  }
149
150
  public void file_save_as() {
151
    pickFiles( FILE_SAVE_AS ).ifPresent( l -> getMainPane().saveAs( l ) );
152
  }
153
154
  public void file_save_all() {
155
    getMainPane().saveAll();
156
  }
157
158
  /**
159
   * Converts the actively edited file in the given file format.
160
   *
161
   * @param format The destination file format.
162
   */
163
  private void file_export( final ExportFormat format ) {
164
    file_export( format, false );
165
  }
166
167
  /**
168
   * Converts one or more files into the given file format. If {@code dir}
169
   * is set to true, this will first append all files in the same directory
170
   * as the actively edited file.
171
   *
172
   * @param format The destination file format.
173
   * @param dir    Export all files in the actively edited file's directory.
174
   */
175
  private void file_export( final ExportFormat format, final boolean dir ) {
176
    final var main = getMainPane();
177
    final var editor = main.getTextEditor();
178
    final var exported = getWorkspace().fileProperty( KEY_UI_RECENT_EXPORT );
179
    final var filename = format.toExportFilename( editor.getPath() );
180
    final var selection = pickFile(
181
      Constants.PDF_DEFAULT.getName().equals( exported.get().getName() )
182
        ? filename
183
        : exported.get(), FILE_EXPORT
184
    );
185
186
    selection.ifPresent( ( files ) -> {
187
      editor.save();
188
189
      final var file = files.get( 0 );
190
      final var path = file.toPath();
191
      final var document = dir ? append( editor ) : editor.getText();
192
      final var context = main.createProcessorContext( path, format );
193
194
      final var task = new Task<Path>() {
195
        @Override
196
        protected Path call() throws Exception {
197
          final var chain = createProcessors( context );
198
          final var export = chain.apply( document );
199
200
          // Processors can export binary files. In such cases, processors
201
          // return null to prevent further processing.
202
          return export == null ? null : writeString( path, export );
203
        }
204
      };
205
206
      task.setOnSucceeded(
207
        e -> {
208
          // Remember the exported file name for next time.
209
          exported.setValue( file );
210
211
          final var result = task.getValue();
212
213
          // Binary formats must notify users of success independently.
214
          if( result != null ) {
215
            clue( "Main.status.export.success", result );
216
          }
217
        }
218
      );
219
220
      task.setOnFailed( e -> {
221
        final var ex = task.getException();
222
        clue( ex );
223
224
        if( ex instanceof TypeNotPresentException ) {
225
          fireExportFailedEvent();
226
        }
227
      } );
228
229
      sExecutor.execute( task );
230
    } );
231
  }
232
233
  /**
234
   * @param dir {@code true} means to export all files in the active file
235
   *            editor's directory; {@code false} means to export only the
236
   *            actively edited file.
237
   */
238
  private void file_export_pdf( final boolean dir ) {
239
    final var workspace = getWorkspace();
240
    final var themes = workspace.getFile(
241
      KEY_TYPESET_CONTEXT_THEMES_PATH
242
    );
243
    final var theme = workspace.stringProperty(
244
      KEY_TYPESET_CONTEXT_THEME_SELECTION
245
    );
246
    final var chapters = workspace.stringProperty(
247
      KEY_TYPESET_CONTEXT_CHAPTERS
248
    );
249
    final var settings = ExportSettings
250
      .builder()
251
      .with( ExportSettings.Mutator::setTheme, theme )
252
      .with( ExportSettings.Mutator::setChapters, chapters )
253
      .build();
254
255
    if( Typesetter.canRun() ) {
256
      // If the typesetter is installed, allow the user to select a theme. If
257
      // the themes aren't installed, a status message will appear.
258
      if( ExportDialog.choose( getWindow(), themes, settings, dir ) ) {
259
        file_export( APPLICATION_PDF, dir );
260
      }
261
    }
262
    else {
263
      fireExportFailedEvent();
264
    }
265
  }
266
267
  public void file_export_pdf() {
268
    file_export_pdf( false );
269
  }
270
271
  public void file_export_pdf_dir() {
272
    file_export_pdf( true );
273
  }
274
275
  public void file_export_html_svg() {
276
    file_export( HTML_TEX_SVG );
277
  }
278
279
  public void file_export_html_tex() {
280
    file_export( HTML_TEX_DELIMITED );
281
  }
282
283
  public void file_export_xhtml_tex() {
284
    file_export( XHTML_TEX );
285
  }
286
287
  private void fireExportFailedEvent() {
288
    runLater( ExportFailedEvent::fire );
289
  }
290
291
  public void file_exit() {
292
    final var window = getWindow();
293
    fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) );
294
  }
295
296
  public void edit_undo() {
297
    getActiveTextEditor().undo();
298
  }
299
300
  public void edit_redo() {
301
    getActiveTextEditor().redo();
302
  }
303
304
  public void edit_cut() {
305
    getActiveTextEditor().cut();
306
  }
307
308
  public void edit_copy() {
309
    getActiveTextEditor().copy();
310
  }
311
312
  public void edit_paste() {
313
    getActiveTextEditor().paste();
314
  }
315
316
  public void edit_select_all() {
317
    getActiveTextEditor().selectAll();
318
  }
319
320
  public void edit_find() {
321
    final var nodes = getMainScene().getStatusBar().getLeftItems();
322
323
    if( nodes.isEmpty() ) {
324
      final var searchBar = new SearchBar();
325
326
      searchBar.matchIndexProperty().bind( mSearchModel.matchIndexProperty() );
327
      searchBar.matchCountProperty().bind( mSearchModel.matchCountProperty() );
328
329
      searchBar.setOnCancelAction( ( event ) -> {
330
        final var editor = getActiveTextEditor();
331
        nodes.remove( searchBar );
332
        editor.unstylize( STYLE_SEARCH );
333
        editor.getNode().requestFocus();
334
      } );
335
336
      searchBar.addInputListener( ( c, o, n ) -> {
337
        if( n != null && !n.isEmpty() ) {
338
          mSearchModel.search( n, getActiveTextEditor().getText() );
339
        }
340
      } );
341
342
      searchBar.setOnNextAction( ( event ) -> edit_find_next() );
343
      searchBar.setOnPrevAction( ( event ) -> edit_find_prev() );
344
345
      nodes.add( searchBar );
346
      searchBar.requestFocus();
347
    }
348
    else {
349
      nodes.clear();
350
    }
351
  }
352
353
  public void edit_find_next() {
354
    mSearchModel.advance();
355
  }
356
357
  public void edit_find_prev() {
358
    mSearchModel.retreat();
359
  }
360
361
  public void edit_preferences() {
362
    try {
363
      new PreferencesController( getWorkspace() ).show();
364
    } catch( final Exception ex ) {
365
      clue( ex );
366
    }
367
  }
368
369
  public void format_bold() {
370
    getActiveTextEditor().bold();
371
  }
372
373
  public void format_italic() {
374
    getActiveTextEditor().italic();
375
  }
376
377
  public void format_monospace() {
378
    getActiveTextEditor().monospace();
379
  }
380
381
  public void format_superscript() {
382
    getActiveTextEditor().superscript();
383
  }
384
385
  public void format_subscript() {
386
    getActiveTextEditor().subscript();
387
  }
388
389
  public void format_strikethrough() {
390
    getActiveTextEditor().strikethrough();
391
  }
392
393
  public void insert_blockquote() {
394
    getActiveTextEditor().blockquote();
395
  }
396
397
  public void insert_code() {
398
    getActiveTextEditor().code();
399
  }
400
401
  public void insert_fenced_code_block() {
402
    getActiveTextEditor().fencedCodeBlock();
403
  }
404
405
  public void insert_link() {
406
    insertObject( createLinkDialog() );
407
  }
408
409
  public void insert_image() {
410
    insertObject( createImageDialog() );
411
  }
412
413
  private void insertObject( final Dialog<String> dialog ) {
414
    final var textArea = getActiveTextEditor().getTextArea();
415
    dialog.showAndWait().ifPresent( textArea::replaceSelection );
416
  }
417
418
  private Dialog<String> createLinkDialog() {
419
    return new LinkDialog( getWindow(), createHyperlinkModel() );
420
  }
421
422
  private Dialog<String> createImageDialog() {
423
    final var path = getActiveTextEditor().getPath();
424
    final var parentDir = path.getParent();
425
    return new ImageDialog( getWindow(), parentDir );
426
  }
427
428
  /**
429
   * Returns one of: selected text, word under cursor, or parsed hyperlink from
430
   * the Markdown AST.
431
   *
432
   * @return An instance containing the link URL and display text.
433
   */
434
  private HyperlinkModel createHyperlinkModel() {
435
    final var context = getMainPane().createProcessorContext();
436
    final var editor = getActiveTextEditor();
437
    final var textArea = editor.getTextArea();
438
    final var selectedText = textArea.getSelectedText();
439
440
    // Convert current paragraph to Markdown nodes.
441
    final var mp = MarkdownProcessor.create( context );
442
    final var p = textArea.getCurrentParagraph();
443
    final var paragraph = textArea.getText( p );
444
    final var node = mp.toNode( paragraph );
445
    final var visitor = new LinkVisitor( textArea.getCaretColumn() );
446
    final var link = visitor.process( node );
447
448
    if( link != null ) {
449
      textArea.selectRange( p, link.getStartOffset(), p, link.getEndOffset() );
450
    }
451
452
    return createHyperlinkModel( link, selectedText );
453
  }
454
455
  private HyperlinkModel createHyperlinkModel(
456
    final Link link, final String selection ) {
457
458
    return link == null
459
      ? new HyperlinkModel( selection, "https://localhost" )
460
      : new HyperlinkModel( link );
461
  }
462
463
  public void insert_heading_1() {
464
    insert_heading( 1 );
465
  }
466
467
  public void insert_heading_2() {
468
    insert_heading( 2 );
469
  }
470
471
  public void insert_heading_3() {
472
    insert_heading( 3 );
473
  }
474
475
  private void insert_heading( final int level ) {
476
    getActiveTextEditor().heading( level );
477
  }
478
479
  public void insert_unordered_list() {
480
    getActiveTextEditor().unorderedList();
481
  }
482
483
  public void insert_ordered_list() {
484
    getActiveTextEditor().orderedList();
485
  }
486
487
  public void insert_horizontal_rule() {
488
    getActiveTextEditor().horizontalRule();
489
  }
490
491
  public void definition_create() {
492
    getActiveTextDefinition().createDefinition();
493
  }
494
495
  public void definition_rename() {
496
    getActiveTextDefinition().renameDefinition();
497
  }
498
499
  public void definition_delete() {
500
    getActiveTextDefinition().deleteDefinitions();
501
  }
502
503
  public void definition_autoinsert() {
504
    getMainPane().autoinsert();
505
  }
506
507
  public void view_refresh() {
508
    getMainPane().viewRefresh();
509
  }
510
511
  public void view_preview() {
512
    getMainPane().viewPreview();
513
  }
514
515
  public void view_outline() {
516
    getMainPane().viewOutline();
517
  }
518
519
  public void view_files() {getMainPane().viewFiles();}
520
521
  public void view_statistics() {
522
    getMainPane().viewStatistics();
523
  }
524
525
  public void view_menubar() {
526
    getMainScene().toggleMenuBar();
527
  }
528
529
  public void view_toolbar() {
530
    getMainScene().toggleToolBar();
531
  }
532
533
  public void view_statusbar() {
534
    getMainScene().toggleStatusBar();
535
  }
536
537
  public void view_log() {
538
    mLogView.view();
539
  }
540
541
  public void help_about() {
542
    final var alert = new Alert( INFORMATION );
543
    final var prefix = "Dialog.about.";
544
    alert.setTitle( get( prefix + "title", APP_TITLE ) );
545
    alert.setHeaderText( get( prefix + "header", APP_TITLE ) );
546
    alert.setContentText( get( prefix + "content", APP_YEAR, APP_VERSION ) );
547
    alert.setGraphic( ICON_DIALOG_NODE );
548
    alert.initOwner( getWindow() );
549
    alert.showAndWait();
550
  }
551
552
  /**
553
   * Concatenates all the files in the same directory as the given file into
554
   * a string. The extension is determined by the given file name pattern; the
555
   * order files are concatenated is based on their numeric sort order (this
556
   * avoids lexicographic sorting).
557
   * <p>
558
   * If the parent path to the file being edited in the text editor cannot
559
   * be found then this will return the editor's text, without iterating through
560
   * the parent directory. (Should never happen, but who knows?)
561
   * </p>
562
   * <p>
563
   * New lines are automatically appended to separate each file.
564
   * </p>
565
   *
566
   * @param editor The text editor containing
567
   * @return All files in the same directory as the file being edited
568
   * concatenated into a single string.
569
   */
570
  private String append( final TextEditor editor ) {
571
    final var pattern = editor.getPath();
572
    final var parent = pattern.getParent();
573
574
    // Short-circuit because nothing else can be done.
575
    if( parent == null ) {
576
      clue( "Main.status.export.concat.parent", pattern );
577
      return editor.getText();
578
    }
579
580
    final var filename = pattern.getFileName().toString();
581
    final var extension = getExtension( filename );
582
583
    if( extension.isBlank() ) {
584
      clue( "Main.status.export.concat.extension", filename );
585
      return editor.getText();
586
    }
587
588
    try {
589
      final var glob = "**/*." + extension;
590
      final var files = new ArrayList<Path>();
591
      final var text = new StringBuilder( DOCUMENT_LENGTH );
592
      final var range = getString( KEY_TYPESET_CONTEXT_CHAPTERS );
593
      final var validator = new RangeValidator( range );
594
      final var chapter = new AtomicInteger();
595
596
      walk( parent, glob, files::add );
597
      files.sort( new AlphanumComparator<>() );
598
      files.forEach( file -> {
599
        try {
600
          clue( "Main.status.export.concat", file );
601
602
          if( validator.test( chapter.incrementAndGet() ) ) {
603
            text.append( readString( file ) );
604
          }
605
        } catch( final IOException ex ) {
606
          clue( "Main.status.export.concat.io", file );
607
        }
608
      } );
609
610
      return text.toString();
611
    } catch( final Throwable t ) {
612
      clue( t );
613
      return editor.getText();
614
    }
615
  }
616
617
  private Optional<List<File>> pickFiles( final SelectionType type ) {
618
    return createPicker( type ).choose();
619
  }
620
621
  @SuppressWarnings( "SameParameterValue" )
622
  private Optional<List<File>> pickFile(
623
    final File filename, final SelectionType type ) {
624
    final var picker = createPicker( type );
625
    picker.setInitialFilename( filename );
7
import com.keenwrite.editors.TextDefinition;
8
import com.keenwrite.editors.TextEditor;
9
import com.keenwrite.editors.markdown.HyperlinkModel;
10
import com.keenwrite.editors.markdown.LinkVisitor;
11
import com.keenwrite.events.CaretMovedEvent;
12
import com.keenwrite.events.ExportFailedEvent;
13
import com.keenwrite.preferences.Key;
14
import com.keenwrite.preferences.PreferencesController;
15
import com.keenwrite.preferences.Workspace;
16
import com.keenwrite.processors.markdown.MarkdownProcessor;
17
import com.keenwrite.search.SearchModel;
18
import com.keenwrite.typesetting.Typesetter;
19
import com.keenwrite.ui.controls.SearchBar;
20
import com.keenwrite.ui.dialogs.ExportDialog;
21
import com.keenwrite.ui.dialogs.ExportSettings;
22
import com.keenwrite.ui.dialogs.ImageDialog;
23
import com.keenwrite.ui.dialogs.LinkDialog;
24
import com.keenwrite.ui.explorer.FilePicker;
25
import com.keenwrite.ui.explorer.FilePickerFactory;
26
import com.keenwrite.ui.logging.LogView;
27
import com.keenwrite.util.AlphanumComparator;
28
import com.keenwrite.util.RangeValidator;
29
import com.vladsch.flexmark.ast.Link;
30
import javafx.concurrent.Task;
31
import javafx.scene.control.Alert;
32
import javafx.scene.control.Dialog;
33
import javafx.stage.Window;
34
import javafx.stage.WindowEvent;
35
36
import java.io.File;
37
import java.io.IOException;
38
import java.nio.file.Path;
39
import java.util.ArrayList;
40
import java.util.List;
41
import java.util.Optional;
42
import java.util.concurrent.ExecutorService;
43
import java.util.concurrent.atomic.AtomicInteger;
44
45
import static com.keenwrite.Bootstrap.*;
46
import static com.keenwrite.ExportFormat.*;
47
import static com.keenwrite.Messages.get;
48
import static com.keenwrite.constants.Constants.PDF_DEFAULT;
49
import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG_NODE;
50
import static com.keenwrite.events.StatusEvent.clue;
51
import static com.keenwrite.preferences.AppKeys.*;
52
import static com.keenwrite.processors.ProcessorFactory.createProcessors;
53
import static com.keenwrite.ui.explorer.FilePickerFactory.SelectionType;
54
import static com.keenwrite.ui.explorer.FilePickerFactory.SelectionType.*;
55
import static com.keenwrite.util.FileWalker.walk;
56
import static java.nio.file.Files.readString;
57
import static java.nio.file.Files.writeString;
58
import static java.util.concurrent.Executors.newFixedThreadPool;
59
import static javafx.application.Platform.runLater;
60
import static javafx.event.Event.fireEvent;
61
import static javafx.scene.control.Alert.AlertType.INFORMATION;
62
import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST;
63
import static org.apache.commons.io.FilenameUtils.getExtension;
64
65
/**
66
 * Responsible for abstracting how functionality is mapped to the application.
67
 * This allows users to customize accelerator keys and will provide pluggable
68
 * functionality so that different text markup languages can change documents
69
 * using their respective syntax.
70
 */
71
public final class GuiCommands {
72
  private static final ExecutorService sExecutor = newFixedThreadPool( 1 );
73
74
  private static final String STYLE_SEARCH = "search";
75
76
  /**
77
   * Sci-fi genres, which are can be longer than other genres, typically fall
78
   * below 150,000 words at 6 chars per word. This reduces re-allocations of
79
   * memory when concatenating files together when exporting novels.
80
   */
81
  private static final int DOCUMENT_LENGTH = 150_000 * 6;
82
83
  /**
84
   * When an action is executed, this is one of the recipients.
85
   */
86
  private final MainPane mMainPane;
87
88
  private final MainScene mMainScene;
89
90
  private final LogView mLogView;
91
92
  /**
93
   * Tracks finding text in the active document.
94
   */
95
  private final SearchModel mSearchModel;
96
97
  public GuiCommands( final MainScene scene, final MainPane pane ) {
98
    mMainScene = scene;
99
    mMainPane = pane;
100
    mLogView = new LogView();
101
    mSearchModel = new SearchModel();
102
    mSearchModel.matchOffsetProperty().addListener( ( c, o, n ) -> {
103
      final var editor = getActiveTextEditor();
104
105
      // Clear highlighted areas before highlighting a new region.
106
      if( o != null ) {
107
        editor.unstylize( STYLE_SEARCH );
108
      }
109
110
      if( n != null ) {
111
        editor.moveTo( n.getStart() );
112
        editor.stylize( n, STYLE_SEARCH );
113
      }
114
    } );
115
116
    // When the active text editor changes ...
117
    mMainPane.textEditorProperty().addListener(
118
      ( c, o, n ) -> {
119
        // ... update the haystack.
120
        mSearchModel.search( getActiveTextEditor().getText() );
121
122
        // ... update the status bar with the current caret position.
123
        if( n != null ) {
124
          final var w = getWorkspace();
125
          final var recentDoc =  w.fileProperty( KEY_UI_RECENT_DOCUMENT );
126
127
          // ... preserve the most recent document.
128
          recentDoc.setValue( n.getFile() );
129
          CaretMovedEvent.fire( n.getCaret() );
130
        }
131
      }
132
    );
133
  }
134
135
  public void file_new() {
136
    getMainPane().newTextEditor();
137
  }
138
139
  public void file_open() {
140
    pickFiles( FILE_OPEN_MULTIPLE ).ifPresent( l -> getMainPane().open( l ) );
141
  }
142
143
  public void file_close() {
144
    getMainPane().close();
145
  }
146
147
  public void file_close_all() {
148
    getMainPane().closeAll();
149
  }
150
151
  public void file_save() {
152
    getMainPane().save();
153
  }
154
155
  public void file_save_as() {
156
    pickFiles( FILE_SAVE_AS ).ifPresent( l -> getMainPane().saveAs( l ) );
157
  }
158
159
  public void file_save_all() {
160
    getMainPane().saveAll();
161
  }
162
163
  /**
164
   * Converts the actively edited file in the given file format.
165
   *
166
   * @param format The destination file format.
167
   */
168
  private void file_export( final ExportFormat format ) {
169
    file_export( format, false );
170
  }
171
172
  /**
173
   * Converts one or more files into the given file format. If {@code dir}
174
   * is set to true, this will first append all files in the same directory
175
   * as the actively edited file.
176
   *
177
   * @param format The destination file format.
178
   * @param dir    Export all files in the actively edited file's directory.
179
   */
180
  private void file_export( final ExportFormat format, final boolean dir ) {
181
    final var main = getMainPane();
182
    final var editor = main.getTextEditor();
183
    final var exported = getWorkspace().fileProperty( KEY_UI_RECENT_EXPORT );
184
    final var filename = format.toExportFilename( editor.getPath() );
185
    final var selected = PDF_DEFAULT.getName().equals( exported.get().getName() );
186
    final var selection = pickFile(
187
      selected ? filename : exported.get(),
188
      exported.get().toPath().getParent(),
189
      FILE_EXPORT
190
    );
191
192
    selection.ifPresent( files -> {
193
      editor.save();
194
195
      final var file = files.get( 0 );
196
      final var path = file.toPath();
197
      final var document = dir ? append( editor ) : editor.getText();
198
      final var context = main.createProcessorContext( path, format );
199
200
      final var task = new Task<Path>() {
201
        @Override
202
        protected Path call() throws Exception {
203
          final var chain = createProcessors( context );
204
          final var export = chain.apply( document );
205
206
          // Processors can export binary files. In such cases, processors
207
          // return null to prevent further processing.
208
          return export == null ? null : writeString( path, export );
209
        }
210
      };
211
212
      task.setOnSucceeded(
213
        e -> {
214
          // Remember the exported file name for next time.
215
          exported.setValue( file );
216
217
          final var result = task.getValue();
218
219
          // Binary formats must notify users of success independently.
220
          if( result != null ) {
221
            clue( "Main.status.export.success", result );
222
          }
223
        }
224
      );
225
226
      task.setOnFailed( e -> {
227
        final var ex = task.getException();
228
        clue( ex );
229
230
        if( ex instanceof TypeNotPresentException ) {
231
          fireExportFailedEvent();
232
        }
233
      } );
234
235
      sExecutor.execute( task );
236
    } );
237
  }
238
239
  /**
240
   * @param dir {@code true} means to export all files in the active file
241
   *            editor's directory; {@code false} means to export only the
242
   *            actively edited file.
243
   */
244
  private void file_export_pdf( final boolean dir ) {
245
    final var workspace = getWorkspace();
246
    final var themes = workspace.getFile(
247
      KEY_TYPESET_CONTEXT_THEMES_PATH
248
    );
249
    final var theme = workspace.stringProperty(
250
      KEY_TYPESET_CONTEXT_THEME_SELECTION
251
    );
252
    final var chapters = workspace.stringProperty(
253
      KEY_TYPESET_CONTEXT_CHAPTERS
254
    );
255
    final var settings = ExportSettings
256
      .builder()
257
      .with( ExportSettings.Mutator::setTheme, theme )
258
      .with( ExportSettings.Mutator::setChapters, chapters )
259
      .build();
260
261
    if( Typesetter.canRun() ) {
262
      // If the typesetter is installed, allow the user to select a theme. If
263
      // the themes aren't installed, a status message will appear.
264
      if( ExportDialog.choose( getWindow(), themes, settings, dir ) ) {
265
        file_export( APPLICATION_PDF, dir );
266
      }
267
    }
268
    else {
269
      fireExportFailedEvent();
270
    }
271
  }
272
273
  public void file_export_pdf() {
274
    file_export_pdf( false );
275
  }
276
277
  public void file_export_pdf_dir() {
278
    file_export_pdf( true );
279
  }
280
281
  public void file_export_html_svg() {
282
    file_export( HTML_TEX_SVG );
283
  }
284
285
  public void file_export_html_tex() {
286
    file_export( HTML_TEX_DELIMITED );
287
  }
288
289
  public void file_export_xhtml_tex() {
290
    file_export( XHTML_TEX );
291
  }
292
293
  private void fireExportFailedEvent() {
294
    runLater( ExportFailedEvent::fire );
295
  }
296
297
  public void file_exit() {
298
    final var window = getWindow();
299
    fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) );
300
  }
301
302
  public void edit_undo() {
303
    getActiveTextEditor().undo();
304
  }
305
306
  public void edit_redo() {
307
    getActiveTextEditor().redo();
308
  }
309
310
  public void edit_cut() {
311
    getActiveTextEditor().cut();
312
  }
313
314
  public void edit_copy() {
315
    getActiveTextEditor().copy();
316
  }
317
318
  public void edit_paste() {
319
    getActiveTextEditor().paste();
320
  }
321
322
  public void edit_select_all() {
323
    getActiveTextEditor().selectAll();
324
  }
325
326
  public void edit_find() {
327
    final var nodes = getMainScene().getStatusBar().getLeftItems();
328
329
    if( nodes.isEmpty() ) {
330
      final var searchBar = new SearchBar();
331
332
      searchBar.matchIndexProperty().bind( mSearchModel.matchIndexProperty() );
333
      searchBar.matchCountProperty().bind( mSearchModel.matchCountProperty() );
334
335
      searchBar.setOnCancelAction( event -> {
336
        final var editor = getActiveTextEditor();
337
        nodes.remove( searchBar );
338
        editor.unstylize( STYLE_SEARCH );
339
        editor.getNode().requestFocus();
340
      } );
341
342
      searchBar.addInputListener( ( c, o, n ) -> {
343
        if( n != null && !n.isEmpty() ) {
344
          mSearchModel.search( n, getActiveTextEditor().getText() );
345
        }
346
      } );
347
348
      searchBar.setOnNextAction( event -> edit_find_next() );
349
      searchBar.setOnPrevAction( event -> edit_find_prev() );
350
351
      nodes.add( searchBar );
352
      searchBar.requestFocus();
353
    }
354
    else {
355
      nodes.clear();
356
    }
357
  }
358
359
  public void edit_find_next() {
360
    mSearchModel.advance();
361
  }
362
363
  public void edit_find_prev() {
364
    mSearchModel.retreat();
365
  }
366
367
  public void edit_preferences() {
368
    try {
369
      new PreferencesController( getWorkspace() ).show();
370
    } catch( final Exception ex ) {
371
      clue( ex );
372
    }
373
  }
374
375
  public void format_bold() {
376
    getActiveTextEditor().bold();
377
  }
378
379
  public void format_italic() {
380
    getActiveTextEditor().italic();
381
  }
382
383
  public void format_monospace() {
384
    getActiveTextEditor().monospace();
385
  }
386
387
  public void format_superscript() {
388
    getActiveTextEditor().superscript();
389
  }
390
391
  public void format_subscript() {
392
    getActiveTextEditor().subscript();
393
  }
394
395
  public void format_strikethrough() {
396
    getActiveTextEditor().strikethrough();
397
  }
398
399
  public void insert_blockquote() {
400
    getActiveTextEditor().blockquote();
401
  }
402
403
  public void insert_code() {
404
    getActiveTextEditor().code();
405
  }
406
407
  public void insert_fenced_code_block() {
408
    getActiveTextEditor().fencedCodeBlock();
409
  }
410
411
  public void insert_link() {
412
    insertObject( createLinkDialog() );
413
  }
414
415
  public void insert_image() {
416
    insertObject( createImageDialog() );
417
  }
418
419
  private void insertObject( final Dialog<String> dialog ) {
420
    final var textArea = getActiveTextEditor().getTextArea();
421
    dialog.showAndWait().ifPresent( textArea::replaceSelection );
422
  }
423
424
  private Dialog<String> createLinkDialog() {
425
    return new LinkDialog( getWindow(), createHyperlinkModel() );
426
  }
427
428
  private Dialog<String> createImageDialog() {
429
    final var path = getActiveTextEditor().getPath();
430
    final var parentDir = path.getParent();
431
    return new ImageDialog( getWindow(), parentDir );
432
  }
433
434
  /**
435
   * Returns one of: selected text, word under cursor, or parsed hyperlink from
436
   * the Markdown AST.
437
   *
438
   * @return An instance containing the link URL and display text.
439
   */
440
  private HyperlinkModel createHyperlinkModel() {
441
    final var context = getMainPane().createProcessorContext();
442
    final var editor = getActiveTextEditor();
443
    final var textArea = editor.getTextArea();
444
    final var selectedText = textArea.getSelectedText();
445
446
    // Convert current paragraph to Markdown nodes.
447
    final var mp = MarkdownProcessor.create( context );
448
    final var p = textArea.getCurrentParagraph();
449
    final var paragraph = textArea.getText( p );
450
    final var node = mp.toNode( paragraph );
451
    final var visitor = new LinkVisitor( textArea.getCaretColumn() );
452
    final var link = visitor.process( node );
453
454
    if( link != null ) {
455
      textArea.selectRange( p, link.getStartOffset(), p, link.getEndOffset() );
456
    }
457
458
    return createHyperlinkModel( link, selectedText );
459
  }
460
461
  private HyperlinkModel createHyperlinkModel(
462
    final Link link, final String selection ) {
463
464
    return link == null
465
      ? new HyperlinkModel( selection, "https://localhost" )
466
      : new HyperlinkModel( link );
467
  }
468
469
  public void insert_heading_1() {
470
    insert_heading( 1 );
471
  }
472
473
  public void insert_heading_2() {
474
    insert_heading( 2 );
475
  }
476
477
  public void insert_heading_3() {
478
    insert_heading( 3 );
479
  }
480
481
  private void insert_heading( final int level ) {
482
    getActiveTextEditor().heading( level );
483
  }
484
485
  public void insert_unordered_list() {
486
    getActiveTextEditor().unorderedList();
487
  }
488
489
  public void insert_ordered_list() {
490
    getActiveTextEditor().orderedList();
491
  }
492
493
  public void insert_horizontal_rule() {
494
    getActiveTextEditor().horizontalRule();
495
  }
496
497
  public void definition_create() {
498
    getActiveTextDefinition().createDefinition();
499
  }
500
501
  public void definition_rename() {
502
    getActiveTextDefinition().renameDefinition();
503
  }
504
505
  public void definition_delete() {
506
    getActiveTextDefinition().deleteDefinitions();
507
  }
508
509
  public void definition_autoinsert() {
510
    getMainPane().autoinsert();
511
  }
512
513
  public void view_refresh() {
514
    getMainPane().viewRefresh();
515
  }
516
517
  public void view_preview() {
518
    getMainPane().viewPreview();
519
  }
520
521
  public void view_outline() {
522
    getMainPane().viewOutline();
523
  }
524
525
  public void view_files() {getMainPane().viewFiles();}
526
527
  public void view_statistics() {
528
    getMainPane().viewStatistics();
529
  }
530
531
  public void view_menubar() {
532
    getMainScene().toggleMenuBar();
533
  }
534
535
  public void view_toolbar() {
536
    getMainScene().toggleToolBar();
537
  }
538
539
  public void view_statusbar() {
540
    getMainScene().toggleStatusBar();
541
  }
542
543
  public void view_log() {
544
    mLogView.view();
545
  }
546
547
  public void help_about() {
548
    final var alert = new Alert( INFORMATION );
549
    final var prefix = "Dialog.about.";
550
    alert.setTitle( get( prefix + "title", APP_TITLE ) );
551
    alert.setHeaderText( get( prefix + "header", APP_TITLE ) );
552
    alert.setContentText( get( prefix + "content", APP_YEAR, APP_VERSION ) );
553
    alert.setGraphic( ICON_DIALOG_NODE );
554
    alert.initOwner( getWindow() );
555
    alert.showAndWait();
556
  }
557
558
  /**
559
   * Concatenates all the files in the same directory as the given file into
560
   * a string. The extension is determined by the given file name pattern; the
561
   * order files are concatenated is based on their numeric sort order (this
562
   * avoids lexicographic sorting).
563
   * <p>
564
   * If the parent path to the file being edited in the text editor cannot
565
   * be found then this will return the editor's text, without iterating through
566
   * the parent directory. (Should never happen, but who knows?)
567
   * </p>
568
   * <p>
569
   * New lines are automatically appended to separate each file.
570
   * </p>
571
   *
572
   * @param editor The text editor containing
573
   * @return All files in the same directory as the file being edited
574
   * concatenated into a single string.
575
   */
576
  private String append( final TextEditor editor ) {
577
    final var pattern = editor.getPath();
578
    final var parent = pattern.getParent();
579
580
    // Short-circuit because nothing else can be done.
581
    if( parent == null ) {
582
      clue( "Main.status.export.concat.parent", pattern );
583
      return editor.getText();
584
    }
585
586
    final var filename = pattern.getFileName().toString();
587
    final var extension = getExtension( filename );
588
589
    if( extension.isBlank() ) {
590
      clue( "Main.status.export.concat.extension", filename );
591
      return editor.getText();
592
    }
593
594
    try {
595
      final var glob = "**/*." + extension;
596
      final var files = new ArrayList<Path>();
597
      final var text = new StringBuilder( DOCUMENT_LENGTH );
598
      final var range = getString( KEY_TYPESET_CONTEXT_CHAPTERS );
599
      final var validator = new RangeValidator( range );
600
      final var chapter = new AtomicInteger();
601
602
      walk( parent, glob, files::add );
603
      files.sort( new AlphanumComparator<>() );
604
      files.forEach( file -> {
605
        try {
606
          clue( "Main.status.export.concat", file );
607
608
          if( validator.test( chapter.incrementAndGet() ) ) {
609
            text.append( readString( file ) );
610
          }
611
        } catch( final IOException ex ) {
612
          clue( "Main.status.export.concat.io", file );
613
        }
614
      } );
615
616
      return text.toString();
617
    } catch( final Throwable t ) {
618
      clue( t );
619
      return editor.getText();
620
    }
621
  }
622
623
  private Optional<List<File>> pickFiles( final SelectionType type ) {
624
    return createPicker( type ).choose();
625
  }
626
627
  @SuppressWarnings( "SameParameterValue" )
628
  private Optional<List<File>> pickFile(
629
    final File file,
630
    final Path directory,
631
    final SelectionType type ) {
632
    final var picker = createPicker( type );
633
    picker.setInitialFilename( file );
634
    picker.setInitialDirectory( directory );
626635
    return picker.choose();
627636
  }
M src/main/java/com/keenwrite/ui/dialogs/ExportDialog.java
66
import com.keenwrite.util.ResourceWalker;
77
import javafx.geometry.Insets;
8
import javafx.scene.Node;
89
import javafx.scene.control.ComboBox;
910
import javafx.scene.control.Label;
...
2425
import java.util.Properties;
2526
import java.util.TreeMap;
27
import java.util.concurrent.atomic.AtomicReference;
2628
2729
import static com.keenwrite.Messages.get;
...
7577
7678
    var title = "Dialog.typesetting.settings.header.";
79
    final var focusNode = new AtomicReference<Node>( mComboBox );
7780
7881
    if( multiple ) {
79
      mChapters.setText( mSettings.chaptersProperty().get() );
8082
      mPane.add( createLabel( "Dialog.typesetting.settings.chapters" ), 0, 2 );
8183
      mPane.add( mChapters, 1, 2 );
8284
85
      focusNode.set( mChapters );
8386
      title += "multiple";
8487
    }
8588
    else {
8689
      title += "single";
8790
    }
91
92
    // Remember the chapter range regardless of text field visibility.
93
    mChapters.textProperty().bindBidirectional( mSettings.chaptersProperty() );
8894
8995
    setHeaderText( get( title ) );
9096
9197
    final var dialogPane = getDialogPane();
9298
    dialogPane.setContent( mPane );
9399
94
    runLater( () -> mComboBox.requestFocus() );
100
    runLater( () -> focusNode.get().requestFocus() );
95101
  }
96102
...
129135
      if( result.isPresent() ) {
130136
        final var theme = mComboBox.getSelectionModel().getSelectedItem();
131
        mSettings.themeProperty().set( theme.toLowerCase() );
132
        mSettings.chaptersProperty().set( mChapters.getText() );
137
        mSettings.themeProperty().setValue( theme.toLowerCase() );
133138
134139
        return true;
...
148153
    mPane = createContentPane();
149154
    mComboBox = createComboBox();
150
    mComboBox.setOnKeyPressed( ( event ) -> {
155
    mComboBox.setOnKeyPressed( event -> {
151156
      // When the user presses the down arrow, open the drop-down. This
152157
      // prevents navigating to the cancel button.
...
205210
206211
      // Populate the choices with themes detected on the system.
207
      walk( themesDir.toPath(), "**/theme.properties", ( path ) -> {
212
      walk( themesDir.toPath(), "**/theme.properties", path -> {
208213
        try {
209214
          final var displayed = readThemeName( path );
M src/main/java/com/keenwrite/ui/dialogs/ImageDialog.java
7070
7171
    setResultConverter( dialogButton -> {
72
      ButtonData data = (dialogButton != null) ? dialogButton.getButtonData() : null;
73
      return (data == ButtonData.OK_DONE) ? image.get() : null;
72
      ButtonData data = dialogButton != null ? dialogButton.getButtonData() : null;
73
      return data == ButtonData.OK_DONE ? image.get() : null;
7474
    } );
7575
M src/main/java/com/keenwrite/ui/dialogs/LinkDialog.java
7171
7272
    setResultConverter( dialogButton -> {
73
      ButtonData data = (dialogButton != null) ? dialogButton.getButtonData() : null;
74
      return (data == ButtonData.OK_DONE) ? link.get() : null;
73
      ButtonData data = dialogButton != null ? dialogButton.getButtonData() : null;
74
      return data == ButtonData.OK_DONE ? link.get() : null;
7575
    } );
7676
M src/main/java/com/keenwrite/ui/explorer/FilePickerFactory.java
9898
    public Optional<List<File>> choose() {
9999
      if( mType == FILE_OPEN_MULTIPLE ) {
100
        return Optional.of( mChooser.showOpenMultipleDialog( mOwner ) );
100
        return Optional.ofNullable( mChooser.showOpenMultipleDialog( mOwner ) );
101101
      }
102102
D src/main/java/com/keenwrite/util/ArrayScanner.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.util;
3
4
import static java.lang.Math.max;
5
6
/**
7
 * Scans an array (haystack) for a particular value (needle).
8
 *
9
 * <p>
10
 * This class is {@code null}-hostile.
11
 */
12
public class ArrayScanner {
13
14
  /**
15
   * The index value returned when an element is not found in an array.
16
   */
17
  public static final int MISSING = -1;
18
19
  /**
20
   * Finds the index of the given needle in the haystack.
21
   *
22
   * @param haystack The haystack to search through for the needle.
23
   * @param needle   The needle to find in the haystack.
24
   * @return Index of the needle within the haystack, or {@link #MISSING}
25
   * if not found.
26
   */
27
  public static int indexOf( final Object[] haystack, final Object needle ) {
28
    assert haystack != null;
29
    assert needle != null;
30
31
    return indexOf( haystack, needle, 0 );
32
  }
33
34
  /**
35
   * Finds the index of the given needle in the haystack.
36
   *
37
   * @param haystack The haystack to search through for the needle.
38
   * @param needle   The needle to find in the haystack.
39
   * @param offset   The starting offset into the haystack to begin looking
40
   *                 (the value may be greater than or less than the number
41
   *                 of array elements).
42
   * @return Index of the needle within the haystack, or {@link #MISSING}
43
   * if not found.
44
   */
45
  public static int indexOf(
46
    final Object[] haystack, final Object needle, int offset ) {
47
    assert haystack != null;
48
    assert needle != null;
49
50
    for( int i = max( 0, offset ); i < haystack.length; i++ ) {
51
      if( needle.equals( haystack[ i ] ) ) {
52
        return i;
53
      }
54
    }
55
56
    return MISSING;
57
  }
58
59
  /**
60
   * Checks if the object is in the given array.
61
   *
62
   * @param haystack The haystack to search through for the needle.
63
   * @param needle   The needle to find in the haystack.
64
   * @return {@code true} if the array contains the object.
65
   */
66
  public static boolean contains(
67
    final Object[] haystack, final Object needle ) {
68
    assert haystack != null;
69
    assert needle != null;
70
71
    return indexOf( haystack, needle ) != MISSING;
72
  }
73
}
741
D src/main/java/com/keenwrite/util/Pair.java
1
package com.keenwrite.util;
2
3
import java.util.AbstractMap;
4
import java.util.Map;
5
6
/**
7
 * Convenience class for pairing two objects together; this is a synonym for
8
 * {@link Map.Entry}.
9
 *
10
 * @param <K> The type of key to store in this pair.
11
 * @param <V> The type of value to store in this pair.
12
 */
13
public class Pair<K, V> extends AbstractMap.SimpleImmutableEntry<K, V> {
14
  /**
15
   * Associates a new key-value pair.
16
   *
17
   * @param key   The key for this key-value pairing.
18
   * @param value The value for this key-value pairing.
19
   */
20
  public Pair( final K key, final V value ) {
21
    super( key, value );
22
  }
23
}
241
M src/main/resources/com/keenwrite/settings.properties
1
# suppress inspection "UnusedProperty" for whole file
2
13
# ########################################################################
24
# Application
M src/test/java/com/keenwrite/io/FileWatchServiceTest.java
3737
    final var thread = new Thread( service );
3838
    final var semaphor = new Semaphore( 0 );
39
    final var listener = createListener( ( f ) -> {
39
    final var listener = createListener( f -> {
4040
      semaphor.release();
4141
      assertEquals( file, f );
M src/test/java/com/keenwrite/r/PluralizeTest.java
2020
public class PluralizeTest {
2121
  private static final ScriptEngine ENGINE =
22
      (new ScriptEngineManager()).getEngineByName( "Renjin" );
22
      new ScriptEngineManager().getEngineByName( "Renjin" );
2323
2424
  private static final Map<String, String> PLURAL_MAP = ofEntries(
M src/test/java/com/keenwrite/richtext/StyleClassedTextAreaTest.java
55
import javafx.scene.layout.StackPane;
66
import javafx.stage.Stage;
7
import org.fxmisc.flowless.VirtualizedScrollPane;
8
import org.fxmisc.richtext.StyleClassedTextArea;
9
10
import java.net.URISyntaxException;
711
812
/**
913
 * Scaffolding for creating one-off tests, not run as part of test suite.
1014
 */
1115
public class StyleClassedTextAreaTest extends Application {
12
  private final org.fxmisc.richtext.StyleClassedTextArea mTextArea =
13
    new org.fxmisc.richtext.StyleClassedTextArea( false );
16
  private final StyleClassedTextArea mTextArea =
17
    new StyleClassedTextArea( false );
18
19
  private final VirtualizedScrollPane<StyleClassedTextArea> mScrollPane =
20
    new VirtualizedScrollPane<>( mTextArea );
1421
1522
  public static void main( final String[] args ) {
1623
    launch( args );
1724
  }
1825
1926
  @Override
20
  public void start( final Stage stage ) {
21
    final var pane = new StackPane( mTextArea );
22
    final var scene = new Scene( pane, 600, 400 );
27
  public void start( final Stage stage ) throws URISyntaxException {
28
    final var pane = new StackPane( mScrollPane );
29
    final var scene = new Scene( pane, 800, 600 );
30
31
    final var stylesheets = scene.getStylesheets();
32
    stylesheets.clear();
33
    stylesheets.add( getStylesheet( "skins/scene.css" ) );
34
    stylesheets.add( getStylesheet( "editor/markdown.css" ) );
35
    stylesheets.add( getStylesheet( "skins/monokai.css" ) );
36
37
    mTextArea.getStyleClass().add( "markdown" );
38
    mTextArea.insertText( 0, TEXT + TEXT + TEXT + TEXT );
39
    mTextArea.setStyle( "-fx-font-size: 13pt" );
40
41
    mTextArea.requestFollowCaret();
42
    mTextArea.moveTo( 4375 );
2343
2444
    stage.setScene( scene );
2545
    stage.show();
46
  }
47
48
  private String getStylesheet( final String suffix )
49
    throws URISyntaxException {
50
    final var url = getClass().getResource( "/com/keenwrite/" + suffix );
51
    return url == null ? "" : url.toURI().toString();
2652
  }
53
54
  private final static String TEXT = """
55
    In my younger and more vulnerable years my father gave me some advice
56
    that I’ve been turning over in my mind ever since.
57
                                                                                    
58
    “Whenever you feel like criticizing anyone,” he told me, “just
59
    remember that all the people in this world haven’t had the advantages
60
    that you’ve had.”
61
                                                                                    
62
    He didn’t say any more, but we’ve always been unusually communicative
63
    in a reserved way, and I understood that he meant a great deal more
64
    than that. In consequence, I’m inclined to reserve all judgements, a
65
    habit that has opened up many curious natures to me and also made me
66
    the victim of not a few veteran bores. The abnormal mind is quick to
67
    detect and attach itself to this quality when it appears in a normal
68
    person, and so it came about that in college I was unjustly accused of
69
    being a politician, because I was privy to the secret griefs of wild,
70
    unknown men. Most of the confidences were unsought—frequently I have
71
    feigned sleep, preoccupation, or a hostile levity when I realized by
72
    some unmistakable sign that an intimate revelation was quivering on
73
    the horizon; for the intimate revelations of young men, or at least
74
    the terms in which they express them, are usually plagiaristic and
75
    marred by obvious suppressions. Reserving judgements is a matter of
76
    infinite hope. I am still a little afraid of missing something if I
77
    forget that, as my father snobbishly suggested, and I snobbishly
78
    repeat, a sense of the fundamental decencies is parcelled out
79
    unequally at birth.""";
2780
}
2881