| 5 | 5 | build |
| 6 | 6 | .gradle |
| 7 | contacted.csv | |
| 8 | 7 | video |
| 9 | 8 | .settings |
| 10 | 9 | .classpath |
| 11 | 10 | .idea |
| 12 | 11 | themes |
| 13 | 12 | quotes |
| 13 | tex | |
| 14 | spell | |
| 14 | 15 |
| 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 | ||
| 1 | 63 |
| 10 | 10 | maven { url 'https://oss.sonatype.org/content/repositories/snapshots/' } |
| 11 | 11 | 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 | } | |
| 12 | 24 | } |
| 13 | 25 | |
| ... | ||
| 49 | 61 | def v_jackson = '2.13.3' |
| 50 | 62 | def v_batik = '1.14' |
| 63 | def v_echosvg = '0.2.1' | |
| 51 | 64 | |
| 52 | 65 | // JavaFX |
| 53 | 66 | implementation 'org.controlsfx:controlsfx:11.1.1' |
| 54 | 67 | implementation 'org.fxmisc.richtext:richtextfx:0.10.9' |
| 55 | 68 | implementation 'org.fxmisc.flowless:flowless:0.6.10' |
| 56 | 69 | implementation 'org.fxmisc.wellbehaved:wellbehavedfx:0.3.3' |
| 57 | 70 | 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' | |
| 59 | 72 | |
| 60 | 73 | // Markdown |
| ... | ||
| 74 | 87 | |
| 75 | 88 | // XML |
| 76 | implementation 'com.ximpleware:vtd-xml:2.13.4' | |
| 89 | //implementation 'com.ximpleware:vtd-xml:2.13.4' | |
| 77 | 90 | |
| 78 | 91 | // HTML parsing and rendering |
| 79 | implementation 'org.jsoup:jsoup:1.15.2' | |
| 92 | implementation 'org.jsoup:jsoup:1.15.3' | |
| 80 | 93 | // TODO: https://github.com/flyingsaucerproject/flyingsaucer/pull/170 |
| 81 | 94 | //implementation 'org.xhtmlrenderer:flying-saucer-core:9.1.22' |
| 82 | 95 | |
| 83 | 96 | // R |
| 84 | 97 | implementation 'org.renjin:renjin-script-engine:3.5-beta76' |
| 98 | implementation 'org.renjin.cran:rjson:0.2.15-renjin-21' | |
| 85 | 99 | |
| 86 | 100 | // 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}" | |
| 102 | 114 | |
| 103 | 115 | // Misc. |
| 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 | ||
| 1 | 36 |
| 1 | *.avi | |
| 2 | *.wav | |
| 3 | *.png | |
| 4 | *.mp4 | |
| 5 | *.mp3 | |
| 6 | ||
| 7 | 1 |
| 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> | |
| 117 | 1 |
| 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 | ||
| 1 | 51 |
| 8 | 8 | import java.util.Properties; |
| 9 | 9 | |
| 10 | import static org.apache.batik.util.ParsedURL.setGlobalUserAgent; | |
| 11 | ||
| 12 | 10 | /** |
| 13 | 11 | * 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 | |
| 15 | 13 | * namespace to avoid hard-coding the application name in many places. |
| 16 | 14 | * 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. | |
| 18 | 16 | * <p> |
| 19 | 17 | * See {@link Constants#PATH_PROPERTIES_SETTINGS} for details. |
| ... | ||
| 40 | 38 | |
| 41 | 39 | static { |
| 40 | // This also sets the user agent for the SVG rendering library. | |
| 42 | 41 | System.setProperty( "http.agent", APP_TITLE + " " + APP_VERSION ); |
| 43 | setGlobalUserAgent( System.getProperty( "http.agent" ) ); | |
| 44 | 42 | } |
| 45 | 43 | |
| 203 | 203 | register( this ); |
| 204 | 204 | 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 | } ); | |
| 1080 | 1131 | |
| 1081 | 1132 | // Set the active editor, which refreshes the preview panel. |
| 31 | 31 | |
| 32 | 32 | /** |
| 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. | |
| 34 | 34 | */ |
| 35 | 35 | public final class MainScene { |
| ... | ||
| 168 | 168 | } |
| 169 | 169 | |
| 170 | private GuiCommands createApplicationActions( | |
| 171 | final MainPane mainPane ) { | |
| 170 | private GuiCommands createApplicationActions( final MainPane mainPane ) { | |
| 172 | 171 | return new GuiCommands( this, mainPane ); |
| 173 | 172 | } |
| ... | ||
| 221 | 220 | * Binds the visible property of the node to the managed property so that |
| 222 | 221 | * 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. | |
| 224 | 223 | * |
| 225 | 224 | * @param node The node to have its real estate bound to visibility. |
| 44 | 44 | |
| 45 | 45 | public static final File DOCUMENT_DEFAULT = getFile( "document" ); |
| 46 | public static final int DOCUMENT_OFFSET = 0; | |
| 46 | 47 | public static final File DEFINITION_DEFAULT = getFile( "definition" ); |
| 47 | 48 | public static final File PDF_DEFAULT = getFile( "pdf" ); |
| 27 | 27 | |
| 28 | 28 | /** |
| 29 | * Delegates requesting focus to the internal {@link StyleClassedTextArea}. | |
| 30 | */ | |
| 31 | default void requestFocus() { | |
| 32 | getTextArea().requestFocus(); | |
| 33 | } | |
| 34 | ||
| 35 | /** | |
| 29 | 36 | * Returns the complete text for the specified paragraph index. |
| 30 | 37 | * |
| 70 | 70 | } |
| 71 | 71 | |
| 72 | public final void setText( final String text ) { | |
| 72 | public void setText( final String text ) { | |
| 73 | 73 | this.text = sanitize( text ); |
| 74 | 74 | } |
| 75 | 75 | |
| 76 | public final void setUrl( final String url ) { | |
| 76 | public void setUrl( final String url ) { | |
| 77 | 77 | this.url = sanitize( url ); |
| 78 | 78 | } |
| 79 | 79 | |
| 80 | public final void setTitle( final String title ) { | |
| 80 | public void setTitle( final String title ) { | |
| 81 | 81 | this.title = sanitize( title ); |
| 82 | 82 | } |
| 439 | 439 | // This won't remove the highlighting if the caret position moves by mouse. |
| 440 | 440 | final var handler = mTextArea.getOnKeyPressed(); |
| 441 | mTextArea.setOnKeyPressed( ( event ) -> { | |
| 441 | mTextArea.setOnKeyPressed( event -> { | |
| 442 | 442 | mTextArea.setOnKeyPressed( handler ); |
| 443 | 443 | unstylize( style ); |
| ... | ||
| 616 | 616 | |
| 617 | 617 | selection.lines().forEach( |
| 618 | ( l ) -> sb.append( "\t" ).append( l ).append( NEWLINE ) | |
| 618 | l -> sb.append( "\t" ).append( l ).append( NEWLINE ) | |
| 619 | 619 | ); |
| 620 | 620 | } |
| ... | ||
| 634 | 634 | |
| 635 | 635 | 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 ) | |
| 638 | 638 | ); |
| 639 | 639 | |
| ... | ||
| 684 | 684 | int length = range.getLength(); |
| 685 | 685 | text = stripStart( text, null ); |
| 686 | final int beganIndex = range.getStart() + (length - text.length()); | |
| 686 | final int beganIndex = range.getStart() + length - text.length(); | |
| 687 | 687 | |
| 688 | 688 | length = text.length(); |
| 169 | 169 | * |
| 170 | 170 | * @param listener The {@link FileModifiedListener} to remove. |
| 171 | * @return {@code true} if this contained the given listener. | |
| 172 | 171 | */ |
| 173 | public boolean removeListener( final FileModifiedListener listener ) { | |
| 174 | return mListeners.remove( listener ); | |
| 172 | public void removeListener( final FileModifiedListener listener ) { | |
| 173 | mListeners.remove( listener ); | |
| 175 | 174 | } |
| 176 | 175 |
| 46 | 46 | public static final Key KEY_UI_RECENT_DIR = key( KEY_UI_RECENT, "dir" ); |
| 47 | 47 | 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" ); | |
| 48 | 49 | public static final Key KEY_UI_RECENT_DEFINITION = key( KEY_UI_RECENT, "definition" ); |
| 49 | 50 | public static final Key KEY_UI_RECENT_EXPORT = key( KEY_UI_RECENT, "export" ); |
| 66 | 66 | createFontSelectorDialog( initialFont ) |
| 67 | 67 | .showAndWait() |
| 68 | .ifPresent( ( font ) -> { | |
| 68 | .ifPresent( font -> { | |
| 69 | 69 | mFontName.setText( font.getFamily() ); |
| 70 | 70 | mFontSize.set( font.getSize() ); |
| ... | ||
| 109 | 109 | final var dialog = new FontSelectorDialog( font ); |
| 110 | 110 | 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 ); | |
| 113 | 113 | |
| 114 | 114 | buttonOk.setDefaultButton( true ); |
| 115 | 115 | buttonCancel.setCancelButton( true ); |
| 116 | pane.setOnKeyReleased( ( keyEvent ) -> { | |
| 116 | pane.setOnKeyReleased( keyEvent -> { | |
| 117 | 117 | switch( keyEvent.getCode() ) { |
| 118 | 118 | case ENTER -> buttonOk.fire(); |
| 110 | 110 | |
| 111 | 111 | table.widthProperty().addListener( ( c, o, n ) -> { |
| 112 | if( (o != null && n != null) | |
| 112 | if( o != null && n != null | |
| 113 | 113 | && o.intValue() == n.intValue() - 2 |
| 114 | 114 | && inserted.getAndSet( false ) ) { |
| 81 | 81 | |
| 82 | 82 | entry( KEY_UI_RECENT_DIR, asFileProperty( USER_DIRECTORY ) ), |
| 83 | entry( KEY_UI_RECENT_OFFSET, asIntegerProperty( DOCUMENT_OFFSET ) ), | |
| 83 | 84 | entry( KEY_UI_RECENT_DOCUMENT, asFileProperty( DOCUMENT_DEFAULT ) ), |
| 84 | 85 | entry( KEY_UI_RECENT_DEFINITION, asFileProperty( DEFINITION_DEFAULT ) ), |
| 213 | 213 | |
| 214 | 214 | for( final var item : list ) { |
| 215 | if( item instanceof Entry entry ) { | |
| 215 | if( item instanceof Entry<?, ?> entry ) { | |
| 216 | 216 | try { |
| 217 | 217 | final var child = Key.key( key, entry.getKey().toString() ); |
| 173 | 173 | |
| 174 | 174 | // 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 | |
| 176 | 176 | // high up and the most recently typed letters won't be visible. |
| 177 | 177 | int y = max( box.getAbsY() - scrollPane.getVerticalScrollBar() |
| 64 | 64 | /** |
| 65 | 65 | * 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. | |
| 67 | 67 | * |
| 68 | 68 | * @param supplier Creates an instance. |
| 2 | 2 | package com.keenwrite.preview; |
| 3 | 3 | |
| 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; | |
| 15 | 15 | import org.w3c.dom.Document; |
| 16 | 16 | import org.w3c.dom.Element; |
| 17 | 17 | |
| 18 | 18 | import java.awt.*; |
| 19 | 19 | import java.awt.image.BufferedImage; |
| 20 | 20 | import java.io.File; |
| 21 | import java.io.IOException; | |
| 22 | 21 | import java.io.InputStream; |
| 23 | 22 | import java.io.StringReader; |
| ... | ||
| 30 | 29 | import static com.keenwrite.events.StatusEvent.clue; |
| 31 | 30 | 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; | |
| 32 | 37 | import static java.awt.image.BufferedImage.TYPE_INT_RGB; |
| 33 | 38 | 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; | |
| 41 | 39 | |
| 42 | 40 | /** |
| 43 | 41 | * Responsible for converting SVG images into rasterized PNG images. |
| 44 | 42 | */ |
| 45 | 43 | 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 | } | |
| 57 | 44 | |
| 58 | 45 | /** |
| ... | ||
| 74 | 61 | clue( ex ); |
| 75 | 62 | } |
| 76 | } | |
| 77 | ||
| 78 | static { | |
| 79 | XMLResourceDescriptor.setCSSParserClassName( | |
| 80 | InkscapeCssParser.class.getName() | |
| 81 | ); | |
| 82 | 63 | } |
| 83 | 64 | |
| 84 | 65 | private static final UserAgent USER_AGENT = new UserAgentAdapter(); |
| 85 | 66 | private static final BridgeContext BRIDGE_CONTEXT = new BridgeContext( |
| 86 | 67 | USER_AGENT, new DocumentLoader( USER_AGENT ) |
| 87 | 68 | ); |
| 88 | 69 | private static final ErrorHandler sErrorHandler = new SvgErrorHandler(); |
| 89 | 70 | |
| 90 | 71 | private static final SAXSVGDocumentFactory FACTORY_DOM = |
| 91 | new SAXSVGDocumentFactory( getXMLParserClassName() ); | |
| 72 | new SAXSVGDocumentFactory(); | |
| 92 | 73 | |
| 93 | 74 | private static final NumberFormat INT_FORMAT = getIntegerInstance(); |
| ... | ||
| 319 | 300 | final var root = document.getDocumentElement(); |
| 320 | 301 | final var width = root.getAttribute( SVG_WIDTH_ATTRIBUTE ); |
| 302 | ||
| 321 | 303 | return rasterizeString( xml, INT_FORMAT.parse( width ).intValue() ); |
| 322 | 304 | } |
| 41 | 41 | * returns one row (height == 1) of byte packed image data in BGR or AGBR form |
| 42 | 42 | * |
| 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 | |
| 44 | 44 | */ |
| 45 | 45 | static void getPixelsBGR( |
| 46 | 46 | BufferedImage img, int y, int w, byte[] array, int[] temp ) { |
| 47 | 47 | final int x = 0; |
| 48 | 48 | final int h = 1; |
| 49 | 49 | |
| 50 | 50 | assert array.length == temp.length * nrChannels( img ); |
| 51 | assert (temp.length == w); | |
| 51 | assert temp.length == w; | |
| 52 | 52 | |
| 53 | 53 | final Raster raster; |
| 11 | 11 | x *= Math.PI; |
| 12 | 12 | |
| 13 | if( (x < 0.01f) && (x > -0.01f) ) { | |
| 13 | if( x < 0.01f && x > -0.01f ) { | |
| 14 | 14 | return 1.0f + x * x * (-1.0f / 6.0f + x * x * 1.0f / 120.0f); |
| 15 | 15 | } |
| ... | ||
| 30 | 30 | |
| 31 | 31 | if( t < 3.0f ) { return clip( sinc( t ) * sinc( t / 3.0f ) ); } |
| 32 | else { return (0.0f); } | |
| 32 | else { return 0.0f; } | |
| 33 | 33 | } |
| 34 | 34 | |
| ... | ||
| 105 | 105 | dst_cols = dst.cols; |
| 106 | 106 | |
| 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; | |
| 109 | 109 | |
| 110 | 110 | float scale; |
| 14 | 14 | } |
| 15 | 15 | |
| 16 | public final float apply( float value ) { | |
| 16 | public float apply( float value ) { | |
| 17 | 17 | if( value == 0 ) { |
| 18 | 18 | return 1.0f; |
| 53 | 53 | } |
| 54 | 54 | |
| 55 | public int getNumContributors() { | |
| 56 | return numContributors; | |
| 57 | } | |
| 58 | ||
| 59 | 55 | public int[] getArrN() { |
| 60 | 56 | return arrN; |
| 61 | } | |
| 62 | ||
| 63 | public float[] getArrWeight() { | |
| 64 | return arrWeight; | |
| 65 | 57 | } |
| 66 | 58 | } |
| 10 | 10 | import com.keenwrite.processors.r.RChunkEvaluator; |
| 11 | 11 | import com.keenwrite.processors.r.RVariableProcessor; |
| 12 | import com.keenwrite.util.Pair; | |
| 13 | 12 | import com.vladsch.flexmark.ast.FencedCodeBlock; |
| 14 | 13 | import com.vladsch.flexmark.html.HtmlRendererOptions; |
| 15 | 14 | import com.vladsch.flexmark.html.HtmlWriter; |
| 16 | 15 | import com.vladsch.flexmark.html.renderer.*; |
| 17 | 16 | import com.vladsch.flexmark.util.data.DataHolder; |
| 18 | 17 | import com.vladsch.flexmark.util.sequence.BasedSequence; |
| 18 | import com.whitemagicsoftware.keenquotes.util.Tuple; | |
| 19 | 19 | import org.jetbrains.annotations.NotNull; |
| 20 | 20 | |
| ... | ||
| 141 | 141 | final var style = sanitize( node.getInfo() ); |
| 142 | 142 | |
| 143 | Pair<String, ResolvedLink> imagePair; | |
| 143 | Tuple<String, ResolvedLink> imagePair; | |
| 144 | 144 | |
| 145 | 145 | if( style.startsWith( STYLE_DIAGRAM ) ) { |
| 146 | 146 | imagePair = importTextDiagram( style, node, context ); |
| 147 | 147 | |
| 148 | html.attr( "src", imagePair.getKey() ); | |
| 149 | html.withAttr( imagePair.getValue() ); | |
| 148 | html.attr( "src", imagePair.item1() ); | |
| 149 | html.withAttr( imagePair.item2() ); | |
| 150 | 150 | html.tagVoid( "img" ); |
| 151 | 151 | } |
| 152 | 152 | else if( style.startsWith( STYLE_R_CHUNK ) ) { |
| 153 | 153 | imagePair = evaluateRChunk( node, context ); |
| 154 | 154 | |
| 155 | html.attr( "src", imagePair.getKey() ); | |
| 156 | html.withAttr( imagePair.getValue() ); | |
| 155 | html.attr( "src", imagePair.item1() ); | |
| 156 | html.withAttr( imagePair.item2() ); | |
| 157 | 157 | html.tagVoid( "img" ); |
| 158 | 158 | } |
| ... | ||
| 167 | 167 | } |
| 168 | 168 | |
| 169 | private Pair<String, ResolvedLink> importTextDiagram( | |
| 169 | private Tuple<String, ResolvedLink> importTextDiagram( | |
| 170 | 170 | final String style, |
| 171 | 171 | final FencedCodeBlock node, |
| ... | ||
| 179 | 179 | final var link = context.resolveLink( LINK, source, false ); |
| 180 | 180 | |
| 181 | return new Pair<>( source, link ); | |
| 181 | return new Tuple<>( source, link ); | |
| 182 | 182 | } |
| 183 | 183 | |
| 184 | private Pair<String, ResolvedLink> evaluateRChunk( | |
| 184 | private Tuple<String, ResolvedLink> evaluateRChunk( | |
| 185 | 185 | final FencedCodeBlock node, |
| 186 | 186 | final NodeRendererContext context ) { |
| ... | ||
| 194 | 194 | final var result = mRChunkEvaluator.apply( r ); |
| 195 | 195 | |
| 196 | return new Pair<>( svg, link ); | |
| 196 | return new Tuple<>( svg, link ); | |
| 197 | 197 | } |
| 198 | 198 | |
| 27 | 27 | */ |
| 28 | 28 | private static final ScriptEngine sEngine = |
| 29 | (new ScriptEngineManager()).getEngineByName( "Renjin" ); | |
| 29 | new ScriptEngineManager().getEngineByName( "Renjin" ); | |
| 30 | 30 | |
| 31 | 31 | /** |
| 89 | 89 | // This allows Markdown, R Markdown, XML, and R XML documents to return |
| 90 | 90 | // sets of words to check. |
| 91 | ||
| 92 | 91 | final var node = mParser.parse( text ); |
| 93 | 92 | final var visitor = new TextVisitor( ( visited, bIndex, eIndex ) -> { |
| 237 | 237 | |
| 238 | 238 | // 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 | |
| 240 | 240 | // creating PDFs on subsequent runs (after the cache has been created). |
| 241 | 241 | // Users don't care about exit codes, only whether the PDF was generated. |
| ... | ||
| 318 | 318 | * }</pre> |
| 319 | 319 | * <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 | |
| 321 | 321 | * message. |
| 322 | 322 | * </p> |
| 5 | 5 | import com.keenwrite.MainPane; |
| 6 | 6 | 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 ); | |
| 626 | 635 | return picker.choose(); |
| 627 | 636 | } |
| 6 | 6 | import com.keenwrite.util.ResourceWalker; |
| 7 | 7 | import javafx.geometry.Insets; |
| 8 | import javafx.scene.Node; | |
| 8 | 9 | import javafx.scene.control.ComboBox; |
| 9 | 10 | import javafx.scene.control.Label; |
| ... | ||
| 24 | 25 | import java.util.Properties; |
| 25 | 26 | import java.util.TreeMap; |
| 27 | import java.util.concurrent.atomic.AtomicReference; | |
| 26 | 28 | |
| 27 | 29 | import static com.keenwrite.Messages.get; |
| ... | ||
| 75 | 77 | |
| 76 | 78 | var title = "Dialog.typesetting.settings.header."; |
| 79 | final var focusNode = new AtomicReference<Node>( mComboBox ); | |
| 77 | 80 | |
| 78 | 81 | if( multiple ) { |
| 79 | mChapters.setText( mSettings.chaptersProperty().get() ); | |
| 80 | 82 | mPane.add( createLabel( "Dialog.typesetting.settings.chapters" ), 0, 2 ); |
| 81 | 83 | mPane.add( mChapters, 1, 2 ); |
| 82 | 84 | |
| 85 | focusNode.set( mChapters ); | |
| 83 | 86 | title += "multiple"; |
| 84 | 87 | } |
| 85 | 88 | else { |
| 86 | 89 | title += "single"; |
| 87 | 90 | } |
| 91 | ||
| 92 | // Remember the chapter range regardless of text field visibility. | |
| 93 | mChapters.textProperty().bindBidirectional( mSettings.chaptersProperty() ); | |
| 88 | 94 | |
| 89 | 95 | setHeaderText( get( title ) ); |
| 90 | 96 | |
| 91 | 97 | final var dialogPane = getDialogPane(); |
| 92 | 98 | dialogPane.setContent( mPane ); |
| 93 | 99 | |
| 94 | runLater( () -> mComboBox.requestFocus() ); | |
| 100 | runLater( () -> focusNode.get().requestFocus() ); | |
| 95 | 101 | } |
| 96 | 102 | |
| ... | ||
| 129 | 135 | if( result.isPresent() ) { |
| 130 | 136 | final var theme = mComboBox.getSelectionModel().getSelectedItem(); |
| 131 | mSettings.themeProperty().set( theme.toLowerCase() ); | |
| 132 | mSettings.chaptersProperty().set( mChapters.getText() ); | |
| 137 | mSettings.themeProperty().setValue( theme.toLowerCase() ); | |
| 133 | 138 | |
| 134 | 139 | return true; |
| ... | ||
| 148 | 153 | mPane = createContentPane(); |
| 149 | 154 | mComboBox = createComboBox(); |
| 150 | mComboBox.setOnKeyPressed( ( event ) -> { | |
| 155 | mComboBox.setOnKeyPressed( event -> { | |
| 151 | 156 | // When the user presses the down arrow, open the drop-down. This |
| 152 | 157 | // prevents navigating to the cancel button. |
| ... | ||
| 205 | 210 | |
| 206 | 211 | // Populate the choices with themes detected on the system. |
| 207 | walk( themesDir.toPath(), "**/theme.properties", ( path ) -> { | |
| 212 | walk( themesDir.toPath(), "**/theme.properties", path -> { | |
| 208 | 213 | try { |
| 209 | 214 | final var displayed = readThemeName( path ); |
| 70 | 70 | |
| 71 | 71 | 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; | |
| 74 | 74 | } ); |
| 75 | 75 |
| 71 | 71 | |
| 72 | 72 | 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; | |
| 75 | 75 | } ); |
| 76 | 76 |
| 98 | 98 | public Optional<List<File>> choose() { |
| 99 | 99 | if( mType == FILE_OPEN_MULTIPLE ) { |
| 100 | return Optional.of( mChooser.showOpenMultipleDialog( mOwner ) ); | |
| 100 | return Optional.ofNullable( mChooser.showOpenMultipleDialog( mOwner ) ); | |
| 101 | 101 | } |
| 102 | 102 |
| 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 | } | |
| 74 | 1 |
| 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 | } | |
| 24 | 1 |
| 1 | # suppress inspection "UnusedProperty" for whole file | |
| 2 | ||
| 1 | 3 | # ######################################################################## |
| 2 | 4 | # Application |
| 37 | 37 | final var thread = new Thread( service ); |
| 38 | 38 | final var semaphor = new Semaphore( 0 ); |
| 39 | final var listener = createListener( ( f ) -> { | |
| 39 | final var listener = createListener( f -> { | |
| 40 | 40 | semaphor.release(); |
| 41 | 41 | assertEquals( file, f ); |
| 20 | 20 | public class PluralizeTest { |
| 21 | 21 | private static final ScriptEngine ENGINE = |
| 22 | (new ScriptEngineManager()).getEngineByName( "Renjin" ); | |
| 22 | new ScriptEngineManager().getEngineByName( "Renjin" ); | |
| 23 | 23 | |
| 24 | 24 | private static final Map<String, String> PLURAL_MAP = ofEntries( |
| 5 | 5 | import javafx.scene.layout.StackPane; |
| 6 | 6 | import javafx.stage.Stage; |
| 7 | import org.fxmisc.flowless.VirtualizedScrollPane; | |
| 8 | import org.fxmisc.richtext.StyleClassedTextArea; | |
| 9 | ||
| 10 | import java.net.URISyntaxException; | |
| 7 | 11 | |
| 8 | 12 | /** |
| 9 | 13 | * Scaffolding for creating one-off tests, not run as part of test suite. |
| 10 | 14 | */ |
| 11 | 15 | 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 ); | |
| 14 | 21 | |
| 15 | 22 | public static void main( final String[] args ) { |
| 16 | 23 | launch( args ); |
| 17 | 24 | } |
| 18 | 25 | |
| 19 | 26 | @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 ); | |
| 23 | 43 | |
| 24 | 44 | stage.setScene( scene ); |
| 25 | 45 | 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(); | |
| 26 | 52 | } |
| 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."""; | |
| 27 | 80 | } |
| 28 | 81 |