Dave Jarvis' Repositories

git clone https://repo.autonoma.ca/repo/keenwrite.git
M R/bootstrap.R
1
setwd( '$application.r.working.directory$' )
2
assign( "anchor", '$date.anchor$', envir = .GlobalEnv )
1
setwd( '{{application.r.working.directory}}' )
2
assign( "anchor", '{{date.anchor}}', envir = .GlobalEnv )
33
44
source( 'pluralize.R' )
M README.md
1717
### Windows
1818
19
Double-click the application to start; give the application permission to run.
20
2119
When upgrading to a new version, delete the following directory:
2220
2321
    C:\Users\%USERNAME%\AppData\Local\warp\packages\keenwrite.exe
22
23
Double-click the application to start; give the application permission to run.
2424
2525
### Linux
...
4848
* Real-time spell check
4949
* Real-time rendering of math using TeX notation
50
* Diagrams: Mermaid, GraphViz, UML, sequence, timing, DITAA, and more!
50
* Diagrams: Mermaid, GraphViz, UML, sequence, timing, and [many more](https://kroki.io/)!
5151
* R integration
5252
* XML transformation using XSLT3 or older
53
* Customizable GUI having detachable tabs
54
* Platform independent (Windows, Linux, MacOS)
53
* Customizable user interface having detachable tabs
54
* Platform-independent (Windows, Linux, MacOS)
5555
5656
## Usage
5757
5858
See the [detailed documentation](docs/README.md) for information about
5959
using the application.
6060
61
## Screenshot
61
## Screenshots
6262
63
![Screenshot with Formulas](docs/images/equations.png)
63
Diagram that includes variables:
64
65
![GraphViz Diagram Screenshot](docs/images/screenshots/01.png)
66
67
Poem with locale settings:
68
69
![Korean Poem Screenshot](docs/images/screenshots/02.png)
70
71
TeX equations with detached preview:
72
73
![TeX Equations Screenshot](docs/images/screenshots/03.png)
6474
6575
## License
M README.zh-CN.md
5858
## 截图
5959
60
![Screenshot with Formulas](docs/images/equations.png)
60
![GraphViz Diagram Screenshot](docs/images/screenshots/01.png)
61
62
![Korean Poem Screenshot](docs/images/screenshots/02.png)
63
64
![TeX Equations Screenshot](docs/images/screenshots/03.png)
65
6166
6267
## 软件许可证
A docs/diagram.md
1
# Introduction
2
3
From a high level, the application architecture for converting Markdown documents is captured in the following figure:
4
5
``` diagram-graphviz
6
digraph {
7
  node [fontname = "Noto Sans" fontsize=6 height=.25 penwidth=.5];
8
  edge [fontname = "Noto Sans" fontsize=6  penwidth=.5 arrowsize=.5];
9
  node [shape=box color="{{keenwrite.palette.primary.light}}" fontcolor="{{keenwrite.palette.primary.dark}}"]
10
  edge [color="{{keenwrite.palette.grayscale.light}}" fontcolor="{{keenwrite.palette.grayscale.dark}}"]
11
12
  {{keenwrite.classes.processors.variable.definition}} ->   {{keenwrite.classes.processors.markdown}} [xlabel="{{keenwrite.graph.label.chain.next}}  "]
13
  {{keenwrite.classes.processors.markdown}} -> {{keenwrite.classes.processors.preview}} [xlabel="{{keenwrite.graph.label.chain.next}}  "]
14
  {{keenwrite.classes.processors.markdown}} -> Extensions [label="  contains"]
15
16
Extensions -> FencedBlockExtension
17
Extensions -> CaretExtension
18
Extensions -> ImageLinkExtension
19
Extensions -> TeXExtension
20
}
21
```
22
23
An extension is an addition to the Markdown parser, flexmark-java, that is used when converting the document's abstract syntax tree into an HTML document. The {{keenwrite.classes.processors.markdown}} contains both prepackaged and custom extensions.
124
A docs/diagram.yaml
1
---
2
keenwrite:
3
  classes:
4
    processors:
5
      markdown: "MarkdownProcessor"
6
      variable:
7
        definition: "DefinitionProcessor"
8
      preview: "PreviewProcessor"
9
  palette:
10
    primary:
11
      light: "#51a9cf"
12
      dark: "#126d95"
13
    secondary:
14
      light: "#ec706a"
15
      dark: "#7e252f"
16
    accent:
17
      light: "#76A786"
18
      dark: "#385742"
19
    grayscale:
20
      light: "#bac2c5"
21
      dark: "#394343"
22
  graph:
23
    label:
24
      chain:
25
        next: "successor"
126
M docs/images/app-title.png
Binary file
M docs/images/app-title.zh-CN.png
Binary file
M docs/images/architecture/architecture.png
Binary file
M docs/images/black-text.png
Binary file
M docs/images/blocked-text.png
Binary file
D docs/images/equations.png
Binary file
M docs/images/resolved-text.png
Binary file
A docs/images/screenshots/01.png
Binary file
A docs/images/screenshots/02.png
Binary file
A docs/images/screenshots/03.png
Binary file
M docs/logo/logo-text.svg
1
<svg height="197.4767" viewBox="0 0 695.99768 197.4767" width="695.99768" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><linearGradient id="a" gradientTransform="matrix(-8.7796153 42.985832 -42.985832 -8.7796153 810.33577 828.59028)" gradientUnits="userSpaceOnUse" x1=".152358" x2=".968809" y1="-.044912" y2="-.049471"><stop offset="0" stop-color="#ec706a"/><stop offset="1" stop-color="#ecd980"/></linearGradient><g transform="translate(-295.50101 -692.52836)"><path d="m793.12811 845.45734c-1.09438 20.55837 6.93804 24.54772 6.93804 24.54772s.98325-13.16026 6.76656-11.6325c4.96369 1.30552 2.67983 10.4134 2.67983 10.4134s26.21535-34.03672 1.372-40.63137-5.51534-1.89773-10.40994.92679c-3.58074 2.06734-6.82887 6.66097-7.34649 16.37596" fill="url(#a)"/><path d="m826.30436 831.16428-10.99206-16.95952 1.75995-6.49966 10.01483 2.71233z" fill="#126d95"/><path d="m828.56081 804.89512-.91739 3.38458-9.99361-2.70665.91739-3.38458z" fill="#126d95"/><g fill="#51a9cf"><path d="m834.01973 741.0381c-1.68105.0185-3.22054 1.13771-3.68367 2.84981-.56186 2.07405.665 4.21099 2.73743 4.77241l-13.96475 51.52944-9.99361-2.70665c8.36013-31.46487 4.99411-51.98144 4.99411-51.98144 14.99782-11.92097 23.67-25.56577 27.63101-32.97331z"/><path d="m818.56767 802.18881-.9174 3.38458-10.03996-2.72957.91314-3.37522z"/><path d="m817.07405 807.70594-1.75995 6.49966-18.03534 9.08805 9.78412-18.31044z"/></g><path d="m836.1981 741.64919 7.72577-28.52932c-.3195 8.40427.28451 24.55036 7.21678 42.41047 0 0-11.89603 16.50235-21.99788 47.3763l-10.03442-2.71758 13.96533-51.5284c2.08221.56405 4.21039-.66603 4.77182-2.73844.45427-1.67248-.26571-3.38317-1.64739-4.27302" fill="#126d95"/></g><text transform="translate(-295.73751 -689.6407)"/><g style="font-style:italic;font-weight:800;font-size:133.333;font-family:Merriweather Sans;letter-spacing:0;word-spacing:0;fill:#51a9cf"><text x="16.133343" y="130.6234"><tspan x="16.133343" y="130.6234">KeenWr</tspan></text><text x="552.53137" y="130.6234"><tspan x="552.53137" y="130.6234">te</tspan></text></g></svg>
1
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
2
<svg
3
   xmlns:dc="http://purl.org/dc/elements/1.1/"
4
   xmlns:cc="http://creativecommons.org/ns#"
5
   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
6
   xmlns:svg="http://www.w3.org/2000/svg"
7
   xmlns="http://www.w3.org/2000/svg"
8
   xmlns:xlink="http://www.w3.org/1999/xlink"
9
   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
10
   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
11
   height="197.4767"
12
   viewBox="0 0 695.99768 197.4767"
13
   width="695.99768"
14
   version="1.1"
15
   id="svg37"
16
   sodipodi:docname="new-logo-text.svg"
17
   inkscape:version="1.0.1 (3bc2e813f5, 2020-09-07)">
18
  <metadata
19
     id="metadata43">
20
    <rdf:RDF>
21
      <cc:Work
22
         rdf:about="">
23
        <dc:format>image/svg+xml</dc:format>
24
        <dc:type
25
           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
26
        <dc:title></dc:title>
27
      </cc:Work>
28
    </rdf:RDF>
29
  </metadata>
30
  <defs
31
     id="defs41">
32
    <linearGradient
33
       id="a"
34
       gradientTransform="matrix(-8.7796153,42.985832,-42.985832,-8.7796153,514.83476,136.06192)"
35
       gradientUnits="userSpaceOnUse"
36
       x1=".152358"
37
       x2=".968809"
38
       y1="-.044912"
39
       y2="-.049471">
40
      <stop
41
         offset="0"
42
         stop-color="#ec706a"
43
         id="stop2" />
44
      <stop
45
         offset="1"
46
         stop-color="#ecd980"
47
         id="stop4" />
48
    </linearGradient>
49
  </defs>
50
  <path
51
     style="fill:url(#a);fill-opacity:1.0;fill-rule:nonzero;stroke:none;stroke-width:1.226;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0"
52
     paint-order="stroke"
53
     d="m 496.76229,150.80474 c -4.25368,20.68081 3.28191,25.95476 3.28191,25.95476 v 0 c 0,0 3.00963,-13.19543 8.64082,-10.76172 v 0 c 4.83401,2.08299 1.12516,10.97002 1.12516,10.97002 v 0 c 0,0 31.78993,-30.5076 7.60484,-40.99434 v 0 c 0,0 -5.30287,-2.76791 -10.69842,-0.65209 v 0 c -3.94735,1.54891 -7.94375,5.71058 -9.95431,15.48337"
54
     stroke-linecap="round"
55
     id="path14" />
56
  <path
57
     d="m 530.80335,138.63592 -10.99206,-16.95952 1.75995,-6.49966 10.01483,2.71233 z"
58
     fill="#126d95"
59
     id="path9" />
60
  <path
61
     d="m 533.0598,112.36676 -0.91739,3.38458 -9.99361,-2.70665 0.91739,-3.38458 z"
62
     fill="#126d95"
63
     id="path11" />
64
  <g
65
     fill="#51a9cf"
66
     id="g19"
67
     transform="translate(-295.50101,-692.52836)">
68
    <path
69
       d="m 834.01973,741.0381 c -1.68105,0.0185 -3.22054,1.13771 -3.68367,2.84981 -0.56186,2.07405 0.665,4.21099 2.73743,4.77241 l -13.96475,51.52944 -9.99361,-2.70665 c 8.36013,-31.46487 4.99411,-51.98144 4.99411,-51.98144 14.99782,-11.92097 23.67,-25.56577 27.63101,-32.97331 z"
70
       id="path13" />
71
    <path
72
       d="m 818.56767,802.18881 -0.9174,3.38458 -10.03996,-2.72957 0.91314,-3.37522 z"
73
       id="path15" />
74
    <path
75
       d="m 817.07405,807.70594 -1.75995,6.49966 -18.03534,9.08805 9.78412,-18.31044 z"
76
       id="path17" />
77
  </g>
78
  <path
79
     d="m 540.69709,49.12083 7.72577,-28.52932 c -0.3195,8.40427 0.28451,24.55036 7.21678,42.41047 0,0 -11.89603,16.50235 -21.99788,47.3763 l -10.03442,-2.71758 13.96533,-51.5284 c 2.08221,0.56405 4.21039,-0.66603 4.77182,-2.73844 0.45427,-1.67248 -0.26571,-3.38317 -1.64739,-4.27302"
80
     fill="#126d95"
81
     id="path21" />
82
  <text
83
     transform="translate(-295.73751 -689.6407)"
84
     id="text25" />
85
  <g
86
     style="font-style:italic;font-weight:800;font-size:133.333;font-family:Merriweather Sans;letter-spacing:0;word-spacing:0;fill:#51a9cf"
87
     id="g35">
88
    <text
89
       x="16.133343"
90
       y="130.6234"
91
       id="text29"><tspan
92
         x="16.133343"
93
         y="130.6234"
94
         id="tspan27">KeenWr</tspan></text>
95
    <text
96
       x="552.53137"
97
       y="130.6234"
98
       id="text33"><tspan
99
         x="552.53137"
100
         y="130.6234"
101
         id="tspan31">te</tspan></text>
102
  </g>
103
</svg>
104
A docs/logo/palette.txt
11
2
Blues
3
  Light - 51a9cf
4
  Dark - 126d95
5
6
Red & Yellow
7
  Light yellow - ecd980
8
  Light red - ec706a
9
  Dark red - 7e252f
10
11
Greens
12
  Light - 76A786
13
  Dark - 385742
14
15
Grayscale
16
  Light - bac2c5
17
  Dark - 394343
18
19
A docs/math.yaml
1
---
2
formula:
3
  sqrt:
4
    value: "420"
5
  quadratic:
6
    a: "25"
7
    b: "84.906"
8
    c: "20"
19
D docs/variables.yaml
1
---
2
formula:
3
  sqrt:
4
    value: "420"
5
  quadratic:
6
    a: "25"
7
    b: "84.906"
8
    c: "20"
91
A fonts/README.md
1
# Fonts
2
3
For best results, it is recommended that the Noto Font family is installed
4
on the system. The required font families include:
5
6
* Sans-serif --- editor pane
7
* Serif --- preview pane
8
* Serif monospace --- preview pane
9
10
# Chinese, Japanese, and Korean (CJK)
11
12
Download and install from the following font bundles:
13
14
* [Hong Kong](noto-hk.zip)
15
* [Japanese](noto-jp.zip)
16
* [Korean](noto-kr.zip)
17
* [Simplified Chinese](noto-sc.zip)
18
* [Traditional Chinese](noto-tc.zip)
19
20
Except for Hong Kong, each bundle contains all the required font families;
21
Hong Kong must be paired with the Simplified Chinese.
22
23
The [official versions](https://www.google.com/get/noto/) of these fonts
24
are updated regularly at the Noto Fonts
25
[repository](https://github.com/googlefonts/noto-fonts/). If downloading
26
from the original location, be sure to retrieve all font families needed
27
for the application to render text correctly.
28
29
# Internationalization
30
31
Fonts for other languages may work but have not been tested.
32
133
A fonts/noto-hk.zip
Binary file
A fonts/noto-jp.zip
Binary file
A fonts/noto-kr.zip
Binary file
A fonts/noto-sc.zip
Binary file
A fonts/noto-tc.zip
Binary file
M src/main/java/com/keenwrite/AbstractFileFactory.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite;
33
M src/main/java/com/keenwrite/Bootstrap.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite;
33
A src/main/java/com/keenwrite/Caret.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite;
3
4
import com.keenwrite.util.GenericBuilder;
5
import javafx.beans.value.ObservableValue;
6
import org.fxmisc.richtext.StyleClassedTextArea;
7
import org.fxmisc.richtext.model.Paragraph;
8
import org.reactfx.collection.LiveList;
9
10
import java.util.Collection;
11
12
import static com.keenwrite.Constants.STATUS_BAR_LINE;
13
import static com.keenwrite.Messages.get;
14
15
/**
16
 * Represents the absolute, relative, and maximum position of the caret. The
17
 * caret position is a character offset into the text.
18
 */
19
public class Caret {
20
21
  public static GenericBuilder<Caret.Mutator, Caret> builder() {
22
    return GenericBuilder.of( Caret.Mutator::new, Caret::new );
23
  }
24
25
  /**
26
   * Used for building a new {@link Caret} instance.
27
   */
28
  public static class Mutator {
29
    /**
30
     * Caret's current paragraph index (i.e., current caret line number).
31
     */
32
    private ObservableValue<Integer> mParagraph;
33
34
    /**
35
     * Used to count the number of lines in the text editor document.
36
     */
37
    private LiveList<Paragraph<Collection<String>, String,
38
      Collection<String>>> mParagraphs;
39
40
    /**
41
     * Caret offset into the full text, represented as a string index.
42
     */
43
    private ObservableValue<Integer> mTextOffset;
44
45
    /**
46
     * Caret offset into the current paragraph, represented as a string index.
47
     */
48
    private ObservableValue<Integer> mParaOffset;
49
50
    /**
51
     * Total number of characters in the document.
52
     */
53
    private ObservableValue<Integer> mTextLength;
54
55
    /**
56
     * Configures this caret position using properties from the given editor.
57
     *
58
     * @param editor The text editor that has a caret with position properties.
59
     */
60
    public void setEditor( final StyleClassedTextArea editor ) {
61
      mParagraph = editor.currentParagraphProperty();
62
      mParagraphs = editor.getParagraphs();
63
      mParaOffset = editor.caretColumnProperty();
64
      mTextOffset = editor.caretPositionProperty();
65
      mTextLength = editor.lengthProperty();
66
    }
67
  }
68
69
  private final Mutator mMutator;
70
71
  /**
72
   * Force using the builder pattern.
73
   */
74
  private Caret( final Mutator mutator ) {
75
    assert mutator != null;
76
77
    mMutator = mutator;
78
  }
79
80
  /**
81
   * Allows observers to be notified when the value of the caret changes.
82
   *
83
   * @return An observer for the caret's document offset.
84
   */
85
  public ObservableValue<Integer> textOffsetProperty() {
86
    return mMutator.mTextOffset;
87
  }
88
89
  /**
90
   * Answers whether the caret's offset into the text is between the given
91
   * offsets.
92
   *
93
   * @param began Starting value compared against the caret's text offset.
94
   * @param ended Ending value compared against the caret's text offset.
95
   * @return {@code true} when the caret's text offset is between the given
96
   * values, inclusively (for either value).
97
   */
98
  public boolean isBetweenText( final int began, final int ended ) {
99
    final var offset = getTextOffset();
100
    return began <= offset && offset <= ended;
101
  }
102
103
  /**
104
   * Answers whether the caret's offset into the paragraph is before the given
105
   * offset.
106
   *
107
   * @param offset Compared against the caret's paragraph offset.
108
   * @return {@code true} the caret's offset is before the given offset.
109
   */
110
  public boolean isBeforeColumn( final int offset ) {
111
    return getParaOffset() < offset;
112
  }
113
114
  /**
115
   * Answers whether the caret's offset into the text is before the given
116
   * text offset.
117
   *
118
   * @param offset Compared against the caret's text offset.
119
   * @return {@code true} the caret's offset is after the given offset.
120
   */
121
  public boolean isAfterColumn( final int offset ) {
122
    return getParaOffset() > offset;
123
  }
124
125
  /**
126
   * Answers whether the caret's offset into the text exceeds the length of
127
   * the text.
128
   *
129
   * @return {@code true} when the caret is at the end of the text boundary.
130
   */
131
  public boolean isAfterText() {
132
    return getTextOffset() >= getTextLength();
133
  }
134
135
  public boolean isAfter( final int offset ) {
136
    return offset >= getTextOffset();
137
  }
138
139
  private int getParagraph() {
140
    return mMutator.mParagraph.getValue();
141
  }
142
143
  /**
144
   * Returns the number of lines in the text editor.
145
   *
146
   * @return The size of the text editor's paragraph list plus one.
147
   */
148
  private int getParagraphCount() {
149
    return mMutator.mParagraphs.size() + 1;
150
  }
151
152
  /**
153
   * Returns the absolute position of the caret within the entire document.
154
   *
155
   * @return A zero-based index of the caret position.
156
   */
157
  private int getTextOffset() {
158
    return mMutator.mTextOffset.getValue();
159
  }
160
161
  /**
162
   * Returns the position of the caret within the current paragraph being
163
   * edited.
164
   *
165
   * @return A zero-based index of the caret position relative to the
166
   * current paragraph.
167
   */
168
  private int getParaOffset() {
169
    return mMutator.mParaOffset.getValue();
170
  }
171
172
  /**
173
   * Returns the total number of characters in the document being edited.
174
   *
175
   * @return A zero-based count of the total characters in the document.
176
   */
177
  private int getTextLength() {
178
    return mMutator.mTextLength.getValue();
179
  }
180
181
  /**
182
   * Returns a human-readable string that shows the current caret position
183
   * within the text. Typically this will include the current line number,
184
   * the number of lines, and the character offset into the text.
185
   *
186
   * @return A string to present to an end user.
187
   */
188
  @Override
189
  public String toString() {
190
    return get( STATUS_BAR_LINE,
191
                getParagraph() + 1,
192
                getParagraphCount(),
193
                getTextOffset() + 1 );
194
  }
195
}
1196
M src/main/java/com/keenwrite/Constants.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite;
33
44
import com.keenwrite.service.Settings;
55
import javafx.scene.image.Image;
6
import javafx.scene.image.ImageView;
67
78
import java.io.File;
...
7475
7576
  public static final Image ICON_DIALOG = LOGOS.get( 1 );
77
  public static final ImageView ICON_DIALOG_NODE = new ImageView( ICON_DIALOG );
7678
7779
  public static final String FILE_PREFERENCES = getPreferencesFilename();
...
121123
  public static final File USER_DIRECTORY =
122124
    new File( System.getProperty( "user.dir" ) );
125
126
  public static final String NEWLINE = System.lineSeparator();
123127
124128
  /**
...
163167
   */
164168
  public static final String FONT_DIRECTORY = "/fonts";
169
170
  /**
171
   * Default text editor font name.
172
   */
173
  public static final String FONT_NAME_EDITOR_DEFAULT = "Noto Sans Regular";
165174
166175
  /**
167176
   * Default text editor font size, in points.
168177
   */
169178
  public static final float FONT_SIZE_EDITOR_DEFAULT = 12f;
179
180
  /**
181
   * Default preview font name.
182
   */
183
  public static final String FONT_NAME_PREVIEW_DEFAULT = "Source Serif Pro";
170184
171185
  /**
172186
   * Default preview font size, in points.
173187
   */
174188
  public static final float FONT_SIZE_PREVIEW_DEFAULT = 13f;
189
190
  /**
191
   * Default monospace preview font name.
192
   */
193
  public static final String FONT_NAME_PREVIEW_MONO_NAME_DEFAULT = "Source Code Pro";
194
195
  /**
196
   * Default monospace preview font size, in points.
197
   */
198
  public static final float FONT_SIZE_PREVIEW_MONO_SIZE_DEFAULT = 13f;
175199
176200
  /**
M src/main/java/com/keenwrite/DefinitionNameInjector.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite;
33
44
import com.keenwrite.editors.TextDefinition;
55
import com.keenwrite.editors.TextEditor;
66
import com.keenwrite.editors.definition.DefinitionTreeItem;
77
import com.keenwrite.sigils.SigilOperator;
88
99
import static com.keenwrite.Constants.*;
10
import static com.keenwrite.StatusBarNotifier.clue;
10
import static com.keenwrite.StatusNotifier.clue;
1111
1212
/**
M src/main/java/com/keenwrite/ExportFormat.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite;
33
M src/main/java/com/keenwrite/Launcher.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite;
33
M src/main/java/com/keenwrite/MainApp.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite;
33
M src/main/java/com/keenwrite/MainPane.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite;
3
4
import com.keenwrite.editors.TextDefinition;
5
import com.keenwrite.editors.TextEditor;
6
import com.keenwrite.editors.TextResource;
7
import com.keenwrite.editors.definition.DefinitionEditor;
8
import com.keenwrite.editors.definition.DefinitionTabSceneFactory;
9
import com.keenwrite.editors.definition.TreeTransformer;
10
import com.keenwrite.editors.definition.yaml.YamlTreeTransformer;
11
import com.keenwrite.editors.markdown.MarkdownEditor;
12
import com.keenwrite.io.MediaType;
13
import com.keenwrite.preferences.Key;
14
import com.keenwrite.preferences.Workspace;
15
import com.keenwrite.preview.HtmlPreview;
16
import com.keenwrite.processors.IdentityProcessor;
17
import com.keenwrite.processors.Processor;
18
import com.keenwrite.processors.ProcessorContext;
19
import com.keenwrite.processors.ProcessorFactory;
20
import com.keenwrite.processors.markdown.Caret;
21
import com.keenwrite.processors.markdown.CaretExtension;
22
import com.keenwrite.service.events.Notifier;
23
import com.keenwrite.sigils.RSigilOperator;
24
import com.keenwrite.sigils.SigilOperator;
25
import com.keenwrite.sigils.Tokens;
26
import com.keenwrite.sigils.YamlSigilOperator;
27
import com.panemu.tiwulfx.control.dock.DetachableTab;
28
import com.panemu.tiwulfx.control.dock.DetachableTabPane;
29
import javafx.beans.property.*;
30
import javafx.collections.ListChangeListener;
31
import javafx.event.ActionEvent;
32
import javafx.event.Event;
33
import javafx.event.EventHandler;
34
import javafx.scene.Scene;
35
import javafx.scene.control.SplitPane;
36
import javafx.scene.control.Tab;
37
import javafx.scene.control.Tooltip;
38
import javafx.scene.control.TreeItem.TreeModificationEvent;
39
import javafx.scene.input.KeyEvent;
40
import javafx.stage.Stage;
41
import javafx.stage.Window;
42
43
import java.io.File;
44
import java.nio.file.Path;
45
import java.util.*;
46
import java.util.concurrent.atomic.AtomicBoolean;
47
import java.util.function.Function;
48
import java.util.stream.Collectors;
49
50
import static com.keenwrite.Constants.*;
51
import static com.keenwrite.ExportFormat.NONE;
52
import static com.keenwrite.Messages.get;
53
import static com.keenwrite.StatusBarNotifier.clue;
54
import static com.keenwrite.io.MediaType.*;
55
import static com.keenwrite.preferences.Workspace.*;
56
import static com.keenwrite.processors.ProcessorFactory.createProcessors;
57
import static com.keenwrite.service.events.Notifier.NO;
58
import static com.keenwrite.service.events.Notifier.YES;
59
import static java.util.stream.Collectors.groupingBy;
60
import static javafx.application.Platform.runLater;
61
import static javafx.scene.control.TabPane.TabClosingPolicy.ALL_TABS;
62
import static javafx.scene.input.KeyCode.SPACE;
63
import static javafx.scene.input.KeyCombination.CONTROL_DOWN;
64
import static javafx.util.Duration.millis;
65
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
66
67
/**
68
 * Responsible for wiring together the main application components for a
69
 * particular workspace (project). These include the definition views,
70
 * text editors, and preview pane along with any corresponding controllers.
71
 */
72
public final class MainPane extends SplitPane {
73
  private static final Notifier sNotifier = Services.load( Notifier.class );
74
75
  /**
76
   * Used when opening files to determine how each file should be binned and
77
   * therefore what tab pane to be opened within.
78
   */
79
  private static final Set<MediaType> PLAIN_TEXT_FORMAT = Set.of(
80
    TEXT_MARKDOWN, TEXT_R_MARKDOWN, TEXT_R_XML, UNDEFINED
81
  );
82
83
  /**
84
   * Prevents re-instantiation of processing classes.
85
   */
86
  private final Map<TextResource, Processor<String>> mProcessors =
87
    new HashMap<>();
88
89
  private final Workspace mWorkspace;
90
91
  /**
92
   * Groups similar file type tabs together.
93
   */
94
  private final Map<MediaType, DetachableTabPane> mTabPanes = new HashMap<>();
95
96
  /**
97
   * Stores definition names and values.
98
   */
99
  private final Map<String, String> mResolvedMap =
100
    new HashMap<>( MAP_SIZE_DEFAULT );
101
102
  /**
103
   * Renders the actively selected plain text editor tab.
104
   */
105
  private final HtmlPreview mHtmlPreview;
106
107
  /**
108
   * Changing the active editor fires the value changed event. This allows
109
   * refreshes to happen when external definitions are modified and need to
110
   * trigger the processing chain.
111
   */
112
  private final ObjectProperty<TextEditor> mActiveTextEditor =
113
    createActiveTextEditor();
114
115
  /**
116
   * Changing the active definition editor fires the value changed event. This
117
   * allows refreshes to happen when external definitions are modified and need
118
   * to trigger the processing chain.
119
   */
120
  private final ObjectProperty<TextDefinition> mActiveDefinitionEditor =
121
    createActiveDefinitionEditor( mActiveTextEditor );
122
123
  /**
124
   * Responsible for creating a new scene when a tab is detached into
125
   * its own window frame.
126
   */
127
  private final DefinitionTabSceneFactory mDefinitionTabSceneFactory =
128
    createDefinitionTabSceneFactory( mActiveDefinitionEditor );
129
130
  /**
131
   * Tracks the number of detached tab panels opened into their own windows,
132
   * which allows unique identification of subordinate windows by their title.
133
   * It is doubtful more than 128 windows, much less 256, will be created.
134
   */
135
  private byte mWindowCount;
136
137
  /**
138
   * Called when the definition data is changed.
139
   */
140
  private final EventHandler<TreeModificationEvent<Event>> mTreeHandler =
141
    event -> {
142
      final var editor = mActiveDefinitionEditor.get();
143
144
      resolve( editor );
145
      process( getActiveTextEditor() );
146
      save( editor );
147
    };
148
149
  /**
150
   * Adds all content panels to the main user interface. This will load the
151
   * configuration settings from the workspace to reproduce the settings from
152
   * a previous session.
153
   */
154
  public MainPane( final Workspace workspace ) {
155
    mWorkspace = workspace;
156
    mHtmlPreview = new HtmlPreview( workspace );
157
158
    open( bin( getRecentFiles() ) );
159
    viewPreview();
160
161
    final var ratio = 100f / getItems().size() / 100;
162
    final var positions = getDividerPositions();
163
164
    for( int i = 0; i < positions.length; i++ ) {
165
      positions[ i ] = ratio * i;
166
    }
167
168
    // TODO: Load divider positions from exported settings, see bin() comment.
169
    setDividerPositions( positions );
170
171
    // Once the main scene's window regains focus, update the active definition
172
    // editor to the currently selected tab.
173
    runLater(
174
      () -> getWindow().focusedProperty().addListener( ( c, o, n ) -> {
175
        if( n != null && n ) {
176
          final var pane = mTabPanes.get( TEXT_YAML );
177
          final var model = pane.getSelectionModel();
178
          final var tab = model.getSelectedItem();
179
180
          if( tab != null ) {
181
            final var editor = (TextDefinition) tab.getContent();
182
183
            mActiveDefinitionEditor.set( editor );
184
          }
185
        }
186
      } )
187
    );
188
  }
189
190
  /**
191
   * Opens all the files into the application, provided the paths are unique.
192
   * This may only be called for any type of files that a user can edit
193
   * (i.e., update and persist), such as definitions and text files.
194
   *
195
   * @param files The list of files to open.
196
   */
197
  public void open( final List<File> files ) {
198
    files.forEach( this::open );
199
  }
200
201
  /**
202
   * This opens the given file. Since the preview pane is not a file that
203
   * can be opened, it is safe to add a listener to the detachable pane.
204
   *
205
   * @param file The file to open.
206
   */
207
  private void open( final File file ) {
208
    final var tab = createTab( file );
209
    final var node = tab.getContent();
210
    final var mediaType = MediaType.valueFrom( file );
211
    final var tabPane = obtainDetachableTabPane( mediaType );
212
    final var newTabPane = !getItems().contains( tabPane );
213
214
    tab.setTooltip( createTooltip( file ) );
215
    tabPane.setFocusTraversable( false );
216
    tabPane.setTabClosingPolicy( ALL_TABS );
217
    tabPane.getTabs().add( tab );
218
219
    if( newTabPane ) {
220
      var index = getItems().size();
221
222
      if( node instanceof TextDefinition ) {
223
        tabPane.setSceneFactory( mDefinitionTabSceneFactory::create );
224
        index = 0;
225
      }
226
227
      addTabPane( index, tabPane );
228
    }
229
230
    getRecentFiles().add( file.getAbsolutePath() );
231
  }
232
233
  /**
234
   * Opens a new text editor document using the default document file name.
235
   */
236
  public void newTextEditor() {
237
    open( DOCUMENT_DEFAULT );
238
  }
239
240
  /**
241
   * Opens a new definition editor document using the default definition
242
   * file name.
243
   */
244
  public void newDefinitionEditor() {
245
    open( DEFINITION_DEFAULT );
246
  }
247
248
  /**
249
   * Iterates over all tab panes to find all {@link TextEditor}s and request
250
   * that they save themselves.
251
   */
252
  public void saveAll() {
253
    mTabPanes.forEach(
254
      ( mt, tp ) -> tp.getTabs().forEach( ( tab ) -> {
255
        final var node = tab.getContent();
256
        if( node instanceof TextEditor ) {
257
          save( ((TextEditor) node) );
258
        }
259
      } )
260
    );
261
  }
262
263
  /**
264
   * Requests that the active {@link TextEditor} saves itself. Don't bother
265
   * checking if modified first because if the user swaps external media from
266
   * an external source (e.g., USB thumb drive), save should not second-guess
267
   * the user: save always re-saves. Also, it's less code.
268
   */
269
  public void save() {
270
    save( getActiveTextEditor() );
271
  }
272
273
  /**
274
   * Saves the active {@link TextEditor} under a new name.
275
   *
276
   * @param file The new active editor {@link File} reference.
277
   */
278
  public void saveAs( final File file ) {
279
    assert file != null;
280
    final var editor = getActiveTextEditor();
281
    final var tab = getTab( editor );
282
283
    editor.rename( file );
284
    tab.ifPresent( t -> {
285
      t.setText( editor.getFilename() );
286
      t.setTooltip( createTooltip( file ) );
287
    } );
288
289
    save();
290
  }
291
292
  /**
293
   * Saves the given {@link TextResource} to a file. This is typically used
294
   * to save either an instance of {@link TextEditor} or {@link TextDefinition}.
295
   *
296
   * @param resource The resource to export.
297
   */
298
  private void save( final TextResource resource ) {
299
    try {
300
      resource.save();
301
    } catch( final Exception ex ) {
302
      clue( ex );
303
      sNotifier.alert(
304
        getWindow(), resource.getPath(), "TextResource.saveFailed", ex
305
      );
306
    }
307
  }
308
309
  /**
310
   * Closes all open {@link TextEditor}s; all {@link TextDefinition}s stay open.
311
   *
312
   * @return {@code true} when all editors, modified or otherwise, were
313
   * permitted to close; {@code false} when one or more editors were modified
314
   * and the user requested no closing.
315
   */
316
  public boolean closeAll() {
317
    var closable = true;
318
319
    for( final var entry : mTabPanes.entrySet() ) {
320
      final var tabPane = entry.getValue();
321
      final var tabIterator = tabPane.getTabs().iterator();
322
323
      while( tabIterator.hasNext() ) {
324
        final var tab = tabIterator.next();
325
        final var node = tab.getContent();
326
327
        if( node instanceof TextEditor &&
328
          (closable &= canClose( (TextEditor) node )) ) {
329
          tabIterator.remove();
330
          close( tab );
331
        }
332
      }
333
    }
334
335
    return closable;
336
  }
337
338
  /**
339
   * Calls the tab's {@link Tab#getOnClosed()} handler to carry out a close
340
   * event.
341
   *
342
   * @param tab The {@link Tab} that was closed.
343
   */
344
  private void close( final Tab tab ) {
345
    final var handler = tab.getOnClosed();
346
347
    if( handler != null ) {
348
      handler.handle( new ActionEvent() );
349
    }
350
  }
351
352
  /**
353
   * Closes the active tab; delegates to {@link #canClose(TextEditor)}.
354
   */
355
  public void close() {
356
    final var editor = getActiveTextEditor();
357
    if( canClose( editor ) ) {
358
      close( editor );
359
    }
360
  }
361
362
  /**
363
   * Closes the given {@link TextEditor}. This must not be called from within
364
   * a loop that iterates over the tab panes using {@code forEach}, lest a
365
   * concurrent modification exception be thrown.
366
   *
367
   * @param editor The {@link TextEditor} to close, without confirming with
368
   *               the user.
369
   */
370
  private void close( final TextEditor editor ) {
371
    getTab( editor ).ifPresent(
372
      ( tab ) -> {
373
        tab.getTabPane().getTabs().remove( tab );
374
        close( tab );
375
      }
376
    );
377
  }
378
379
  /**
380
   * Answers whether the given {@link TextEditor} may be closed.
381
   *
382
   * @param editor The {@link TextEditor} to try closing.
383
   * @return {@code true} when the editor may be closed; {@code false} when
384
   * the user has requested to keep the editor open.
385
   */
386
  private boolean canClose( final TextEditor editor ) {
387
    final var editorTab = getTab( editor );
388
    final var canClose = new AtomicBoolean( true );
389
390
    if( editor.isModified() ) {
391
      final var filename = new StringBuilder();
392
      editorTab.ifPresent( ( tab ) -> filename.append( tab.getText() ) );
393
394
      final var message = sNotifier.createNotification(
395
        Messages.get( "Alert.file.close.title" ),
396
        Messages.get( "Alert.file.close.text" ),
397
        filename.toString()
398
      );
399
400
      final var dialog = sNotifier.createConfirmation( getWindow(), message );
401
402
      dialog.showAndWait().ifPresent(
403
        save -> canClose.set( save == YES ? editor.save() : save == NO )
404
      );
405
    }
406
407
    return canClose.get();
408
  }
409
410
  private ObjectProperty<TextEditor> createActiveTextEditor() {
411
    final var editor = new SimpleObjectProperty<TextEditor>();
412
413
    editor.addListener( ( c, o, n ) -> {
414
      if( n != null ) {
415
        mHtmlPreview.setBaseUri( n.getPath() );
416
        process( n );
417
      }
418
    } );
419
420
    return editor;
421
  }
422
423
  /**
424
   * Adds the HTML preview tab to its own tab pane. This will only add the
425
   * preview once.
426
   */
427
  public void viewPreview() {
428
    final var tabPane = obtainDetachableTabPane( TEXT_HTML );
429
430
    // Prevent multiple HTML previews because in the end, there can be only one.
431
    for( final var tab : tabPane.getTabs() ) {
432
      if( tab.getContent() == mHtmlPreview ) {
433
        return;
434
      }
435
    }
436
437
    tabPane.addTab( "HTML", mHtmlPreview );
438
    addTabPane( tabPane );
439
  }
440
441
  public void viewRefresh() {
442
    mHtmlPreview.refresh();
443
  }
444
445
  /**
446
   * Returns the tab that contains the given {@link TextEditor}.
447
   *
448
   * @param editor The {@link TextEditor} instance to find amongst the tabs.
449
   * @return The first tab having content that matches the given tab.
450
   */
451
  private Optional<Tab> getTab( final TextEditor editor ) {
452
    return mTabPanes.values()
453
                    .stream()
454
                    .flatMap( pane -> pane.getTabs().stream() )
455
                    .filter( tab -> editor.equals( tab.getContent() ) )
456
                    .findFirst();
457
  }
458
459
  /**
460
   * Creates a new {@link DefinitionEditor} wrapped in a listener that
461
   * is used to detect when the active {@link DefinitionEditor} has changed.
462
   * Upon changing, the {@link #mResolvedMap} is updated and the active
463
   * text editor is refreshed.
464
   *
465
   * @param editor Text editor to update with the revised resolved map.
466
   * @return A newly configured property that represents the active
467
   * {@link DefinitionEditor}, never null.
468
   */
469
  private ObjectProperty<TextDefinition> createActiveDefinitionEditor(
470
    final ObjectProperty<TextEditor> editor ) {
471
    final var definitions = new SimpleObjectProperty<TextDefinition>();
472
    definitions.addListener( ( c, o, n ) -> {
473
      resolve( n == null ? createDefinitionEditor() : n );
474
      process( editor.get() );
475
    } );
476
477
    return definitions;
478
  }
479
480
  /**
481
   * Instantiates a factory that's responsible for creating new scenes when
482
   * a tab is dropped outside of any application window. The definition tabs
483
   * are fairly complex in that only one may be active at any time. When
484
   * activated, the {@link #mResolvedMap} must be updated to reflect the
485
   * hierarchy displayed in the {@link DefinitionEditor}.
486
   *
487
   * @param activeDefinitionEditor The current {@link DefinitionEditor}.
488
   * @return An object that listens to {@link DefinitionEditor} tab focus
489
   * changes.
490
   */
491
  private DefinitionTabSceneFactory createDefinitionTabSceneFactory(
492
    final ObjectProperty<TextDefinition> activeDefinitionEditor ) {
493
    return new DefinitionTabSceneFactory( ( tab ) -> {
494
      assert tab != null;
495
496
      var node = tab.getContent();
497
      if( node instanceof TextDefinition ) {
498
        activeDefinitionEditor.set( (DefinitionEditor) node );
499
      }
500
    } );
501
  }
502
503
  private DetachableTab createTab( final File file ) {
504
    final var r = createTextResource( file );
505
    final var tab = new DetachableTab( r.getFilename(), r.getNode() );
506
507
    r.modifiedProperty().addListener(
508
      ( c, o, n ) -> tab.setText( r.getFilename() + (n ? "*" : "") )
509
    );
510
511
    // This is called when either the tab is closed by the user clicking on
512
    // the tab's close icon or when closing (all) from the file menu.
513
    tab.setOnClosed(
514
      ( __ ) -> getRecentFiles().remove( file.getAbsolutePath() )
515
    );
516
517
    return tab;
518
  }
519
520
  /**
521
   * Creates bins for the different {@link MediaType}s, which eventually are
522
   * added to the UI as separate tab panes. If ever a general-purpose scene
523
   * exporter is developed to serialize a scene to an FXML file, this could
524
   * be replaced by such a class.
525
   * <p>
526
   * When binning the files, this makes sure that at least one file exists
527
   * for every type. If the user has opted to close a particular type (such
528
   * as the definition pane), the view will suppressed elsewhere.
529
   * </p>
530
   * <p>
531
   * The order that the binned files are returned will be reflected in the
532
   * order that the corresponding panes are rendered in the UI.
533
   * </p>
534
   *
535
   * @param paths The file paths to bin according to their type.
536
   * @return An in-order list of files, first by structured definition files,
537
   * then by plain text documents.
538
   */
539
  private List<File> bin( final SetProperty<String> paths ) {
540
    // Treat all files destined for the text editor as plain text documents
541
    // so that they are added to the same pane. Grouping by TEXT_PLAIN is a
542
    // bit arbitrary, but means explicitly capturing TEXT_PLAIN isn't needed.
543
    final Function<MediaType, MediaType> bin =
544
      m -> PLAIN_TEXT_FORMAT.contains( m ) ? TEXT_PLAIN : m;
545
546
    // Create two groups: YAML files and plain text files.
547
    final var bins = paths
548
      .stream()
549
      .collect(
550
        groupingBy( path -> bin.apply( MediaType.valueFrom( path ) ) )
551
      );
552
553
    bins.putIfAbsent( TEXT_YAML, List.of( DEFINITION_DEFAULT.toString() ) );
554
    bins.putIfAbsent( TEXT_PLAIN, List.of( DOCUMENT_DEFAULT.toString() ) );
555
556
    final var result = new ArrayList<File>( paths.size() );
557
558
    // Ensure that the same types are listed together (keep insertion order).
559
    bins.forEach( ( mediaType, files ) -> result.addAll(
560
      files.stream().map( File::new ).collect( Collectors.toList() ) )
561
    );
562
563
    return result;
564
  }
565
566
  /**
567
   * Uses the given {@link TextDefinition} instance to update the
568
   * {@link #mResolvedMap}.
569
   *
570
   * @param editor A non-null, possibly empty definition editor.
571
   */
572
  private void resolve( final TextDefinition editor ) {
573
    assert editor != null;
574
575
    final var tokens = createDefinitionTokens();
576
    final var operator = new YamlSigilOperator( tokens );
577
    final var map = new HashMap<String, String>();
578
579
    editor.toMap().forEach( ( k, v ) -> map.put( operator.entoken( k ), v ) );
580
581
    mResolvedMap.clear();
582
    mResolvedMap.putAll( editor.interpolate( map, tokens ) );
583
  }
584
585
  /**
586
   * Force the active editor to update, which will cause the processor
587
   * to re-evaluate the interpolated definition map thereby updating the
588
   * preview pane.
589
   *
590
   * @param editor Contains the source document to update in the preview pane.
591
   */
592
  private void process( final TextEditor editor ) {
593
    mProcessors.getOrDefault( editor, IdentityProcessor.INSTANCE )
594
               .apply( editor == null ? "" : editor.getText() );
595
    mHtmlPreview.scrollTo( CARET_ID );
596
  }
597
598
  /**
599
   * Lazily creates a {@link DetachableTabPane} configured to handle focus
600
   * requests by delegating to the selected tab's content. The tab pane is
601
   * associated with a given media type so that similar files can be grouped
602
   * together.
603
   *
604
   * @param mediaType The media type to associate with the tab pane.
605
   * @return An instance of {@link DetachableTabPane} that will handle
606
   * docking of tabs.
607
   */
608
  private DetachableTabPane obtainDetachableTabPane(
609
    final MediaType mediaType ) {
610
    return mTabPanes.computeIfAbsent(
611
      mediaType, ( mt ) -> createDetachableTabPane()
612
    );
613
  }
614
615
  /**
616
   * Creates an initialized {@link DetachableTabPane} instance.
617
   *
618
   * @return A new {@link DetachableTabPane} with all listeners configured.
619
   */
620
  private DetachableTabPane createDetachableTabPane() {
621
    final var tabPane = new DetachableTabPane();
622
623
    initStageOwnerFactory( tabPane );
624
    initTabListener( tabPane );
625
    initSelectionModelListener( tabPane );
626
627
    return tabPane;
628
  }
629
630
  /**
631
   * When any {@link DetachableTabPane} is detached from the main window,
632
   * the stage owner factory must be given its parent window, which will
633
   * own the child window. The parent window is the {@link MainPane}'s
634
   * {@link Scene}'s {@link Window} instance.
635
   *
636
   * <p>
637
   * This will derives the new title from the main window title, incrementing
638
   * the window count to help uniquely identify the child windows.
639
   * </p>
640
   *
641
   * @param tabPane A new {@link DetachableTabPane} to configure.
642
   */
643
  private void initStageOwnerFactory( final DetachableTabPane tabPane ) {
644
    tabPane.setStageOwnerFactory( ( stage ) -> {
645
      final var title = get(
646
        "Detach.tab.title",
647
        ((Stage) getWindow()).getTitle(), ++mWindowCount
648
      );
649
      stage.setTitle( title );
650
      return getScene().getWindow();
651
    } );
652
  }
653
654
  /**
655
   * Responsible for configuring the content of each {@link DetachableTab} when
656
   * it is added to the given {@link DetachableTabPane} instance.
657
   * <p>
658
   * For {@link TextEditor} contents, an instance of {@link ScrollEventHandler}
659
   * is initialized to perform synchronized scrolling between the editor and
660
   * its preview window. Additionally, the last tab in the tab pane's list of
661
   * tabs is given focus.
662
   * </p>
663
   * <p>
664
   * Note that multiple tabs can be added simultaneously.
665
   * </p>
666
   *
667
   * @param tabPane A new {@link DetachableTabPane} to configure.
668
   */
669
  private void initTabListener( final DetachableTabPane tabPane ) {
670
    tabPane.getTabs().addListener(
671
      ( final ListChangeListener.Change<? extends Tab> listener ) -> {
672
        while( listener.next() ) {
673
          if( listener.wasAdded() ) {
674
            final var tabs = listener.getAddedSubList();
675
676
            tabs.forEach( ( tab ) -> {
677
              final var node = tab.getContent();
678
679
              if( node instanceof TextEditor ) {
680
                initScrollEventListener( tab );
681
              }
682
            } );
683
684
            // Select and give focus to the last tab opened.
685
            final var index = tabs.size() - 1;
686
            if( index >= 0 ) {
687
              final var tab = tabs.get( index );
688
              tabPane.getSelectionModel().select( tab );
689
              tab.getContent().requestFocus();
690
            }
691
          }
692
        }
693
      }
694
    );
695
  }
696
697
  /**
698
   * Responsible for handling tab change events.
699
   *
700
   * @param tabPane A new {@link DetachableTabPane} to configure.
701
   */
702
  private void initSelectionModelListener( final DetachableTabPane tabPane ) {
703
    final var model = tabPane.getSelectionModel();
704
705
    model.selectedItemProperty().addListener( ( c, o, n ) -> {
706
      if( o != null && n == null ) {
707
        final var node = o.getContent();
708
709
        // If the last definition editor in the active pane was closed,
710
        // clear out the definitions then refresh the text editor.
711
        if( node instanceof TextDefinition ) {
712
          mActiveDefinitionEditor.set( createDefinitionEditor() );
713
        }
714
      }
715
      else if( n != null ) {
716
        final var node = n.getContent();
717
718
        if( node instanceof TextEditor ) {
719
          // Changing the active node will fire an event, which will
720
          // update the preview panel and grab focus.
721
          mActiveTextEditor.set( (TextEditor) node );
722
          runLater( node::requestFocus );
723
        }
724
        else if( node instanceof TextDefinition ) {
725
          mActiveDefinitionEditor.set( (DefinitionEditor) node );
726
        }
727
      }
728
    } );
729
  }
730
731
  /**
732
   * Synchronizes scrollbar positions between the given {@link Tab} that
733
   * contains an instance of {@link TextEditor} and {@link HtmlPreview} pane.
734
   *
735
   * @param tab The container for an instance of {@link TextEditor}.
736
   */
737
  private void initScrollEventListener( final Tab tab ) {
738
    final var editor = (TextEditor) tab.getContent();
739
    final var scrollPane = editor.getScrollPane();
740
    final var scrollBar = mHtmlPreview.getVerticalScrollBar();
741
    final var handler = new ScrollEventHandler( scrollPane, scrollBar );
742
    handler.enabledProperty().bind( tab.selectedProperty() );
743
  }
744
745
  private void addTabPane( final int index, final DetachableTabPane tabPane ) {
746
    final var items = getItems();
747
    if( !items.contains( tabPane ) ) {
748
      items.add( index, tabPane );
749
    }
750
  }
751
752
  private void addTabPane( final DetachableTabPane tabPane ) {
753
    addTabPane( getItems().size(), tabPane );
754
  }
755
756
  /**
757
   * @param path  Used by {@link ProcessorFactory} to determine
758
   *              {@link Processor} type to create based on file type.
759
   * @param caret Used by {@link CaretExtension} to add ID attribute into
760
   *              preview document for scrollbar synchronization.
761
   * @return A new {@link ProcessorContext} to use when creating an instance of
762
   * {@link Processor}.
763
   */
764
  private ProcessorContext createProcessorContext(
765
    final Path path, final Caret caret ) {
766
    return new ProcessorContext(
767
      mHtmlPreview, mResolvedMap, path, caret, NONE, mWorkspace
768
    );
769
  }
770
771
  public ProcessorContext createProcessorContext( final TextEditor t ) {
772
    return createProcessorContext( t.getPath(), t.getCaret() );
773
  }
774
775
  private TextResource createTextResource( final File file ) {
776
    // TODO: Create PlainTextEditor that's returned by default.
777
    return MediaType.valueFrom( file ) == TEXT_YAML
778
      ? createDefinitionEditor( file )
779
      : createMarkdownEditor( file );
780
  }
781
782
  /**
783
   * Creates an instance of {@link MarkdownEditor} that listens for both
784
   * caret change events and text change events. Text change events must
785
   * take priority over caret change events because it's possible to change
786
   * the text without moving the caret (e.g., delete selected text).
787
   *
788
   * @param file The file containing contents for the text editor.
789
   * @return A non-null text editor.
790
   */
791
  private TextResource createMarkdownEditor( final File file ) {
792
    final var path = file.toPath();
793
    final var editor = new MarkdownEditor( file, getWorkspace() );
794
    final var caret = editor.getCaret();
795
    final var context = createProcessorContext( path, caret );
796
797
    mProcessors.computeIfAbsent( editor, p -> createProcessors( context ) );
798
799
    editor.addDirtyListener( ( c, o, n ) -> {
800
      if( n ) {
801
        // Reset the status to OK after changing the text.
802
        clue();
803
804
        // Processing the text will update the status bar.
805
        process( getActiveTextEditor() );
806
      }
807
    } );
808
809
    editor.addEventListener(
810
      keyPressed( SPACE, CONTROL_DOWN ), this::autoinsert
811
    );
812
813
    // Set the active editor, which refreshes the preview panel.
814
    mActiveTextEditor.set( editor );
815
816
    return editor;
817
  }
818
819
  /**
820
   * Delegates to {@link #autoinsert()}.
821
   *
822
   * @param event Ignored.
823
   */
824
  private void autoinsert( final KeyEvent event ) {
825
    autoinsert();
826
  }
827
828
  /**
829
   * Finds a node that matches the word at the caret, then inserts the
830
   * corresponding definition. The definition token delimiters depend on
831
   * the type of file being edited.
832
   */
833
  public void autoinsert() {
834
    final var definitions = getActiveTextDefinition();
835
    final var editor = getActiveTextEditor();
836
    final var mediaType = editor.getMediaType();
837
    final var operator = getSigilOperator( mediaType );
838
839
    DefinitionNameInjector.autoinsert( editor, definitions, operator );
840
  }
841
842
  private TextDefinition createDefinitionEditor() {
843
    return createDefinitionEditor( DEFINITION_DEFAULT );
844
  }
845
846
  private TextDefinition createDefinitionEditor( final File file ) {
847
    final var transformer = createTreeTransformer();
848
    final var editor = new DefinitionEditor( file, transformer );
849
850
    editor.addTreeChangeHandler( mTreeHandler );
851
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite;
3
4
import com.keenwrite.editors.TextDefinition;
5
import com.keenwrite.editors.TextEditor;
6
import com.keenwrite.editors.TextResource;
7
import com.keenwrite.editors.definition.DefinitionEditor;
8
import com.keenwrite.editors.definition.DefinitionTabSceneFactory;
9
import com.keenwrite.editors.definition.TreeTransformer;
10
import com.keenwrite.editors.definition.yaml.YamlTreeTransformer;
11
import com.keenwrite.editors.markdown.MarkdownEditor;
12
import com.keenwrite.io.MediaType;
13
import com.keenwrite.preferences.Key;
14
import com.keenwrite.preferences.Workspace;
15
import com.keenwrite.preview.HtmlPreview;
16
import com.keenwrite.processors.IdentityProcessor;
17
import com.keenwrite.processors.Processor;
18
import com.keenwrite.processors.ProcessorContext;
19
import com.keenwrite.processors.ProcessorFactory;
20
import com.keenwrite.processors.markdown.extensions.caret.CaretExtension;
21
import com.keenwrite.service.events.Notifier;
22
import com.keenwrite.sigils.RSigilOperator;
23
import com.keenwrite.sigils.SigilOperator;
24
import com.keenwrite.sigils.Tokens;
25
import com.keenwrite.sigils.YamlSigilOperator;
26
import com.panemu.tiwulfx.control.dock.DetachableTab;
27
import com.panemu.tiwulfx.control.dock.DetachableTabPane;
28
import javafx.beans.property.*;
29
import javafx.collections.ListChangeListener;
30
import javafx.event.ActionEvent;
31
import javafx.event.Event;
32
import javafx.event.EventHandler;
33
import javafx.scene.Scene;
34
import javafx.scene.control.SplitPane;
35
import javafx.scene.control.Tab;
36
import javafx.scene.control.Tooltip;
37
import javafx.scene.control.TreeItem.TreeModificationEvent;
38
import javafx.scene.input.KeyEvent;
39
import javafx.stage.Stage;
40
import javafx.stage.Window;
41
42
import java.io.File;
43
import java.nio.file.Path;
44
import java.util.*;
45
import java.util.concurrent.atomic.AtomicBoolean;
46
import java.util.function.Function;
47
import java.util.stream.Collectors;
48
49
import static com.keenwrite.Constants.*;
50
import static com.keenwrite.ExportFormat.NONE;
51
import static com.keenwrite.Messages.get;
52
import static com.keenwrite.StatusNotifier.clue;
53
import static com.keenwrite.io.MediaType.*;
54
import static com.keenwrite.preferences.Workspace.*;
55
import static com.keenwrite.processors.ProcessorFactory.createProcessors;
56
import static com.keenwrite.service.events.Notifier.NO;
57
import static com.keenwrite.service.events.Notifier.YES;
58
import static java.util.stream.Collectors.groupingBy;
59
import static javafx.application.Platform.runLater;
60
import static javafx.scene.control.TabPane.TabClosingPolicy.ALL_TABS;
61
import static javafx.scene.input.KeyCode.SPACE;
62
import static javafx.scene.input.KeyCombination.CONTROL_DOWN;
63
import static javafx.util.Duration.millis;
64
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
65
66
/**
67
 * Responsible for wiring together the main application components for a
68
 * particular workspace (project). These include the definition views,
69
 * text editors, and preview pane along with any corresponding controllers.
70
 */
71
public final class MainPane extends SplitPane {
72
  private static final Notifier sNotifier = Services.load( Notifier.class );
73
74
  /**
75
   * Used when opening files to determine how each file should be binned and
76
   * therefore what tab pane to be opened within.
77
   */
78
  private static final Set<MediaType> PLAIN_TEXT_FORMAT = Set.of(
79
    TEXT_MARKDOWN, TEXT_R_MARKDOWN, TEXT_R_XML, UNDEFINED
80
  );
81
82
  /**
83
   * Prevents re-instantiation of processing classes.
84
   */
85
  private final Map<TextResource, Processor<String>> mProcessors =
86
    new HashMap<>();
87
88
  private final Workspace mWorkspace;
89
90
  /**
91
   * Groups similar file type tabs together.
92
   */
93
  private final Map<MediaType, DetachableTabPane> mTabPanes = new HashMap<>();
94
95
  /**
96
   * Stores definition names and values.
97
   */
98
  private final Map<String, String> mResolvedMap =
99
    new HashMap<>( MAP_SIZE_DEFAULT );
100
101
  /**
102
   * Renders the actively selected plain text editor tab.
103
   */
104
  private final HtmlPreview mHtmlPreview;
105
106
  /**
107
   * Changing the active editor fires the value changed event. This allows
108
   * refreshes to happen when external definitions are modified and need to
109
   * trigger the processing chain.
110
   */
111
  private final ObjectProperty<TextEditor> mActiveTextEditor =
112
    createActiveTextEditor();
113
114
  /**
115
   * Changing the active definition editor fires the value changed event. This
116
   * allows refreshes to happen when external definitions are modified and need
117
   * to trigger the processing chain.
118
   */
119
  private final ObjectProperty<TextDefinition> mActiveDefinitionEditor =
120
    createActiveDefinitionEditor( mActiveTextEditor );
121
122
  /**
123
   * Responsible for creating a new scene when a tab is detached into
124
   * its own window frame.
125
   */
126
  private final DefinitionTabSceneFactory mDefinitionTabSceneFactory =
127
    createDefinitionTabSceneFactory( mActiveDefinitionEditor );
128
129
  /**
130
   * Tracks the number of detached tab panels opened into their own windows,
131
   * which allows unique identification of subordinate windows by their title.
132
   * It is doubtful more than 128 windows, much less 256, will be created.
133
   */
134
  private byte mWindowCount;
135
136
  /**
137
   * Called when the definition data is changed.
138
   */
139
  private final EventHandler<TreeModificationEvent<Event>> mTreeHandler =
140
    event -> {
141
      final var editor = mActiveDefinitionEditor.get();
142
143
      resolve( editor );
144
      process( getActiveTextEditor() );
145
      save( editor );
146
    };
147
148
  /**
149
   * Adds all content panels to the main user interface. This will load the
150
   * configuration settings from the workspace to reproduce the settings from
151
   * a previous session.
152
   */
153
  public MainPane( final Workspace workspace ) {
154
    mWorkspace = workspace;
155
    mHtmlPreview = new HtmlPreview( workspace );
156
157
    open( bin( getRecentFiles() ) );
158
    viewPreview();
159
    setDividerPositions( calculateDividerPositions() );
160
161
    // Once the main scene's window regains focus, update the active definition
162
    // editor to the currently selected tab.
163
    runLater(
164
      () -> getWindow().focusedProperty().addListener( ( c, o, n ) -> {
165
        if( n != null && n ) {
166
          final var pane = mTabPanes.get( TEXT_YAML );
167
          final var model = pane.getSelectionModel();
168
          final var tab = model.getSelectedItem();
169
170
          if( tab != null ) {
171
            final var resource = tab.getContent();
172
173
            if( resource instanceof TextDefinition ) {
174
              mActiveDefinitionEditor.set( (TextDefinition) tab.getContent() );
175
            }
176
          }
177
        }
178
      } )
179
    );
180
  }
181
182
  /**
183
   * TODO: Load divider positions from exported settings, see bin() comment.
184
   */
185
  private double[] calculateDividerPositions() {
186
    final var ratio = 100f / getItems().size() / 100;
187
    final var positions = getDividerPositions();
188
189
    for( int i = 0; i < positions.length; i++ ) {
190
      positions[ i ] = ratio * i;
191
    }
192
193
    return positions;
194
  }
195
196
  /**
197
   * Opens all the files into the application, provided the paths are unique.
198
   * This may only be called for any type of files that a user can edit
199
   * (i.e., update and persist), such as definitions and text files.
200
   *
201
   * @param files The list of files to open.
202
   */
203
  public void open( final List<File> files ) {
204
    files.forEach( this::open );
205
  }
206
207
  /**
208
   * This opens the given file. Since the preview pane is not a file that
209
   * can be opened, it is safe to add a listener to the detachable pane.
210
   *
211
   * @param file The file to open.
212
   */
213
  private void open( final File file ) {
214
    final var tab = createTab( file );
215
    final var node = tab.getContent();
216
    final var mediaType = MediaType.valueFrom( file );
217
    final var tabPane = obtainDetachableTabPane( mediaType );
218
    final var newTabPane = !getItems().contains( tabPane );
219
220
    tab.setTooltip( createTooltip( file ) );
221
    tabPane.setFocusTraversable( false );
222
    tabPane.setTabClosingPolicy( ALL_TABS );
223
    tabPane.getTabs().add( tab );
224
225
    if( newTabPane ) {
226
      var index = getItems().size();
227
228
      if( node instanceof TextDefinition ) {
229
        tabPane.setSceneFactory( mDefinitionTabSceneFactory::create );
230
        index = 0;
231
      }
232
233
      addTabPane( index, tabPane );
234
    }
235
236
    getRecentFiles().add( file.getAbsolutePath() );
237
  }
238
239
  /**
240
   * Opens a new text editor document using the default document file name.
241
   */
242
  public void newTextEditor() {
243
    open( DOCUMENT_DEFAULT );
244
  }
245
246
  /**
247
   * Opens a new definition editor document using the default definition
248
   * file name.
249
   */
250
  public void newDefinitionEditor() {
251
    open( DEFINITION_DEFAULT );
252
  }
253
254
  /**
255
   * Iterates over all tab panes to find all {@link TextEditor}s and request
256
   * that they save themselves.
257
   */
258
  public void saveAll() {
259
    mTabPanes.forEach(
260
      ( mt, tp ) -> tp.getTabs().forEach( ( tab ) -> {
261
        final var node = tab.getContent();
262
        if( node instanceof TextEditor ) {
263
          save( ((TextEditor) node) );
264
        }
265
      } )
266
    );
267
  }
268
269
  /**
270
   * Requests that the active {@link TextEditor} saves itself. Don't bother
271
   * checking if modified first because if the user swaps external media from
272
   * an external source (e.g., USB thumb drive), save should not second-guess
273
   * the user: save always re-saves. Also, it's less code.
274
   */
275
  public void save() {
276
    save( getActiveTextEditor() );
277
  }
278
279
  /**
280
   * Saves the active {@link TextEditor} under a new name.
281
   *
282
   * @param file The new active editor {@link File} reference.
283
   */
284
  public void saveAs( final File file ) {
285
    assert file != null;
286
    final var editor = getActiveTextEditor();
287
    final var tab = getTab( editor );
288
289
    editor.rename( file );
290
    tab.ifPresent( t -> {
291
      t.setText( editor.getFilename() );
292
      t.setTooltip( createTooltip( file ) );
293
    } );
294
295
    save();
296
  }
297
298
  /**
299
   * Saves the given {@link TextResource} to a file. This is typically used
300
   * to save either an instance of {@link TextEditor} or {@link TextDefinition}.
301
   *
302
   * @param resource The resource to export.
303
   */
304
  private void save( final TextResource resource ) {
305
    try {
306
      resource.save();
307
    } catch( final Exception ex ) {
308
      clue( ex );
309
      sNotifier.alert(
310
        getWindow(), resource.getPath(), "TextResource.saveFailed", ex
311
      );
312
    }
313
  }
314
315
  /**
316
   * Closes all open {@link TextEditor}s; all {@link TextDefinition}s stay open.
317
   *
318
   * @return {@code true} when all editors, modified or otherwise, were
319
   * permitted to close; {@code false} when one or more editors were modified
320
   * and the user requested no closing.
321
   */
322
  public boolean closeAll() {
323
    var closable = true;
324
325
    for( final var entry : mTabPanes.entrySet() ) {
326
      final var tabPane = entry.getValue();
327
      final var tabIterator = tabPane.getTabs().iterator();
328
329
      while( tabIterator.hasNext() ) {
330
        final var tab = tabIterator.next();
331
        final var node = tab.getContent();
332
333
        if( node instanceof TextEditor &&
334
          (closable &= canClose( (TextEditor) node )) ) {
335
          tabIterator.remove();
336
          close( tab );
337
        }
338
      }
339
    }
340
341
    return closable;
342
  }
343
344
  /**
345
   * Calls the tab's {@link Tab#getOnClosed()} handler to carry out a close
346
   * event.
347
   *
348
   * @param tab The {@link Tab} that was closed.
349
   */
350
  private void close( final Tab tab ) {
351
    final var handler = tab.getOnClosed();
352
353
    if( handler != null ) {
354
      handler.handle( new ActionEvent() );
355
    }
356
  }
357
358
  /**
359
   * Closes the active tab; delegates to {@link #canClose(TextEditor)}.
360
   */
361
  public void close() {
362
    final var editor = getActiveTextEditor();
363
    if( canClose( editor ) ) {
364
      close( editor );
365
    }
366
  }
367
368
  /**
369
   * Closes the given {@link TextEditor}. This must not be called from within
370
   * a loop that iterates over the tab panes using {@code forEach}, lest a
371
   * concurrent modification exception be thrown.
372
   *
373
   * @param editor The {@link TextEditor} to close, without confirming with
374
   *               the user.
375
   */
376
  private void close( final TextEditor editor ) {
377
    getTab( editor ).ifPresent(
378
      ( tab ) -> {
379
        tab.getTabPane().getTabs().remove( tab );
380
        close( tab );
381
      }
382
    );
383
  }
384
385
  /**
386
   * Answers whether the given {@link TextEditor} may be closed.
387
   *
388
   * @param editor The {@link TextEditor} to try closing.
389
   * @return {@code true} when the editor may be closed; {@code false} when
390
   * the user has requested to keep the editor open.
391
   */
392
  private boolean canClose( final TextEditor editor ) {
393
    final var editorTab = getTab( editor );
394
    final var canClose = new AtomicBoolean( true );
395
396
    if( editor.isModified() ) {
397
      final var filename = new StringBuilder();
398
      editorTab.ifPresent( ( tab ) -> filename.append( tab.getText() ) );
399
400
      final var message = sNotifier.createNotification(
401
        Messages.get( "Alert.file.close.title" ),
402
        Messages.get( "Alert.file.close.text" ),
403
        filename.toString()
404
      );
405
406
      final var dialog = sNotifier.createConfirmation( getWindow(), message );
407
408
      dialog.showAndWait().ifPresent(
409
        save -> canClose.set( save == YES ? editor.save() : save == NO )
410
      );
411
    }
412
413
    return canClose.get();
414
  }
415
416
  private ObjectProperty<TextEditor> createActiveTextEditor() {
417
    final var editor = new SimpleObjectProperty<TextEditor>();
418
419
    editor.addListener( ( c, o, n ) -> {
420
      if( n != null ) {
421
        mHtmlPreview.setBaseUri( n.getPath() );
422
        process( n );
423
      }
424
    } );
425
426
    return editor;
427
  }
428
429
  /**
430
   * Adds the HTML preview tab to its own tab pane. This will only add the
431
   * preview once.
432
   */
433
  public void viewPreview() {
434
    final var tabPane = obtainDetachableTabPane( TEXT_HTML );
435
436
    // Prevent multiple HTML previews because in the end, there can be only one.
437
    for( final var tab : tabPane.getTabs() ) {
438
      if( tab.getContent() == mHtmlPreview ) {
439
        return;
440
      }
441
    }
442
443
    tabPane.addTab( "HTML", mHtmlPreview );
444
    addTabPane( tabPane );
445
  }
446
447
  public void viewRefresh() {
448
    mHtmlPreview.refresh();
449
  }
450
451
  /**
452
   * Returns the tab that contains the given {@link TextEditor}.
453
   *
454
   * @param editor The {@link TextEditor} instance to find amongst the tabs.
455
   * @return The first tab having content that matches the given tab.
456
   */
457
  private Optional<Tab> getTab( final TextEditor editor ) {
458
    return mTabPanes.values()
459
                    .stream()
460
                    .flatMap( pane -> pane.getTabs().stream() )
461
                    .filter( tab -> editor.equals( tab.getContent() ) )
462
                    .findFirst();
463
  }
464
465
  /**
466
   * Creates a new {@link DefinitionEditor} wrapped in a listener that
467
   * is used to detect when the active {@link DefinitionEditor} has changed.
468
   * Upon changing, the {@link #mResolvedMap} is updated and the active
469
   * text editor is refreshed.
470
   *
471
   * @param editor Text editor to update with the revised resolved map.
472
   * @return A newly configured property that represents the active
473
   * {@link DefinitionEditor}, never null.
474
   */
475
  private ObjectProperty<TextDefinition> createActiveDefinitionEditor(
476
    final ObjectProperty<TextEditor> editor ) {
477
    final var definitions = new SimpleObjectProperty<TextDefinition>();
478
    definitions.addListener( ( c, o, n ) -> {
479
      resolve( n == null ? createDefinitionEditor() : n );
480
      process( editor.get() );
481
    } );
482
483
    return definitions;
484
  }
485
486
  /**
487
   * Instantiates a factory that's responsible for creating new scenes when
488
   * a tab is dropped outside of any application window. The definition tabs
489
   * are fairly complex in that only one may be active at any time. When
490
   * activated, the {@link #mResolvedMap} must be updated to reflect the
491
   * hierarchy displayed in the {@link DefinitionEditor}.
492
   *
493
   * @param activeDefinitionEditor The current {@link DefinitionEditor}.
494
   * @return An object that listens to {@link DefinitionEditor} tab focus
495
   * changes.
496
   */
497
  private DefinitionTabSceneFactory createDefinitionTabSceneFactory(
498
    final ObjectProperty<TextDefinition> activeDefinitionEditor ) {
499
    return new DefinitionTabSceneFactory( ( tab ) -> {
500
      assert tab != null;
501
502
      var node = tab.getContent();
503
      if( node instanceof TextDefinition ) {
504
        activeDefinitionEditor.set( (DefinitionEditor) node );
505
      }
506
    } );
507
  }
508
509
  private DetachableTab createTab( final File file ) {
510
    final var r = createTextResource( file );
511
    final var tab = new DetachableTab( r.getFilename(), r.getNode() );
512
513
    r.modifiedProperty().addListener(
514
      ( c, o, n ) -> tab.setText( r.getFilename() + (n ? "*" : "") )
515
    );
516
517
    // This is called when either the tab is closed by the user clicking on
518
    // the tab's close icon or when closing (all) from the file menu.
519
    tab.setOnClosed(
520
      ( __ ) -> getRecentFiles().remove( file.getAbsolutePath() )
521
    );
522
523
    return tab;
524
  }
525
526
  /**
527
   * Creates bins for the different {@link MediaType}s, which eventually are
528
   * added to the UI as separate tab panes. If ever a general-purpose scene
529
   * exporter is developed to serialize a scene to an FXML file, this could
530
   * be replaced by such a class.
531
   * <p>
532
   * When binning the files, this makes sure that at least one file exists
533
   * for every type. If the user has opted to close a particular type (such
534
   * as the definition pane), the view will suppressed elsewhere.
535
   * </p>
536
   * <p>
537
   * The order that the binned files are returned will be reflected in the
538
   * order that the corresponding panes are rendered in the UI.
539
   * </p>
540
   *
541
   * @param paths The file paths to bin according to their type.
542
   * @return An in-order list of files, first by structured definition files,
543
   * then by plain text documents.
544
   */
545
  private List<File> bin( final SetProperty<String> paths ) {
546
    // Treat all files destined for the text editor as plain text documents
547
    // so that they are added to the same pane. Grouping by TEXT_PLAIN is a
548
    // bit arbitrary, but means explicitly capturing TEXT_PLAIN isn't needed.
549
    final Function<MediaType, MediaType> bin =
550
      m -> PLAIN_TEXT_FORMAT.contains( m ) ? TEXT_PLAIN : m;
551
552
    // Create two groups: YAML files and plain text files.
553
    final var bins = paths
554
      .stream()
555
      .collect(
556
        groupingBy( path -> bin.apply( MediaType.valueFrom( path ) ) )
557
      );
558
559
    bins.putIfAbsent( TEXT_YAML, List.of( DEFINITION_DEFAULT.toString() ) );
560
    bins.putIfAbsent( TEXT_PLAIN, List.of( DOCUMENT_DEFAULT.toString() ) );
561
562
    final var result = new ArrayList<File>( paths.size() );
563
564
    // Ensure that the same types are listed together (keep insertion order).
565
    bins.forEach( ( mediaType, files ) -> result.addAll(
566
      files.stream().map( File::new ).collect( Collectors.toList() ) )
567
    );
568
569
    return result;
570
  }
571
572
  /**
573
   * Uses the given {@link TextDefinition} instance to update the
574
   * {@link #mResolvedMap}.
575
   *
576
   * @param editor A non-null, possibly empty definition editor.
577
   */
578
  private void resolve( final TextDefinition editor ) {
579
    assert editor != null;
580
581
    final var tokens = createDefinitionTokens();
582
    final var operator = new YamlSigilOperator( tokens );
583
    final var map = new HashMap<String, String>();
584
585
    editor.toMap().forEach( ( k, v ) -> map.put( operator.entoken( k ), v ) );
586
587
    mResolvedMap.clear();
588
    mResolvedMap.putAll( editor.interpolate( map, tokens ) );
589
  }
590
591
  /**
592
   * Force the active editor to update, which will cause the processor
593
   * to re-evaluate the interpolated definition map thereby updating the
594
   * preview pane.
595
   *
596
   * @param editor Contains the source document to update in the preview pane.
597
   */
598
  private void process( final TextEditor editor ) {
599
    mProcessors.getOrDefault( editor, IdentityProcessor.IDENTITY )
600
               .apply( editor == null ? "" : editor.getText() );
601
    mHtmlPreview.scrollTo( CARET_ID );
602
  }
603
604
  /**
605
   * Lazily creates a {@link DetachableTabPane} configured to handle focus
606
   * requests by delegating to the selected tab's content. The tab pane is
607
   * associated with a given media type so that similar files can be grouped
608
   * together.
609
   *
610
   * @param mediaType The media type to associate with the tab pane.
611
   * @return An instance of {@link DetachableTabPane} that will handle
612
   * docking of tabs.
613
   */
614
  private DetachableTabPane obtainDetachableTabPane(
615
    final MediaType mediaType ) {
616
    return mTabPanes.computeIfAbsent(
617
      mediaType, ( mt ) -> createDetachableTabPane()
618
    );
619
  }
620
621
  /**
622
   * Creates an initialized {@link DetachableTabPane} instance.
623
   *
624
   * @return A new {@link DetachableTabPane} with all listeners configured.
625
   */
626
  private DetachableTabPane createDetachableTabPane() {
627
    final var tabPane = new DetachableTabPane();
628
629
    initStageOwnerFactory( tabPane );
630
    initTabListener( tabPane );
631
    initSelectionModelListener( tabPane );
632
633
    return tabPane;
634
  }
635
636
  /**
637
   * When any {@link DetachableTabPane} is detached from the main window,
638
   * the stage owner factory must be given its parent window, which will
639
   * own the child window. The parent window is the {@link MainPane}'s
640
   * {@link Scene}'s {@link Window} instance.
641
   *
642
   * <p>
643
   * This will derives the new title from the main window title, incrementing
644
   * the window count to help uniquely identify the child windows.
645
   * </p>
646
   *
647
   * @param tabPane A new {@link DetachableTabPane} to configure.
648
   */
649
  private void initStageOwnerFactory( final DetachableTabPane tabPane ) {
650
    tabPane.setStageOwnerFactory( ( stage ) -> {
651
      final var title = get(
652
        "Detach.tab.title",
653
        ((Stage) getWindow()).getTitle(), ++mWindowCount
654
      );
655
      stage.setTitle( title );
656
      return getScene().getWindow();
657
    } );
658
  }
659
660
  /**
661
   * Responsible for configuring the content of each {@link DetachableTab} when
662
   * it is added to the given {@link DetachableTabPane} instance.
663
   * <p>
664
   * For {@link TextEditor} contents, an instance of {@link ScrollEventHandler}
665
   * is initialized to perform synchronized scrolling between the editor and
666
   * its preview window. Additionally, the last tab in the tab pane's list of
667
   * tabs is given focus.
668
   * </p>
669
   * <p>
670
   * Note that multiple tabs can be added simultaneously.
671
   * </p>
672
   *
673
   * @param tabPane A new {@link DetachableTabPane} to configure.
674
   */
675
  private void initTabListener( final DetachableTabPane tabPane ) {
676
    tabPane.getTabs().addListener(
677
      ( final ListChangeListener.Change<? extends Tab> listener ) -> {
678
        while( listener.next() ) {
679
          if( listener.wasAdded() ) {
680
            final var tabs = listener.getAddedSubList();
681
682
            tabs.forEach( ( tab ) -> {
683
              final var node = tab.getContent();
684
685
              if( node instanceof TextEditor ) {
686
                initScrollEventListener( tab );
687
              }
688
            } );
689
690
            // Select and give focus to the last tab opened.
691
            final var index = tabs.size() - 1;
692
            if( index >= 0 ) {
693
              final var tab = tabs.get( index );
694
              tabPane.getSelectionModel().select( tab );
695
              tab.getContent().requestFocus();
696
            }
697
          }
698
        }
699
      }
700
    );
701
  }
702
703
  /**
704
   * Responsible for handling tab change events.
705
   *
706
   * @param tabPane A new {@link DetachableTabPane} to configure.
707
   */
708
  private void initSelectionModelListener( final DetachableTabPane tabPane ) {
709
    final var model = tabPane.getSelectionModel();
710
711
    model.selectedItemProperty().addListener( ( c, o, n ) -> {
712
      if( o != null && n == null ) {
713
        final var node = o.getContent();
714
715
        // If the last definition editor in the active pane was closed,
716
        // clear out the definitions then refresh the text editor.
717
        if( node instanceof TextDefinition ) {
718
          mActiveDefinitionEditor.set( createDefinitionEditor() );
719
        }
720
      }
721
      else if( n != null ) {
722
        final var node = n.getContent();
723
724
        if( node instanceof TextEditor ) {
725
          // Changing the active node will fire an event, which will
726
          // update the preview panel and grab focus.
727
          mActiveTextEditor.set( (TextEditor) node );
728
          runLater( node::requestFocus );
729
        }
730
        else if( node instanceof TextDefinition ) {
731
          mActiveDefinitionEditor.set( (DefinitionEditor) node );
732
        }
733
      }
734
    } );
735
  }
736
737
  /**
738
   * Synchronizes scrollbar positions between the given {@link Tab} that
739
   * contains an instance of {@link TextEditor} and {@link HtmlPreview} pane.
740
   *
741
   * @param tab The container for an instance of {@link TextEditor}.
742
   */
743
  private void initScrollEventListener( final Tab tab ) {
744
    final var editor = (TextEditor) tab.getContent();
745
    final var scrollPane = editor.getScrollPane();
746
    final var scrollBar = mHtmlPreview.getVerticalScrollBar();
747
    final var handler = new ScrollEventHandler( scrollPane, scrollBar );
748
    handler.enabledProperty().bind( tab.selectedProperty() );
749
  }
750
751
  private void addTabPane( final int index, final DetachableTabPane tabPane ) {
752
    final var items = getItems();
753
    if( !items.contains( tabPane ) ) {
754
      items.add( index, tabPane );
755
    }
756
  }
757
758
  private void addTabPane( final DetachableTabPane tabPane ) {
759
    addTabPane( getItems().size(), tabPane );
760
  }
761
762
  public ProcessorContext createProcessorContext() {
763
    final var editor = getActiveTextEditor();
764
    return createProcessorContext( editor.getPath(), editor.getCaret() );
765
  }
766
767
  /**
768
   * @param path  Used by {@link ProcessorFactory} to determine
769
   *              {@link Processor} type to create based on file type.
770
   * @param caret Used by {@link CaretExtension} to add ID attribute into
771
   *              preview document for scrollbar synchronization.
772
   * @return A new {@link ProcessorContext} to use when creating an instance of
773
   * {@link Processor}.
774
   */
775
  private ProcessorContext createProcessorContext(
776
    final Path path, final Caret caret ) {
777
    return new ProcessorContext(
778
      mHtmlPreview, mResolvedMap, path, caret, NONE, mWorkspace
779
    );
780
  }
781
782
  private TextResource createTextResource( final File file ) {
783
    // TODO: Create PlainTextEditor that's returned by default.
784
    return MediaType.valueFrom( file ) == TEXT_YAML
785
      ? createDefinitionEditor( file )
786
      : createMarkdownEditor( file );
787
  }
788
789
  /**
790
   * Creates an instance of {@link MarkdownEditor} that listens for both
791
   * caret change events and text change events. Text change events must
792
   * take priority over caret change events because it's possible to change
793
   * the text without moving the caret (e.g., delete selected text).
794
   *
795
   * @param file The file containing contents for the text editor.
796
   * @return A non-null text editor.
797
   */
798
  private TextResource createMarkdownEditor( final File file ) {
799
    final var path = file.toPath();
800
    final var editor = new MarkdownEditor( file, getWorkspace() );
801
    final var caret = editor.getCaret();
802
    final var context = createProcessorContext( path, caret );
803
804
    mProcessors.computeIfAbsent( editor, p -> createProcessors( context ) );
805
806
    editor.addDirtyListener( ( c, o, n ) -> {
807
      if( n ) {
808
        // Reset the status to OK after changing the text.
809
        clue();
810
811
        // Processing the text will update the status bar.
812
        process( getActiveTextEditor() );
813
      }
814
    } );
815
816
    editor.addEventListener(
817
      keyPressed( SPACE, CONTROL_DOWN ), this::autoinsert
818
    );
819
820
    // Set the active editor, which refreshes the preview panel.
821
    mActiveTextEditor.set( editor );
822
823
    return editor;
824
  }
825
826
  /**
827
   * Delegates to {@link #autoinsert()}.
828
   *
829
   * @param event Ignored.
830
   */
831
  private void autoinsert( final KeyEvent event ) {
832
    autoinsert();
833
  }
834
835
  /**
836
   * Finds a node that matches the word at the caret, then inserts the
837
   * corresponding definition. The definition token delimiters depend on
838
   * the type of file being edited.
839
   */
840
  public void autoinsert() {
841
    final var definitions = getActiveTextDefinition();
842
    final var editor = getActiveTextEditor();
843
    final var mediaType = editor.getMediaType();
844
    final var operator = getSigilOperator( mediaType );
845
846
    DefinitionNameInjector.autoinsert( editor, definitions, operator );
847
  }
848
849
  private TextDefinition createDefinitionEditor() {
850
    return createDefinitionEditor( DEFINITION_DEFAULT );
851
  }
852
853
  private TextDefinition createDefinitionEditor( final File file ) {
854
    final var editor = new DefinitionEditor( file, createTreeTransformer() );
855
    editor.addTreeChangeHandler( mTreeHandler );
852856
    return editor;
853857
  }
M src/main/java/com/keenwrite/MainScene.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite;
33
44
import com.keenwrite.preferences.Workspace;
55
import com.keenwrite.ui.actions.ApplicationActions;
6
import com.keenwrite.ui.actions.ApplicationMenuBar;
76
import com.keenwrite.ui.listeners.CaretListener;
87
import javafx.scene.Node;
98
import javafx.scene.Parent;
109
import javafx.scene.Scene;
1110
import javafx.scene.layout.BorderPane;
11
import javafx.scene.layout.VBox;
1212
import org.controlsfx.control.StatusBar;
1313
1414
import static com.keenwrite.Constants.STYLESHEET_SCENE;
15
import static com.keenwrite.StatusNotifier.getStatusBar;
16
import static com.keenwrite.ui.actions.ApplicationBars.createMenuBar;
17
import static com.keenwrite.ui.actions.ApplicationBars.createToolBar;
1518
1619
/**
1720
 * Responsible for creating the bar scene: menu bar, tool bar, and status bar.
1821
 */
1922
public class MainScene {
2023
  private final Scene mScene;
24
  private final Node mMenuBar;
25
  private final Node mToolBar;
26
  private final StatusBar mStatusBar;
2127
2228
  public MainScene( final Workspace workspace ) {
2329
    final var mainPane = createMainPane( workspace );
2430
    final var actions = createApplicationActions( mainPane );
25
    final var menuBar = createMenuBar( actions );
26
    final var appPane = new BorderPane();
27
    final var statusBar = StatusBarNotifier.getStatusBar();
2831
    final var caretListener = createCaretListener( mainPane );
32
    mMenuBar = setManagedLayout( createMenuBar( actions ) );
33
    mToolBar = setManagedLayout( createToolBar() );
34
    mStatusBar = setManagedLayout( getStatusBar() );
2935
30
    statusBar.getRightItems().add( caretListener );
36
    mStatusBar.getRightItems().add( caretListener );
3137
32
    appPane.setTop( menuBar );
38
    final var appPane = new BorderPane();
39
    appPane.setTop( new VBox( mMenuBar, mToolBar ) );
3340
    appPane.setCenter( mainPane );
34
    appPane.setBottom( statusBar );
41
    appPane.setBottom( mStatusBar );
3542
3643
    mScene = createScene( appPane );
...
4552
  public Scene getScene() {
4653
    return mScene;
54
  }
55
56
  public void toggleMenuBar() {
57
    final var node = mMenuBar;
58
    node.setVisible( !node.isVisible() );
59
  }
60
61
  public void toggleToolBar() {
62
    final var node = mToolBar;
63
    node.setVisible( !node.isVisible() );
64
  }
65
66
  public void toggleStatusBar() {
67
    final var node = mStatusBar;
68
    node.setVisible( !node.isVisible() );
4769
  }
4870
4971
  private MainPane createMainPane( final Workspace workspace ) {
5072
    return new MainPane( workspace );
5173
  }
5274
5375
  private ApplicationActions createApplicationActions(
5476
    final MainPane mainPane ) {
55
    return new ApplicationActions( mainPane );
56
  }
57
58
  private Node createMenuBar( final ApplicationActions actions ) {
59
    return (new ApplicationMenuBar()).createMenuBar( actions );
77
    return new ApplicationActions( this, mainPane );
6078
  }
6179
...
7795
  private CaretListener createCaretListener( final MainPane mainPane ) {
7896
    return new CaretListener( mainPane.activeTextEditorProperty() );
97
  }
98
99
  /**
100
   * Binds the visible property of the node to the managed property so that
101
   * hiding the node also removes the screen real estate that it occupies.
102
   *
103
   * @param node The node to have its real estate bound to visibility.
104
   * @return The given node.
105
   */
106
  private <T extends Node> T setManagedLayout( final T node ) {
107
    node.managedProperty().bind( node.visibleProperty() );
108
    return node;
79109
  }
80110
}
M src/main/java/com/keenwrite/Messages.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite;
33
M src/main/java/com/keenwrite/ScrollEventHandler.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite;
33
M src/main/java/com/keenwrite/Services.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite;
33
D src/main/java/com/keenwrite/StatusBarNotifier.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite;
3
4
import com.keenwrite.service.events.Notifier;
5
import org.controlsfx.control.StatusBar;
6
7
import static com.keenwrite.Constants.STATUS_BAR_OK;
8
import static com.keenwrite.Messages.get;
9
import static javafx.application.Platform.runLater;
10
11
/**
12
 * Responsible for passing notifications about exceptions (or other error
13
 * messages) through the application. Once the Event Bus is implemented, this
14
 * class can go away.
15
 */
16
public class StatusBarNotifier {
17
  private static final String OK = get( STATUS_BAR_OK, "OK" );
18
19
  private static final Notifier sNotifier = Services.load( Notifier.class );
20
  private static final StatusBar sStatusBar = new StatusBar();
21
22
  /**
23
   * Resets the status bar to a default message.
24
   */
25
  public static void clue() {
26
    // Don't burden the repaint thread if there's no status bar change.
27
    if( !OK.equals( sStatusBar.getText() ) ) {
28
      update( OK );
29
    }
30
  }
31
32
  /**
33
   * Updates the status bar with a custom message.
34
   *
35
   * @param key The resource bundle key associated with a message (typically
36
   *            to inform the user about an error).
37
   */
38
  public static void clue( final String key ) {
39
    update( get( key ) );
40
  }
41
42
  /**
43
   * Updates the status bar with a custom message.
44
   *
45
   * @param key  The property key having a value to populate with arguments.
46
   * @param args The placeholder values to substitute into the key's value.
47
   */
48
  public static void clue( final String key, final Object... args ) {
49
    update( get( key, args ) );
50
  }
51
52
  /**
53
   * Called when an exception occurs that warrants the user's attention.
54
   *
55
   * @param t The exception with a message that the user should know about.
56
   */
57
  public static void clue( final Throwable t ) {
58
    update( t.getMessage() );
59
  }
60
61
  /**
62
   * Returns the global {@link Notifier} instance that can be used for opening
63
   * pop-up alert messages.
64
   *
65
   * @return The pop-up {@link Notifier} dispatcher.
66
   */
67
  public static Notifier getNotifier() {
68
    return sNotifier;
69
  }
70
71
  public static StatusBar getStatusBar() {
72
    return sStatusBar;
73
  }
74
75
  /**
76
   * Updates the status bar to show the first line of the given message.
77
   *
78
   * @param message The message to show in the status bar.
79
   */
80
  private static void update( final String message ) {
81
    runLater(
82
        () -> {
83
          final var s = message == null ? "" : message;
84
          final var i = s.indexOf( '\n' );
85
          sStatusBar.setText( s.substring( 0, i > 0 ? i : s.length() ) );
86
        }
87
    );
88
  }
89
}
901
A src/main/java/com/keenwrite/StatusNotifier.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite;
3
4
import com.keenwrite.service.events.Notifier;
5
import com.keenwrite.ui.logging.LogView;
6
import org.controlsfx.control.StatusBar;
7
8
import static com.keenwrite.Constants.STATUS_BAR_OK;
9
import static com.keenwrite.Messages.get;
10
import static javafx.application.Platform.runLater;
11
12
/**
13
 * Responsible for passing notifications about exceptions (or other error
14
 * messages) through the application. Once the Event Bus is implemented, this
15
 * class can go away.
16
 */
17
public class StatusNotifier {
18
  private static final String OK = get( STATUS_BAR_OK, "OK" );
19
20
  private static final Notifier sNotifier = Services.load( Notifier.class );
21
  private static final StatusBar sStatusBar = new StatusBar();
22
  private static final LogView sLogView = new LogView();
23
24
  /**
25
   * Resets the status bar to a default message.
26
   */
27
  public static void clue() {
28
    // Don't burden the repaint thread if there's no status bar change.
29
    if( !OK.equals( sStatusBar.getText() ) ) {
30
      update( OK );
31
    }
32
  }
33
34
  /**
35
   * Updates the status bar with a custom message.
36
   *
37
   * @param key  The property key having a value to populate with arguments.
38
   * @param args The placeholder values to substitute into the key's value.
39
   */
40
  public static void clue( final String key, final Object... args ) {
41
    final var message = get( key, args );
42
    update( message );
43
    sLogView.log( message );
44
  }
45
46
  /**
47
   * Update the status bar with a pre-parsed message and exception.
48
   *
49
   * @param message The custom message to log.
50
   * @param t       The exception that triggered the status update.
51
   */
52
  public static void clue( final String message, final Throwable t ) {
53
    update( message );
54
    sLogView.log( message, t );
55
  }
56
57
  /**
58
   * Called when an exception occurs that warrants the user's attention.
59
   *
60
   * @param t The exception with a message that the user should know about.
61
   */
62
  public static void clue( final Throwable t ) {
63
    update( t.getMessage() );
64
    sLogView.log( t );
65
  }
66
67
  /**
68
   * Returns the global {@link Notifier} instance that can be used for opening
69
   * pop-up alert messages.
70
   *
71
   * @return The pop-up {@link Notifier} dispatcher.
72
   */
73
  public static Notifier getNotifier() {
74
    return sNotifier;
75
  }
76
77
  public static StatusBar getStatusBar() {
78
    return sStatusBar;
79
  }
80
81
  /**
82
   * Updates the status bar to show the first line of the given message.
83
   *
84
   * @param message The message to show in the status bar.
85
   */
86
  private static void update( final String message ) {
87
    runLater(
88
      () -> {
89
        final var s = message == null ? "" : message;
90
        final var i = s.indexOf( '\n' );
91
        sStatusBar.setText( s.substring( 0, i > 0 ? i : s.length() ) );
92
      }
93
    );
94
  }
95
96
  public static void viewIssues() {
97
    sLogView.view();
98
  }
99
}
1100
M src/main/java/com/keenwrite/editors/TextDefinition.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.editors;
33
M src/main/java/com/keenwrite/editors/TextEditor.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.editors;
33
4
import com.keenwrite.processors.markdown.Caret;
4
import com.keenwrite.Caret;
55
import javafx.scene.control.IndexRange;
66
import org.fxmisc.flowless.VirtualizedScrollPane;
M src/main/java/com/keenwrite/editors/TextResource.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.editors;
33
...
1212
1313
import static com.keenwrite.Constants.DEFAULT_CHARSET;
14
import static com.keenwrite.StatusBarNotifier.clue;
14
import static com.keenwrite.StatusNotifier.clue;
1515
import static java.nio.charset.Charset.forName;
1616
import static java.nio.file.Files.readAllBytes;
1717
import static java.nio.file.Files.write;
18
import static java.util.Arrays.asList;
1819
import static java.util.Locale.ENGLISH;
1920
...
8081
  default MediaType getMediaType() {
8182
    return MediaType.valueFrom( getFile() );
83
  }
84
85
  /**
86
   * Answers whether this instance is an editor for at least one of the given
87
   * {@link MediaType} references.
88
   *
89
   * @param mediaTypes The {@link MediaType} references to compare against.
90
   * @return {@code true} if the given list of media types contains the
91
   * {@link MediaType} for this editor.
92
   */
93
  default boolean isMediaType( final MediaType... mediaTypes ) {
94
    return asList( mediaTypes ).contains( getMediaType() );
8295
  }
8396
M src/main/java/com/keenwrite/editors/definition/DefinitionEditor.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.editors.definition;
33
...
2727
import static com.keenwrite.Constants.DEFINITION_DEFAULT;
2828
import static com.keenwrite.Messages.get;
29
import static com.keenwrite.StatusBarNotifier.clue;
29
import static com.keenwrite.StatusNotifier.clue;
3030
import static java.lang.String.format;
3131
import static java.util.regex.Pattern.compile;
M src/main/java/com/keenwrite/editors/definition/DefinitionTabSceneFactory.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.editors.definition;
33
M src/main/java/com/keenwrite/editors/definition/DefinitionTreeItem.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.editors.definition;
33
M src/main/java/com/keenwrite/editors/definition/FocusAwareTextFieldTreeCell.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.editors.definition;
33
M src/main/java/com/keenwrite/editors/definition/RootTreeItem.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.editors.definition;
33
M src/main/java/com/keenwrite/editors/definition/TreeCellFactory.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.editors.definition;
33
M src/main/java/com/keenwrite/editors/definition/TreeItemMapper.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.editors.definition;
33
M src/main/java/com/keenwrite/editors/definition/TreeTransformer.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.editors.definition;
33
M src/main/java/com/keenwrite/editors/definition/package-info.java
1
/* Copyright 2020 White Magic Software, Ltd.
1
/* Copyright 2020-2021 White Magic Software, Ltd.
22
 *
33
 * All rights reserved.
M src/main/java/com/keenwrite/editors/definition/yaml/YamlParser.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.editors.definition.yaml;
33
M src/main/java/com/keenwrite/editors/definition/yaml/YamlTreeTransformer.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.editors.definition.yaml;
33
M src/main/java/com/keenwrite/editors/definition/yaml/package-info.java
1
/* Copyright 2020 White Magic Software, Ltd.
1
/* Copyright 2020-2021 White Magic Software, Ltd.
22
 *
33
 * All rights reserved.
M src/main/java/com/keenwrite/editors/markdown/HyperlinkModel.java
1
/*
2
 * Copyright 2020 White Magic Software, Ltd.
3
 *
4
 * All rights reserved.
5
 *
6
 * Redistribution and use in source and binary forms, with or without
7
 * modification, are permitted provided that the following conditions are met:
8
 *
9
 *  o Redistributions of source code must retain the above copyright
10
 *    notice, this list of conditions and the following disclaimer.
11
 *
12
 *  o Redistributions in binary form must reproduce the above copyright
13
 *    notice, this list of conditions and the following disclaimer in the
14
 *    documentation and/or other materials provided with the distribution.
15
 *
16
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
17
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
18
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
19
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
20
 * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
21
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
22
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
23
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
24
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
25
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
26
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
27
 */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
282
package com.keenwrite.editors.markdown;
293
...
5327
   * Constructs a new hyperlink model for the given AST link.
5428
   *
55
   * @param link A markdown link.
29
   * @param link A Markdown link.
5630
   */
5731
  public HyperlinkModel( final Link link ) {
5832
    this(
59
        link.getText().toString(),
60
        link.getUrl().toString(),
61
        link.getTitle().toString()
33
      link.getText().toString(),
34
      link.getUrl().toString(),
35
      link.getTitle().toString()
6236
    );
6337
  }
...
7044
   * @param title The hyperlink title (e.g., shown as a tooltip).
7145
   */
72
  public HyperlinkModel( final String text, final String url,
73
                         final String title ) {
46
  public HyperlinkModel(
47
    final String text, final String url, final String title ) {
7448
    setText( text );
7549
    setUrl( url );
7650
    setTitle( title );
7751
  }
7852
7953
  /**
8054
   * Returns the string in Markdown format by default.
8155
   *
82
   * @return A markdown version of the hyperlink.
56
   * @return A Markdown version of the hyperlink.
8357
   */
8458
  @Override
...
9771
9872
  public final void setText( final String text ) {
99
    this.text = nullSafe( text );
73
    this.text = sanitize( text );
10074
  }
10175
10276
  public final void setUrl( final String url ) {
103
    this.url = nullSafe( url );
77
    this.url = sanitize( url );
10478
  }
10579
10680
  public final void setTitle( final String title ) {
107
    this.title = nullSafe( title );
81
    this.title = sanitize( title );
10882
  }
10983
...
138112
  }
139113
140
  private String nullSafe( final String s ) {
114
  private String sanitize( final String s ) {
141115
    return s == null ? "" : s;
142116
  }
M src/main/java/com/keenwrite/editors/markdown/LinkVisitor.java
11
/*
2
 * Copyright 2020 White Magic Software, Ltd.
2
 * Copyright 2020-2021 White Magic Software, Ltd.
33
 *
44
 * All rights reserved.
...
3939
public class LinkVisitor {
4040
41
  private NodeVisitor visitor;
42
  private Link link;
43
  private final int offset;
41
  private NodeVisitor mVisitor;
42
  private Link mLink;
43
  private final int mOffset;
4444
4545
  /**
46
   * Creates a hyperlink given an offset into a paragraph and the markdown AST
46
   * Creates a hyperlink given an offset into a paragraph and the Markdown AST
4747
   * link node.
4848
   *
4949
   * @param index Index into the paragraph that indicates the hyperlink to
5050
   *              change.
5151
   */
5252
  public LinkVisitor( final int index ) {
53
    this.offset = index;
53
    mOffset = index;
5454
  }
5555
...
7373
7474
  private synchronized NodeVisitor getVisitor() {
75
    if( this.visitor == null ) {
76
      this.visitor = createVisitor();
75
    if( mVisitor == null ) {
76
      mVisitor = createVisitor();
7777
    }
7878
79
    return this.visitor;
79
    return mVisitor;
8080
  }
8181
8282
  protected NodeVisitor createVisitor() {
8383
    return new NodeVisitor(
84
        new VisitHandler<>( Link.class, LinkVisitor.this::visit ) );
84
      new VisitHandler<>( Link.class, LinkVisitor.this::visit ) );
8585
  }
8686
8787
  private Link getLink() {
88
    return this.link;
88
    return mLink;
8989
  }
9090
9191
  private void setLink( final Link link ) {
92
    this.link = link;
92
    mLink = link;
9393
  }
9494
9595
  public int getOffset() {
96
    return this.offset;
96
    return mOffset;
9797
  }
9898
}
M src/main/java/com/keenwrite/editors/markdown/MarkdownEditor.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.editors.markdown;
3
4
import com.keenwrite.Constants;
5
import com.keenwrite.editors.TextEditor;
6
import com.keenwrite.preferences.LocaleProperty;
7
import com.keenwrite.preferences.Workspace;
8
import com.keenwrite.processors.markdown.Caret;
9
import com.keenwrite.spelling.impl.TextEditorSpeller;
10
import javafx.beans.binding.Bindings;
11
import javafx.beans.property.BooleanProperty;
12
import javafx.beans.property.DoubleProperty;
13
import javafx.beans.property.ReadOnlyBooleanProperty;
14
import javafx.beans.property.SimpleBooleanProperty;
15
import javafx.beans.value.ChangeListener;
16
import javafx.event.Event;
17
import javafx.scene.Node;
18
import javafx.scene.control.IndexRange;
19
import javafx.scene.input.KeyCode;
20
import javafx.scene.input.KeyEvent;
21
import javafx.scene.layout.BorderPane;
22
import org.fxmisc.flowless.VirtualizedScrollPane;
23
import org.fxmisc.richtext.StyleClassedTextArea;
24
import org.fxmisc.richtext.model.StyleSpans;
25
import org.fxmisc.undo.UndoManager;
26
import org.fxmisc.wellbehaved.event.EventPattern;
27
import org.fxmisc.wellbehaved.event.Nodes;
28
29
import java.io.File;
30
import java.nio.charset.Charset;
31
import java.text.BreakIterator;
32
import java.util.*;
33
import java.util.function.Consumer;
34
import java.util.function.Supplier;
35
import java.util.regex.Pattern;
36
37
import static com.keenwrite.Constants.*;
38
import static com.keenwrite.Messages.get;
39
import static com.keenwrite.StatusBarNotifier.clue;
40
import static com.keenwrite.preferences.Workspace.KEY_UI_FONT_EDITOR_SIZE;
41
import static com.keenwrite.preferences.Workspace.KEY_UI_FONT_LOCALE;
42
import static java.lang.Character.isWhitespace;
43
import static java.lang.Math.max;
44
import static java.lang.String.format;
45
import static java.util.Collections.singletonList;
46
import static javafx.scene.control.ScrollPane.ScrollBarPolicy.ALWAYS;
47
import static javafx.scene.input.KeyCode.*;
48
import static javafx.scene.input.KeyCombination.CONTROL_DOWN;
49
import static javafx.scene.input.KeyCombination.SHIFT_DOWN;
50
import static javafx.scene.input.KeyEvent.KEY_PRESSED;
51
import static org.apache.commons.lang3.StringUtils.stripEnd;
52
import static org.apache.commons.lang3.StringUtils.stripStart;
53
import static org.fxmisc.richtext.model.StyleSpans.singleton;
54
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
55
import static org.fxmisc.wellbehaved.event.InputMap.consume;
56
57
/**
58
 * Responsible for editing Markdown documents.
59
 */
60
public class MarkdownEditor extends BorderPane implements TextEditor {
61
  private static final String NEWLINE = System.lineSeparator();
62
63
  /**
64
   * Regular expression that matches the type of markup block. This is used
65
   * when Enter is pressed to continue the block environment.
66
   */
67
  private static final Pattern PATTERN_AUTO_INDENT = Pattern.compile(
68
    "(\\s*[*+-]\\s+|\\s*[0-9]+\\.\\s+|\\s+)(.*)" );
69
70
  /**
71
   * The text editor.
72
   */
73
  private final StyleClassedTextArea mTextArea =
74
    new StyleClassedTextArea( false );
75
76
  /**
77
   * Wraps the text editor in scrollbars.
78
   */
79
  private final VirtualizedScrollPane<StyleClassedTextArea> mScrollPane =
80
    new VirtualizedScrollPane<>( mTextArea );
81
82
  private final Workspace mWorkspace;
83
84
  /**
85
   * Tracks where the caret is located in this document. This offers observable
86
   * properties for caret position changes.
87
   */
88
  private final Caret mCaret = createCaret( mTextArea );
89
90
  /**
91
   * File being edited by this editor instance.
92
   */
93
  private File mFile;
94
95
  /**
96
   * Set to {@code true} upon text or caret position changes. Value is {@code
97
   * false} by default.
98
   */
99
  private final BooleanProperty mDirty = new SimpleBooleanProperty();
100
101
  /**
102
   * Opened file's character encoding, or {@link Constants#DEFAULT_CHARSET} if
103
   * either no encoding could be determined or this is a new (empty) file.
104
   */
105
  private final Charset mEncoding;
106
107
  /**
108
   * Tracks whether the in-memory definitions have changed with respect to the
109
   * persisted definitions.
110
   */
111
  private final BooleanProperty mModified = new SimpleBooleanProperty();
112
113
  public MarkdownEditor( final Workspace workspace ) {
114
    this( DOCUMENT_DEFAULT, workspace );
115
  }
116
117
  public MarkdownEditor( final File file, final Workspace workspace ) {
118
    mEncoding = open( mFile = file );
119
    mWorkspace = workspace;
120
121
    initTextArea( mTextArea );
122
    initStyle( mTextArea );
123
    initScrollPane( mScrollPane );
124
    initSpellchecker( mTextArea );
125
    initHotKeys();
126
    initUndoManager();
127
  }
128
129
  private void initTextArea( final StyleClassedTextArea textArea ) {
130
    textArea.setWrapText( true );
131
    textArea.requestFollowCaret();
132
    textArea.moveTo( 0 );
133
134
    textArea.textProperty().addListener( ( c, o, n ) -> {
135
      // Fire, regardless of whether the caret position has changed.
136
      mDirty.set( false );
137
138
      // Prevent a caret position change from raising the dirty bits.
139
      mDirty.set( true );
140
    } );
141
    textArea.caretPositionProperty().addListener( ( c, o, n ) -> {
142
      // Fire when the caret position has changed and the text has not.
143
      mDirty.set( true );
144
      mDirty.set( false );
145
    } );
146
  }
147
148
  private void initStyle( final StyleClassedTextArea textArea ) {
149
    textArea.getStyleClass().add( "markdown" );
150
151
    final var stylesheets = textArea.getStylesheets();
152
    stylesheets.add( STYLESHEET_MARKDOWN );
153
    stylesheets.add( getStylesheetPath( getLocale() ) );
154
155
    localeProperty().addListener( ( c, o, n ) -> {
156
      if( n != null ) {
157
        stylesheets.remove( max( 0, stylesheets.size() - 1 ) );
158
        stylesheets.add( getStylesheetPath( getLocale() ) );
159
      }
160
    } );
161
162
    fontSizeProperty().addListener( ( c, o, n ) -> {
163
      mTextArea.setStyle( format( "-fx-font-size: %spt;", getFontSize() ) );
164
    } );
165
  }
166
167
  private void initScrollPane(
168
    final VirtualizedScrollPane<StyleClassedTextArea> scrollpane ) {
169
    scrollpane.setVbarPolicy( ALWAYS );
170
    setCenter( scrollpane );
171
  }
172
173
  private void initSpellchecker( final StyleClassedTextArea textarea ) {
174
    final var speller = new TextEditorSpeller();
175
    speller.checkDocument( textarea );
176
    speller.checkParagraphs( textarea );
177
  }
178
179
  private void initHotKeys() {
180
    addEventListener( keyPressed( ENTER ), this::onEnterPressed );
181
    addEventListener( keyPressed( X, CONTROL_DOWN ), this::cut );
182
    addEventListener( keyPressed( TAB ), this::tab );
183
    addEventListener( keyPressed( TAB, SHIFT_DOWN ), this::untab );
184
  }
185
186
  private void initUndoManager() {
187
    final var undoManager = getUndoManager();
188
    final var markedPosition = undoManager.atMarkedPositionProperty();
189
190
    undoManager.forgetHistory();
191
    undoManager.mark();
192
    mModified.bind( Bindings.not( markedPosition ) );
193
  }
194
195
  @Override
196
  public void moveTo( final int offset ) {
197
    assert 0 <= offset && offset <= mTextArea.getLength();
198
    mTextArea.moveTo( offset );
199
    mTextArea.requestFollowCaret();
200
  }
201
202
  /**
203
   * Delegate the focus request to the text area itself.
204
   */
205
  @Override
206
  public void requestFocus() {
207
    mTextArea.requestFocus();
208
  }
209
210
  @Override
211
  public void setText( final String text ) {
212
    mTextArea.clear();
213
    mTextArea.appendText( text );
214
    mTextArea.getUndoManager().mark();
215
  }
216
217
  @Override
218
  public String getText() {
219
    return mTextArea.getText();
220
  }
221
222
  @Override
223
  public Charset getEncoding() {
224
    return mEncoding;
225
  }
226
227
  @Override
228
  public File getFile() {
229
    return mFile;
230
  }
231
232
  @Override
233
  public void rename( final File file ) {
234
    mFile = file;
235
  }
236
237
  @Override
238
  public void undo() {
239
    final var manager = getUndoManager();
240
    xxdo( manager::isUndoAvailable, manager::undo, "Main.status.error.undo" );
241
  }
242
243
  @Override
244
  public void redo() {
245
    final var manager = getUndoManager();
246
    xxdo( manager::isRedoAvailable, manager::redo, "Main.status.error.redo" );
247
  }
248
249
  /**
250
   * Performs an undo or redo action, if possible, otherwise displays an error
251
   * message to the user.
252
   *
253
   * @param ready  Answers whether the action can be executed.
254
   * @param action The action to execute.
255
   * @param key    The informational message key having a value to display if
256
   *               the {@link Supplier} is not ready.
257
   */
258
  private void xxdo(
259
    final Supplier<Boolean> ready, final Runnable action, final String key ) {
260
    if( ready.get() ) {
261
      action.run();
262
    }
263
    else {
264
      clue( key );
265
    }
266
  }
267
268
  @Override
269
  public void cut() {
270
    final var selected = mTextArea.getSelectedText();
271
272
    // Emulate selecting the current line by firing Home then Shift+Down Arrow.
273
    if( selected == null || selected.isEmpty() ) {
274
      // Note: mTextArea.selectLine() does not select empty lines.
275
      mTextArea.fireEvent( keyEvent( HOME, false ) );
276
      mTextArea.fireEvent( keyEvent( DOWN, true ) );
277
    }
278
279
    mTextArea.cut();
280
  }
281
282
  private Event keyEvent( final KeyCode code, final boolean shift ) {
283
    return new KeyEvent(
284
      KEY_PRESSED, "", "", code, shift, false, false, false
285
    );
286
  }
287
288
  @Override
289
  public void copy() {
290
    mTextArea.copy();
291
  }
292
293
  @Override
294
  public void paste() {
295
    mTextArea.paste();
296
  }
297
298
  @Override
299
  public void selectAll() {
300
    mTextArea.selectAll();
301
  }
302
303
  @Override
304
  public void bold() {
305
    enwrap( "**" );
306
  }
307
308
  @Override
309
  public void italic() {
310
    enwrap( "*" );
311
  }
312
313
  @Override
314
  public void superscript() {
315
    enwrap( "^" );
316
  }
317
318
  @Override
319
  public void subscript() {
320
    enwrap( "~" );
321
  }
322
323
  @Override
324
  public void strikethrough() {
325
    enwrap( "~~" );
326
  }
327
328
  @Override
329
  public void blockquote() {
330
    block( "> " );
331
  }
332
333
  @Override
334
  public void code() {
335
    enwrap( "`" );
336
  }
337
338
  @Override
339
  public void fencedCodeBlock() {
340
    final var key = "App.action.insert.fenced_code_block.prompt.text";
341
342
    // TODO: Introduce sample text if nothing is selected.
343
    //enwrap( "\n\n```\n", "\n```\n\n", get( key ) );
344
  }
345
346
  @Override
347
  public void heading( final int level ) {
348
    final var hashes = new String( new char[ level ] ).replace( "\0", "#" );
349
    block( format( "%s ", hashes ) );
350
  }
351
352
  @Override
353
  public void unorderedList() {
354
    block( "* " );
355
  }
356
357
  @Override
358
  public void orderedList() {
359
    block( "1. " );
360
  }
361
362
  @Override
363
  public void horizontalRule() {
364
    block( format( "---%n%n" ) );
365
  }
366
367
  @Override
368
  public Node getNode() {
369
    return this;
370
  }
371
372
  @Override
373
  public ReadOnlyBooleanProperty modifiedProperty() {
374
    return mModified;
375
  }
376
377
  @Override
378
  public void clearModifiedProperty() {
379
    getUndoManager().mark();
380
  }
381
382
  @Override
383
  public VirtualizedScrollPane<StyleClassedTextArea> getScrollPane() {
384
    return mScrollPane;
385
  }
386
387
  @Override
388
  public StyleClassedTextArea getTextArea() {
389
    return mTextArea;
390
  }
391
392
  private final Map<String, IndexRange> mStyles = new HashMap<>();
393
394
  @Override
395
  public void stylize( final IndexRange range, final String style ) {
396
    final var began = range.getStart();
397
    final var ended = range.getEnd() + 1;
398
399
    assert 0 <= began && began <= ended;
400
    assert style != null;
401
402
    // TODO: Ensure spell check and find highlights can coexist.
403
//    final var spans = mTextArea.getStyleSpans( range );
404
//    System.out.println( "SPANS: " + spans );
405
406
//    final var spans = mTextArea.getStyleSpans( range );
407
//    mTextArea.setStyleSpans( began, merge( spans, range.getLength(), style
408
//    ) );
409
410
//    final var builder = new StyleSpansBuilder<Collection<String>>();
411
//    builder.add( singleton( style ), range.getLength() + 1 );
412
//    mTextArea.setStyleSpans( began, builder.create() );
413
414
//    final var s = mTextArea.getStyleSpans( began, ended );
415
//    System.out.println( "STYLES: " +s );
416
417
    mStyles.put( style, range );
418
    mTextArea.setStyleClass( began, ended, style );
419
420
    // Ensure that whenever the user interacts with the text that the found
421
    // word will have its highlighting removed. The handler removes itself.
422
    // This won't remove the highlighting if the caret position moves by mouse.
423
    final var handler = mTextArea.getOnKeyPressed();
424
    mTextArea.setOnKeyPressed( ( event ) -> {
425
      mTextArea.setOnKeyPressed( handler );
426
      unstylize( style );
427
    } );
428
429
    //mTextArea.setStyleSpans(began, ended, s);
430
  }
431
432
  private static StyleSpans<Collection<String>> merge(
433
    StyleSpans<Collection<String>> spans, int len, String style ) {
434
    spans = spans.overlay(
435
      singleton( singletonList( style ), len ),
436
      ( bottomSpan, list ) -> {
437
        final List<String> l =
438
          new ArrayList<>( bottomSpan.size() + list.size() );
439
        l.addAll( bottomSpan );
440
        l.addAll( list );
441
        return l;
442
      } );
443
444
    return spans;
445
  }
446
447
  @Override
448
  public void unstylize( final String style ) {
449
    final var indexes = mStyles.remove( style );
450
    if( indexes != null ) {
451
      mTextArea.clearStyle( indexes.getStart(), indexes.getEnd() + 1 );
452
    }
453
  }
454
455
  @Override
456
  public Caret getCaret() {
457
    return mCaret;
458
  }
459
460
  private Caret createCaret( final StyleClassedTextArea editor ) {
461
    return Caret
462
      .builder()
463
      .with( Caret.Mutator::setEditor, editor )
464
      .build();
465
  }
466
467
  /**
468
   * This method adds listeners to editor events.
469
   *
470
   * @param <T>      The event type.
471
   * @param <U>      The consumer type for the given event type.
472
   * @param event    The event of interest.
473
   * @param consumer The method to call when the event happens.
474
   */
475
  public <T extends Event, U extends T> void addEventListener(
476
    final EventPattern<? super T, ? extends U> event,
477
    final Consumer<? super U> consumer ) {
478
    Nodes.addInputMap( mTextArea, consume( event, consumer ) );
479
  }
480
481
  @SuppressWarnings( "unused" )
482
  private void onEnterPressed( final KeyEvent event ) {
483
    final var currentLine = getCaretParagraph();
484
    final var matcher = PATTERN_AUTO_INDENT.matcher( currentLine );
485
486
    // By default, insert a new line by itself.
487
    String newText = NEWLINE;
488
489
    // If the pattern was matched then determine what block type to continue.
490
    if( matcher.matches() ) {
491
      if( matcher.group( 2 ).isEmpty() ) {
492
        final var pos = mTextArea.getCaretPosition();
493
        mTextArea.selectRange( pos - currentLine.length(), pos );
494
      }
495
      else {
496
        // Indent the new line with the same whitespace characters and
497
        // list markers as current line. This ensures that the indentation
498
        // is propagated.
499
        newText = newText.concat( matcher.group( 1 ) );
500
      }
501
    }
502
503
    mTextArea.replaceSelection( newText );
504
  }
505
506
  private void cut( final KeyEvent event ) {
507
    cut();
508
  }
509
510
  private void tab( final KeyEvent event ) {
511
    final var range = mTextArea.selectionProperty().getValue();
512
    final var sb = new StringBuilder( 1024 );
513
514
    if( range.getLength() > 0 ) {
515
      final var selection = mTextArea.getSelectedText();
516
517
      selection.lines().forEach(
518
        ( l ) -> sb.append( "\t" ).append( l ).append( NEWLINE )
519
      );
520
    }
521
    else {
522
      sb.append( "\t" );
523
    }
524
525
    mTextArea.replaceSelection( sb.toString() );
526
  }
527
528
  private void untab( final KeyEvent event ) {
529
    final var range = mTextArea.selectionProperty().getValue();
530
531
    if( range.getLength() > 0 ) {
532
      final var selection = mTextArea.getSelectedText();
533
      final var sb = new StringBuilder( selection.length() );
534
535
      selection.lines().forEach(
536
        ( l ) -> sb.append( l.startsWith( "\t" ) ? l.substring( 1 ) : l )
537
                   .append( NEWLINE )
538
      );
539
540
      mTextArea.replaceSelection( sb.toString() );
541
    }
542
    else {
543
      final var p = getCaretParagraph();
544
545
      if( p.startsWith( "\t" ) ) {
546
        mTextArea.selectParagraph();
547
        mTextArea.replaceSelection( p.substring( 1 ) );
548
      }
549
    }
550
  }
551
552
  /**
553
   * Observers may listen for changes to the property returned from this method
554
   * to receive notifications when either the text or caret have changed. This
555
   * should not be used to track whether the text has been modified.
556
   */
557
  public void addDirtyListener( ChangeListener<Boolean> listener ) {
558
    mDirty.addListener( listener );
559
  }
560
561
  /**
562
   * Surrounds the selected text or word under the caret in Markdown markup.
563
   *
564
   * @param token The beginning and ending token for enclosing the text.
565
   */
566
  private void enwrap( final String token ) {
567
    enwrap( token, token );
568
  }
569
570
  /**
571
   * Surrounds the selected text or word under the caret in Markdown markup.
572
   *
573
   * @param began The beginning token for enclosing the text.
574
   * @param ended The ending token for enclosing the text.
575
   */
576
  private void enwrap( final String began, String ended ) {
577
    // Ensure selected text takes precedence over the word at caret position.
578
    final var selected = mTextArea.selectionProperty().getValue();
579
    final var range = selected.getLength() == 0
580
      ? getCaretWord()
581
      : selected;
582
    String text = mTextArea.getText( range );
583
584
    int length = range.getLength();
585
    text = stripStart( text, null );
586
    final int beganIndex = range.getStart() + (length - text.length());
587
588
    length = text.length();
589
    text = stripEnd( text, null );
590
    final int endedIndex = range.getEnd() - (length - text.length());
591
592
    mTextArea.replaceText( beganIndex, endedIndex, began + text + ended );
593
  }
594
595
  /**
596
   * Inserts the given block-level markup at the current caret position
597
   * within the document. This will prepend two blank lines to ensure that
598
   * the block element begins at the start of a new line.
599
   *
600
   * @param markup The text to insert at the caret.
601
   */
602
  private void block( final String markup ) {
603
    final int pos = mTextArea.getCaretPosition();
604
    mTextArea.insertText( pos, format( "%n%n%s", markup ) );
605
  }
606
607
  /**
608
   * Returns the caret position within the current paragraph.
609
   *
610
   * @return A value from 0 to the length of the current paragraph.
611
   */
612
  private int getCaretColumn() {
613
    return mTextArea.getCaretColumn();
614
  }
615
616
  @Override
617
  public IndexRange getCaretWord() {
618
    final var paragraph = getCaretParagraph();
619
    final var length = paragraph.length();
620
    final var column = getCaretColumn();
621
622
    var began = column;
623
    var ended = column;
624
625
    while( began > 0 && !isWhitespace( paragraph.charAt( began - 1 ) ) ) {
626
      began--;
627
    }
628
629
    while( ended < length && !isWhitespace( paragraph.charAt( ended ) ) ) {
630
      ended++;
631
    }
632
633
    final var iterator = BreakIterator.getWordInstance();
634
    iterator.setText( paragraph );
635
636
    while( began < length && iterator.isBoundary( began + 1 ) ) {
637
      began++;
638
    }
639
640
    while( ended > 0 && iterator.isBoundary( ended - 1 ) ) {
641
      ended--;
642
    }
643
644
    final var offset = getCaretDocumentOffset( column );
645
646
    return IndexRange.normalize( began + offset, ended + offset );
647
  }
648
649
  private int getCaretDocumentOffset( final int column ) {
650
    return mTextArea.getCaretPosition() - column;
651
  }
652
653
  /**
654
   * Returns the index of the paragraph where the caret resides.
655
   *
656
   * @return A number greater than or equal to 0.
657
   */
658
  private int getCurrentParagraph() {
659
    return mTextArea.getCurrentParagraph();
660
  }
661
662
  /**
663
   * Returns the text for the paragraph that contains the caret.
664
   *
665
   * @return A non-null string, possibly empty.
666
   */
667
  private String getCaretParagraph() {
668
    return getText( getCurrentParagraph() );
669
  }
670
671
  @Override
672
  public String getText( final int paragraph ) {
673
    return mTextArea.getText( paragraph );
674
  }
675
676
  @Override
677
  public String getText( final IndexRange indexes )
678
    throws IndexOutOfBoundsException {
679
    return mTextArea.getText( indexes.getStart(), indexes.getEnd() );
680
  }
681
682
  @Override
683
  public void replaceText( final IndexRange indexes, final String s ) {
684
    mTextArea.replaceText( indexes, s );
685
  }
686
687
  private UndoManager<?> getUndoManager() {
688
    return mTextArea.getUndoManager();
689
  }
690
691
  /**
692
   * Returns the path to a {@link Locale}-specific stylesheet.
693
   *
694
   * @return A non-null string to inject into the HTML document head.
695
   */
696
  private static String getStylesheetPath( final Locale locale ) {
697
    return get(
698
      sSettings.getSetting( STYLESHEET_MARKDOWN_LOCALE, "" ),
699
      locale.getLanguage(),
700
      locale.getScript(),
701
      locale.getCountry()
702
    );
703
  }
704
705
  private Locale getLocale() {
706
    return localeProperty().toLocale();
707
  }
708
709
  private LocaleProperty localeProperty() {
710
    return mWorkspace.localeProperty( KEY_UI_FONT_LOCALE );
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.editors.markdown;
3
4
import com.keenwrite.Caret;
5
import com.keenwrite.Constants;
6
import com.keenwrite.editors.TextEditor;
7
import com.keenwrite.preferences.LocaleProperty;
8
import com.keenwrite.preferences.Workspace;
9
import com.keenwrite.spelling.impl.TextEditorSpeller;
10
import javafx.beans.binding.Bindings;
11
import javafx.beans.property.*;
12
import javafx.beans.value.ChangeListener;
13
import javafx.event.Event;
14
import javafx.scene.Node;
15
import javafx.scene.control.IndexRange;
16
import javafx.scene.input.KeyCode;
17
import javafx.scene.input.KeyEvent;
18
import javafx.scene.layout.BorderPane;
19
import org.fxmisc.flowless.VirtualizedScrollPane;
20
import org.fxmisc.richtext.StyleClassedTextArea;
21
import org.fxmisc.richtext.model.StyleSpans;
22
import org.fxmisc.undo.UndoManager;
23
import org.fxmisc.wellbehaved.event.EventPattern;
24
import org.fxmisc.wellbehaved.event.Nodes;
25
26
import java.io.File;
27
import java.nio.charset.Charset;
28
import java.text.BreakIterator;
29
import java.util.*;
30
import java.util.function.Consumer;
31
import java.util.function.Supplier;
32
import java.util.regex.Pattern;
33
34
import static com.keenwrite.Constants.*;
35
import static com.keenwrite.Messages.get;
36
import static com.keenwrite.StatusNotifier.clue;
37
import static com.keenwrite.preferences.Workspace.*;
38
import static java.lang.Character.isWhitespace;
39
import static java.lang.Math.max;
40
import static java.lang.String.format;
41
import static java.util.Collections.singletonList;
42
import static javafx.scene.control.ScrollPane.ScrollBarPolicy.ALWAYS;
43
import static javafx.scene.input.KeyCode.*;
44
import static javafx.scene.input.KeyCombination.CONTROL_DOWN;
45
import static javafx.scene.input.KeyCombination.SHIFT_DOWN;
46
import static javafx.scene.input.KeyEvent.KEY_PRESSED;
47
import static org.apache.commons.lang3.StringUtils.stripEnd;
48
import static org.apache.commons.lang3.StringUtils.stripStart;
49
import static org.fxmisc.richtext.model.StyleSpans.singleton;
50
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
51
import static org.fxmisc.wellbehaved.event.InputMap.consume;
52
53
/**
54
 * Responsible for editing Markdown documents.
55
 */
56
public class MarkdownEditor extends BorderPane implements TextEditor {
57
  /**
58
   * Regular expression that matches the type of markup block. This is used
59
   * when Enter is pressed to continue the block environment.
60
   */
61
  private static final Pattern PATTERN_AUTO_INDENT = Pattern.compile(
62
    "(\\s*[*+-]\\s+|\\s*[0-9]+\\.\\s+|\\s+)(.*)" );
63
64
  /**
65
   * The text editor.
66
   */
67
  private final StyleClassedTextArea mTextArea =
68
    new StyleClassedTextArea( false );
69
70
  /**
71
   * Wraps the text editor in scrollbars.
72
   */
73
  private final VirtualizedScrollPane<StyleClassedTextArea> mScrollPane =
74
    new VirtualizedScrollPane<>( mTextArea );
75
76
  private final Workspace mWorkspace;
77
78
  /**
79
   * Tracks where the caret is located in this document. This offers observable
80
   * properties for caret position changes.
81
   */
82
  private final Caret mCaret = createCaret( mTextArea );
83
84
  /**
85
   * File being edited by this editor instance.
86
   */
87
  private File mFile;
88
89
  /**
90
   * Set to {@code true} upon text or caret position changes. Value is {@code
91
   * false} by default.
92
   */
93
  private final BooleanProperty mDirty = new SimpleBooleanProperty();
94
95
  /**
96
   * Opened file's character encoding, or {@link Constants#DEFAULT_CHARSET} if
97
   * either no encoding could be determined or this is a new (empty) file.
98
   */
99
  private final Charset mEncoding;
100
101
  /**
102
   * Tracks whether the in-memory definitions have changed with respect to the
103
   * persisted definitions.
104
   */
105
  private final BooleanProperty mModified = new SimpleBooleanProperty();
106
107
  public MarkdownEditor( final Workspace workspace ) {
108
    this( DOCUMENT_DEFAULT, workspace );
109
  }
110
111
  public MarkdownEditor( final File file, final Workspace workspace ) {
112
    mEncoding = open( mFile = file );
113
    mWorkspace = workspace;
114
115
    initTextArea( mTextArea );
116
    initStyle( mTextArea );
117
    initScrollPane( mScrollPane );
118
    initSpellchecker( mTextArea );
119
    initHotKeys();
120
    initUndoManager();
121
  }
122
123
  private void initTextArea( final StyleClassedTextArea textArea ) {
124
    textArea.setWrapText( true );
125
    textArea.requestFollowCaret();
126
    textArea.moveTo( 0 );
127
128
    textArea.textProperty().addListener( ( c, o, n ) -> {
129
      // Fire, regardless of whether the caret position has changed.
130
      mDirty.set( false );
131
132
      // Prevent a caret position change from raising the dirty bits.
133
      mDirty.set( true );
134
    } );
135
    textArea.caretPositionProperty().addListener( ( c, o, n ) -> {
136
      // Fire when the caret position has changed and the text has not.
137
      mDirty.set( true );
138
      mDirty.set( false );
139
    } );
140
  }
141
142
  private void initStyle( final StyleClassedTextArea textArea ) {
143
    textArea.getStyleClass().add( "markdown" );
144
145
    final var stylesheets = textArea.getStylesheets();
146
    stylesheets.add( STYLESHEET_MARKDOWN );
147
    stylesheets.add( getStylesheetPath( getLocale() ) );
148
149
    localeProperty().addListener( ( c, o, n ) -> {
150
      if( n != null ) {
151
        stylesheets.remove( max( 0, stylesheets.size() - 1 ) );
152
        stylesheets.add( getStylesheetPath( getLocale() ) );
153
      }
154
    } );
155
156
    fontNameProperty().addListener(
157
      ( c, o, n ) -> {
158
        mTextArea.setStyle( format( "-fx-font-family: '%s';", getFontName() ) );
159
      }
160
    );
161
162
    fontSizeProperty().addListener(
163
      ( c, o, n ) ->
164
        mTextArea.setStyle( format( "-fx-font-size: %spt;", getFontSize() ) )
165
    );
166
  }
167
168
  private void initScrollPane(
169
    final VirtualizedScrollPane<StyleClassedTextArea> scrollpane ) {
170
    scrollpane.setVbarPolicy( ALWAYS );
171
    setCenter( scrollpane );
172
  }
173
174
  private void initSpellchecker( final StyleClassedTextArea textarea ) {
175
    final var speller = new TextEditorSpeller();
176
    speller.checkDocument( textarea );
177
    speller.checkParagraphs( textarea );
178
  }
179
180
  private void initHotKeys() {
181
    addEventListener( keyPressed( ENTER ), this::onEnterPressed );
182
    addEventListener( keyPressed( X, CONTROL_DOWN ), this::cut );
183
    addEventListener( keyPressed( TAB ), this::tab );
184
    addEventListener( keyPressed( TAB, SHIFT_DOWN ), this::untab );
185
  }
186
187
  private void initUndoManager() {
188
    final var undoManager = getUndoManager();
189
    final var markedPosition = undoManager.atMarkedPositionProperty();
190
191
    undoManager.forgetHistory();
192
    undoManager.mark();
193
    mModified.bind( Bindings.not( markedPosition ) );
194
  }
195
196
  @Override
197
  public void moveTo( final int offset ) {
198
    assert 0 <= offset && offset <= mTextArea.getLength();
199
    mTextArea.moveTo( offset );
200
    mTextArea.requestFollowCaret();
201
  }
202
203
  /**
204
   * Delegate the focus request to the text area itself.
205
   */
206
  @Override
207
  public void requestFocus() {
208
    mTextArea.requestFocus();
209
  }
210
211
  @Override
212
  public void setText( final String text ) {
213
    mTextArea.clear();
214
    mTextArea.appendText( text );
215
    mTextArea.getUndoManager().mark();
216
  }
217
218
  @Override
219
  public String getText() {
220
    return mTextArea.getText();
221
  }
222
223
  @Override
224
  public Charset getEncoding() {
225
    return mEncoding;
226
  }
227
228
  @Override
229
  public File getFile() {
230
    return mFile;
231
  }
232
233
  @Override
234
  public void rename( final File file ) {
235
    mFile = file;
236
  }
237
238
  @Override
239
  public void undo() {
240
    final var manager = getUndoManager();
241
    xxdo( manager::isUndoAvailable, manager::undo, "Main.status.error.undo" );
242
  }
243
244
  @Override
245
  public void redo() {
246
    final var manager = getUndoManager();
247
    xxdo( manager::isRedoAvailable, manager::redo, "Main.status.error.redo" );
248
  }
249
250
  /**
251
   * Performs an undo or redo action, if possible, otherwise displays an error
252
   * message to the user.
253
   *
254
   * @param ready  Answers whether the action can be executed.
255
   * @param action The action to execute.
256
   * @param key    The informational message key having a value to display if
257
   *               the {@link Supplier} is not ready.
258
   */
259
  private void xxdo(
260
    final Supplier<Boolean> ready, final Runnable action, final String key ) {
261
    if( ready.get() ) {
262
      action.run();
263
    }
264
    else {
265
      clue( key );
266
    }
267
  }
268
269
  @Override
270
  public void cut() {
271
    final var selected = mTextArea.getSelectedText();
272
273
    // Emulate selecting the current line by firing Home then Shift+Down Arrow.
274
    if( selected == null || selected.isEmpty() ) {
275
      // Note: mTextArea.selectLine() does not select empty lines.
276
      mTextArea.fireEvent( keyEvent( HOME, false ) );
277
      mTextArea.fireEvent( keyEvent( DOWN, true ) );
278
    }
279
280
    mTextArea.cut();
281
  }
282
283
  private Event keyEvent( final KeyCode code, final boolean shift ) {
284
    return new KeyEvent(
285
      KEY_PRESSED, "", "", code, shift, false, false, false
286
    );
287
  }
288
289
  @Override
290
  public void copy() {
291
    mTextArea.copy();
292
  }
293
294
  @Override
295
  public void paste() {
296
    mTextArea.paste();
297
  }
298
299
  @Override
300
  public void selectAll() {
301
    mTextArea.selectAll();
302
  }
303
304
  @Override
305
  public void bold() {
306
    enwrap( "**" );
307
  }
308
309
  @Override
310
  public void italic() {
311
    enwrap( "*" );
312
  }
313
314
  @Override
315
  public void superscript() {
316
    enwrap( "^" );
317
  }
318
319
  @Override
320
  public void subscript() {
321
    enwrap( "~" );
322
  }
323
324
  @Override
325
  public void strikethrough() {
326
    enwrap( "~~" );
327
  }
328
329
  @Override
330
  public void blockquote() {
331
    block( "> " );
332
  }
333
334
  @Override
335
  public void code() {
336
    enwrap( "`" );
337
  }
338
339
  @Override
340
  public void fencedCodeBlock() {
341
    final var key = "App.action.insert.fenced_code_block.prompt.text";
342
343
    // TODO: Introduce sample text if nothing is selected.
344
    //enwrap( "\n\n```\n", "\n```\n\n", get( key ) );
345
  }
346
347
  @Override
348
  public void heading( final int level ) {
349
    final var hashes = new String( new char[ level ] ).replace( "\0", "#" );
350
    block( format( "%s ", hashes ) );
351
  }
352
353
  @Override
354
  public void unorderedList() {
355
    block( "* " );
356
  }
357
358
  @Override
359
  public void orderedList() {
360
    block( "1. " );
361
  }
362
363
  @Override
364
  public void horizontalRule() {
365
    block( format( "---%n%n" ) );
366
  }
367
368
  @Override
369
  public Node getNode() {
370
    return this;
371
  }
372
373
  @Override
374
  public ReadOnlyBooleanProperty modifiedProperty() {
375
    return mModified;
376
  }
377
378
  @Override
379
  public void clearModifiedProperty() {
380
    getUndoManager().mark();
381
  }
382
383
  @Override
384
  public VirtualizedScrollPane<StyleClassedTextArea> getScrollPane() {
385
    return mScrollPane;
386
  }
387
388
  @Override
389
  public StyleClassedTextArea getTextArea() {
390
    return mTextArea;
391
  }
392
393
  private final Map<String, IndexRange> mStyles = new HashMap<>();
394
395
  @Override
396
  public void stylize( final IndexRange range, final String style ) {
397
    final var began = range.getStart();
398
    final var ended = range.getEnd() + 1;
399
400
    assert 0 <= began && began <= ended;
401
    assert style != null;
402
403
    // TODO: Ensure spell check and find highlights can coexist.
404
//    final var spans = mTextArea.getStyleSpans( range );
405
//    System.out.println( "SPANS: " + spans );
406
407
//    final var spans = mTextArea.getStyleSpans( range );
408
//    mTextArea.setStyleSpans( began, merge( spans, range.getLength(), style
409
//    ) );
410
411
//    final var builder = new StyleSpansBuilder<Collection<String>>();
412
//    builder.add( singleton( style ), range.getLength() + 1 );
413
//    mTextArea.setStyleSpans( began, builder.create() );
414
415
//    final var s = mTextArea.getStyleSpans( began, ended );
416
//    System.out.println( "STYLES: " +s );
417
418
    mStyles.put( style, range );
419
    mTextArea.setStyleClass( began, ended, style );
420
421
    // Ensure that whenever the user interacts with the text that the found
422
    // word will have its highlighting removed. The handler removes itself.
423
    // This won't remove the highlighting if the caret position moves by mouse.
424
    final var handler = mTextArea.getOnKeyPressed();
425
    mTextArea.setOnKeyPressed( ( event ) -> {
426
      mTextArea.setOnKeyPressed( handler );
427
      unstylize( style );
428
    } );
429
430
    //mTextArea.setStyleSpans(began, ended, s);
431
  }
432
433
  private static StyleSpans<Collection<String>> merge(
434
    StyleSpans<Collection<String>> spans, int len, String style ) {
435
    spans = spans.overlay(
436
      singleton( singletonList( style ), len ),
437
      ( bottomSpan, list ) -> {
438
        final List<String> l =
439
          new ArrayList<>( bottomSpan.size() + list.size() );
440
        l.addAll( bottomSpan );
441
        l.addAll( list );
442
        return l;
443
      } );
444
445
    return spans;
446
  }
447
448
  @Override
449
  public void unstylize( final String style ) {
450
    final var indexes = mStyles.remove( style );
451
    if( indexes != null ) {
452
      mTextArea.clearStyle( indexes.getStart(), indexes.getEnd() + 1 );
453
    }
454
  }
455
456
  @Override
457
  public Caret getCaret() {
458
    return mCaret;
459
  }
460
461
  private Caret createCaret( final StyleClassedTextArea editor ) {
462
    return Caret
463
      .builder()
464
      .with( Caret.Mutator::setEditor, editor )
465
      .build();
466
  }
467
468
  /**
469
   * This method adds listeners to editor events.
470
   *
471
   * @param <T>      The event type.
472
   * @param <U>      The consumer type for the given event type.
473
   * @param event    The event of interest.
474
   * @param consumer The method to call when the event happens.
475
   */
476
  public <T extends Event, U extends T> void addEventListener(
477
    final EventPattern<? super T, ? extends U> event,
478
    final Consumer<? super U> consumer ) {
479
    Nodes.addInputMap( mTextArea, consume( event, consumer ) );
480
  }
481
482
  @SuppressWarnings( "unused" )
483
  private void onEnterPressed( final KeyEvent event ) {
484
    final var currentLine = getCaretParagraph();
485
    final var matcher = PATTERN_AUTO_INDENT.matcher( currentLine );
486
487
    // By default, insert a new line by itself.
488
    String newText = NEWLINE;
489
490
    // If the pattern was matched then determine what block type to continue.
491
    if( matcher.matches() ) {
492
      if( matcher.group( 2 ).isEmpty() ) {
493
        final var pos = mTextArea.getCaretPosition();
494
        mTextArea.selectRange( pos - currentLine.length(), pos );
495
      }
496
      else {
497
        // Indent the new line with the same whitespace characters and
498
        // list markers as current line. This ensures that the indentation
499
        // is propagated.
500
        newText = newText.concat( matcher.group( 1 ) );
501
      }
502
    }
503
504
    mTextArea.replaceSelection( newText );
505
  }
506
507
  private void cut( final KeyEvent event ) {
508
    cut();
509
  }
510
511
  private void tab( final KeyEvent event ) {
512
    final var range = mTextArea.selectionProperty().getValue();
513
    final var sb = new StringBuilder( 1024 );
514
515
    if( range.getLength() > 0 ) {
516
      final var selection = mTextArea.getSelectedText();
517
518
      selection.lines().forEach(
519
        ( l ) -> sb.append( "\t" ).append( l ).append( NEWLINE )
520
      );
521
    }
522
    else {
523
      sb.append( "\t" );
524
    }
525
526
    mTextArea.replaceSelection( sb.toString() );
527
  }
528
529
  private void untab( final KeyEvent event ) {
530
    final var range = mTextArea.selectionProperty().getValue();
531
532
    if( range.getLength() > 0 ) {
533
      final var selection = mTextArea.getSelectedText();
534
      final var sb = new StringBuilder( selection.length() );
535
536
      selection.lines().forEach(
537
        ( l ) -> sb.append( l.startsWith( "\t" ) ? l.substring( 1 ) : l )
538
                   .append( NEWLINE )
539
      );
540
541
      mTextArea.replaceSelection( sb.toString() );
542
    }
543
    else {
544
      final var p = getCaretParagraph();
545
546
      if( p.startsWith( "\t" ) ) {
547
        mTextArea.selectParagraph();
548
        mTextArea.replaceSelection( p.substring( 1 ) );
549
      }
550
    }
551
  }
552
553
  /**
554
   * Observers may listen for changes to the property returned from this method
555
   * to receive notifications when either the text or caret have changed. This
556
   * should not be used to track whether the text has been modified.
557
   */
558
  public void addDirtyListener( ChangeListener<Boolean> listener ) {
559
    mDirty.addListener( listener );
560
  }
561
562
  /**
563
   * Surrounds the selected text or word under the caret in Markdown markup.
564
   *
565
   * @param token The beginning and ending token for enclosing the text.
566
   */
567
  private void enwrap( final String token ) {
568
    enwrap( token, token );
569
  }
570
571
  /**
572
   * Surrounds the selected text or word under the caret in Markdown markup.
573
   *
574
   * @param began The beginning token for enclosing the text.
575
   * @param ended The ending token for enclosing the text.
576
   */
577
  private void enwrap( final String began, String ended ) {
578
    // Ensure selected text takes precedence over the word at caret position.
579
    final var selected = mTextArea.selectionProperty().getValue();
580
    final var range = selected.getLength() == 0
581
      ? getCaretWord()
582
      : selected;
583
    String text = mTextArea.getText( range );
584
585
    int length = range.getLength();
586
    text = stripStart( text, null );
587
    final int beganIndex = range.getStart() + (length - text.length());
588
589
    length = text.length();
590
    text = stripEnd( text, null );
591
    final int endedIndex = range.getEnd() - (length - text.length());
592
593
    mTextArea.replaceText( beganIndex, endedIndex, began + text + ended );
594
  }
595
596
  /**
597
   * Inserts the given block-level markup at the current caret position
598
   * within the document. This will prepend two blank lines to ensure that
599
   * the block element begins at the start of a new line.
600
   *
601
   * @param markup The text to insert at the caret.
602
   */
603
  private void block( final String markup ) {
604
    final int pos = mTextArea.getCaretPosition();
605
    mTextArea.insertText( pos, format( "%n%n%s", markup ) );
606
  }
607
608
  /**
609
   * Returns the caret position within the current paragraph.
610
   *
611
   * @return A value from 0 to the length of the current paragraph.
612
   */
613
  private int getCaretColumn() {
614
    return mTextArea.getCaretColumn();
615
  }
616
617
  @Override
618
  public IndexRange getCaretWord() {
619
    final var paragraph = getCaretParagraph();
620
    final var length = paragraph.length();
621
    final var column = getCaretColumn();
622
623
    var began = column;
624
    var ended = column;
625
626
    while( began > 0 && !isWhitespace( paragraph.charAt( began - 1 ) ) ) {
627
      began--;
628
    }
629
630
    while( ended < length && !isWhitespace( paragraph.charAt( ended ) ) ) {
631
      ended++;
632
    }
633
634
    final var iterator = BreakIterator.getWordInstance();
635
    iterator.setText( paragraph );
636
637
    while( began < length && iterator.isBoundary( began + 1 ) ) {
638
      began++;
639
    }
640
641
    while( ended > 0 && iterator.isBoundary( ended - 1 ) ) {
642
      ended--;
643
    }
644
645
    final var offset = getCaretDocumentOffset( column );
646
647
    return IndexRange.normalize( began + offset, ended + offset );
648
  }
649
650
  private int getCaretDocumentOffset( final int column ) {
651
    return mTextArea.getCaretPosition() - column;
652
  }
653
654
  /**
655
   * Returns the index of the paragraph where the caret resides.
656
   *
657
   * @return A number greater than or equal to 0.
658
   */
659
  private int getCurrentParagraph() {
660
    return mTextArea.getCurrentParagraph();
661
  }
662
663
  /**
664
   * Returns the text for the paragraph that contains the caret.
665
   *
666
   * @return A non-null string, possibly empty.
667
   */
668
  private String getCaretParagraph() {
669
    return getText( getCurrentParagraph() );
670
  }
671
672
  @Override
673
  public String getText( final int paragraph ) {
674
    return mTextArea.getText( paragraph );
675
  }
676
677
  @Override
678
  public String getText( final IndexRange indexes )
679
    throws IndexOutOfBoundsException {
680
    return mTextArea.getText( indexes.getStart(), indexes.getEnd() );
681
  }
682
683
  @Override
684
  public void replaceText( final IndexRange indexes, final String s ) {
685
    mTextArea.replaceText( indexes, s );
686
  }
687
688
  private UndoManager<?> getUndoManager() {
689
    return mTextArea.getUndoManager();
690
  }
691
692
  /**
693
   * Returns the path to a {@link Locale}-specific stylesheet.
694
   *
695
   * @return A non-null string to inject into the HTML document head.
696
   */
697
  private static String getStylesheetPath( final Locale locale ) {
698
    return get(
699
      sSettings.getSetting( STYLESHEET_MARKDOWN_LOCALE, "" ),
700
      locale.getLanguage(),
701
      locale.getScript(),
702
      locale.getCountry()
703
    );
704
  }
705
706
  private Locale getLocale() {
707
    return localeProperty().toLocale();
708
  }
709
710
  private LocaleProperty localeProperty() {
711
    return mWorkspace.localeProperty( KEY_LANG_LOCALE );
712
  }
713
714
  private String getFontName() {
715
    return fontNameProperty().get();
716
  }
717
718
  private StringProperty fontNameProperty() {
719
    return mWorkspace.stringProperty( KEY_UI_FONT_EDITOR_NAME );
711720
  }
712721
M src/main/java/com/keenwrite/exceptions/MissingFileException.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.exceptions;
33
M src/main/java/com/keenwrite/io/FileType.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.io;
33
M src/main/java/com/keenwrite/io/HttpMediaType.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.io;
33
44
import java.net.MalformedURLException;
55
import java.net.URI;
66
import java.net.URL;
77
import java.net.http.HttpClient;
88
import java.net.http.HttpRequest;
99
10
import static com.keenwrite.StatusBarNotifier.clue;
10
import static com.keenwrite.StatusNotifier.clue;
1111
import static com.keenwrite.io.MediaType.UNDEFINED;
1212
import static java.net.http.HttpClient.Redirect.NORMAL;
M src/main/java/com/keenwrite/io/MediaType.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.io;
33
M src/main/java/com/keenwrite/io/MediaTypeExtensions.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.io;
33
M src/main/java/com/keenwrite/predicates/PredicateFactory.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.predicates;
33
M src/main/java/com/keenwrite/preferences/FileProperty.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.preferences;
33
M src/main/java/com/keenwrite/preferences/Key.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.preferences;
33
M src/main/java/com/keenwrite/preferences/LocaleProperty.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.preferences;
33
4
import javafx.beans.property.SimpleListProperty;
54
import javafx.beans.property.SimpleObjectProperty;
65
import javafx.collections.ObservableList;
76
87
import java.util.LinkedHashMap;
98
import java.util.Locale;
109
import java.util.Map;
1110
import java.util.Objects;
1211
1312
import static com.keenwrite.Constants.LOCALE_DEFAULT;
13
import static com.keenwrite.preferences.Workspace.listProperty;
1414
import static java.util.Locale.forLanguageTag;
15
import static javafx.collections.FXCollections.observableArrayList;
1615
1716
public class LocaleProperty extends SimpleObjectProperty<String> {
...
8180
8281
  public static ObservableList<String> localeListProperty() {
83
    return new SimpleListProperty<>( observableArrayList( sLocales.keySet() ) );
82
    return listProperty( sLocales.keySet() );
8483
  }
8584
M src/main/java/com/keenwrite/preferences/LocaleScripts.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.preferences;
33
M src/main/java/com/keenwrite/preferences/PreferencesController.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.preferences;
33
44
import com.dlsc.formsfx.model.structure.StringField;
55
import com.dlsc.preferencesfx.PreferencesFx;
66
import com.dlsc.preferencesfx.PreferencesFxEvent;
77
import com.dlsc.preferencesfx.model.Category;
88
import com.dlsc.preferencesfx.model.Group;
99
import com.dlsc.preferencesfx.model.Setting;
10
import com.dlsc.preferencesfx.view.NavigationView;
1011
import javafx.beans.property.DoubleProperty;
1112
import javafx.beans.property.ObjectProperty;
1213
import javafx.beans.property.StringProperty;
1314
import javafx.event.EventHandler;
1415
import javafx.scene.Node;
16
import javafx.scene.control.Button;
17
import javafx.scene.control.DialogPane;
1518
import javafx.scene.control.Label;
19
import org.controlsfx.control.MasterDetailPane;
1620
1721
import java.io.File;
1822
23
import static com.dlsc.formsfx.model.structure.Field.ofStringType;
24
import static com.dlsc.preferencesfx.PreferencesFxEvent.EVENT_PREFERENCES_SAVED;
1925
import static com.keenwrite.Constants.ICON_DIALOG;
2026
import static com.keenwrite.Messages.get;
2127
import static com.keenwrite.preferences.LocaleProperty.localeListProperty;
2228
import static com.keenwrite.preferences.Workspace.*;
29
import static javafx.scene.control.ButtonType.CANCEL;
30
import static javafx.scene.control.ButtonType.OK;
2331
2432
/**
...
3745
    // All properties must be initialized before creating the dialog.
3846
    mPreferencesFx = createPreferencesFx();
47
48
    initKeyEventHandler( mPreferencesFx );
3949
  }
4050
...
6272
  public void addSaveEventHandler(
6373
    final EventHandler<? super PreferencesFxEvent> eventHandler ) {
64
    final var eventType = PreferencesFxEvent.EVENT_PREFERENCES_SAVED;
65
    getPreferencesFx().addEventHandler( eventType, eventHandler );
74
    getPreferencesFx().addEventHandler( EVENT_PREFERENCES_SAVED, eventHandler );
75
  }
76
77
  private StringField createFontNameField(
78
    final StringProperty fontName, final DoubleProperty fontSize ) {
79
    final var control = new SimpleFontControl( "Change" );
80
    control.fontSizeProperty().addListener( ( c, o, n ) -> {
81
      if( n != null ) {
82
        fontSize.set( n.doubleValue() );
83
      }
84
    } );
85
    return ofStringType( fontName ).render( control );
6686
  }
6787
...
7595
   * @return A new instance of preferences for users to edit.
7696
   */
77
  @SuppressWarnings( "unchecked" )
7897
  private PreferencesFx createPreferencesFx() {
79
    final Setting<StringField, StringProperty> scriptSetting =
80
      Setting.of( "Script", stringProperty( KEY_R_SCRIPT ) );
81
    final StringField field = scriptSetting.getElement();
82
    field.multiline( true );
83
8498
    return PreferencesFx.of(
8599
      new XmlStorageHandler(),
86100
      Category.of(
87101
        get( KEY_R ),
88102
        Group.of(
89103
          get( KEY_R_DIR ),
90104
          Setting.of( label( KEY_R_DIR,
91105
                             stringProperty( KEY_DEF_DELIM_BEGAN ).get(),
92106
                             stringProperty( KEY_DEF_DELIM_ENDED ).get() ) ),
93
          Setting.of( title( KEY_R_DIR ), fileProperty( KEY_R_DIR ), true )
107
          Setting.of( title( KEY_R_DIR ),
108
                      fileProperty( KEY_R_DIR ), true )
94109
        ),
95110
        Group.of(
96111
          get( KEY_R_SCRIPT ),
97112
          Setting.of( label( KEY_R_SCRIPT ) ),
98
          scriptSetting
113
          createScriptSetting()
99114
        ),
100115
        Group.of(
...
117132
          Setting.of( label( KEY_IMAGES_DIR ) ),
118133
          Setting.of( title( KEY_IMAGES_DIR ),
119
                      fileProperty( KEY_IMAGES_DIR ),
120
                      true )
134
                      fileProperty( KEY_IMAGES_DIR ), true )
121135
        ),
122136
        Group.of(
...
133147
          Setting.of( label( KEY_DEF_PATH ) ),
134148
          Setting.of( title( KEY_DEF_PATH ),
135
                      fileProperty( KEY_DEF_PATH ),
136
                      false )
149
                      fileProperty( KEY_DEF_PATH ), false )
137150
        ),
138151
        Group.of(
...
151164
      Category.of(
152165
        get( KEY_UI_FONT ),
153
        Group.of(
154
          get( KEY_UI_FONT_PREVIEW_SIZE ),
155
          Setting.of( label( KEY_UI_FONT_PREVIEW_SIZE ) ),
156
          Setting.of( title( KEY_UI_FONT_PREVIEW_SIZE ),
157
                      doubleProperty( KEY_UI_FONT_PREVIEW_SIZE ) )
158
        ),
159166
        Group.of(
160
          get( KEY_UI_FONT_EDITOR_SIZE ),
167
          get( KEY_UI_FONT_EDITOR ),
168
          Setting.of( label( KEY_UI_FONT_EDITOR_NAME ) ),
169
          Setting.of( title( KEY_UI_FONT_EDITOR_NAME ),
170
                      createFontNameField(
171
                        stringProperty( KEY_UI_FONT_EDITOR_NAME ),
172
                        doubleProperty( KEY_UI_FONT_EDITOR_SIZE ) ),
173
                      stringProperty( KEY_UI_FONT_EDITOR_NAME ) ),
161174
          Setting.of( label( KEY_UI_FONT_EDITOR_SIZE ) ),
162175
          Setting.of( title( KEY_UI_FONT_EDITOR_SIZE ),
163176
                      doubleProperty( KEY_UI_FONT_EDITOR_SIZE ) )
164177
        ),
165178
        Group.of(
166
          get( KEY_UI_FONT_LOCALE ),
167
          Setting.of( label( KEY_UI_FONT_LOCALE ) ),
168
          Setting.of( title( KEY_UI_FONT_LOCALE ),
179
          get( KEY_UI_FONT_PREVIEW ),
180
          Setting.of( label( KEY_UI_FONT_PREVIEW_NAME ) ),
181
          Setting.of( title( KEY_UI_FONT_PREVIEW_NAME ),
182
                      createFontNameField(
183
                        stringProperty( KEY_UI_FONT_PREVIEW_NAME ),
184
                        doubleProperty( KEY_UI_FONT_PREVIEW_SIZE ) ),
185
                      stringProperty( KEY_UI_FONT_PREVIEW_NAME ) ),
186
          Setting.of( label( KEY_UI_FONT_PREVIEW_SIZE ) ),
187
          Setting.of( title( KEY_UI_FONT_PREVIEW_SIZE ),
188
                      doubleProperty( KEY_UI_FONT_PREVIEW_SIZE ) ),
189
          Setting.of( label( KEY_UI_FONT_PREVIEW_MONO_NAME ) ),
190
          Setting.of( title( KEY_UI_FONT_PREVIEW_MONO_NAME ),
191
                      createFontNameField(
192
                        stringProperty( KEY_UI_FONT_PREVIEW_MONO_NAME ),
193
                        doubleProperty( KEY_UI_FONT_PREVIEW_MONO_SIZE ) ),
194
                      stringProperty( KEY_UI_FONT_PREVIEW_MONO_NAME ) ),
195
          Setting.of( label( KEY_UI_FONT_PREVIEW_MONO_SIZE ) ),
196
          Setting.of( title( KEY_UI_FONT_PREVIEW_MONO_SIZE ),
197
                      doubleProperty( KEY_UI_FONT_PREVIEW_MONO_SIZE ) )
198
        )
199
      ),
200
      Category.of(
201
        get( KEY_LANGUAGE ),
202
        Group.of(
203
          get( KEY_LANG_LOCALE ),
204
          Setting.of( label( KEY_LANG_LOCALE ) ),
205
          Setting.of( title( KEY_LANG_LOCALE ),
169206
                      localeListProperty(),
170
                      localeProperty( KEY_UI_FONT_LOCALE ) )
207
                      localeProperty( KEY_LANG_LOCALE ) )
171208
        )
172209
      )
173210
    ).instantPersistent( false ).dialogIcon( ICON_DIALOG );
211
  }
212
213
  @SuppressWarnings( "unchecked" )
214
  private Setting<StringField, StringProperty> createScriptSetting() {
215
    final Setting<StringField, StringProperty> scriptSetting =
216
      Setting.of( "Script", stringProperty( KEY_R_SCRIPT ) );
217
    final var field = scriptSetting.getElement();
218
    field.multiline( true );
219
220
    return scriptSetting;
221
  }
222
223
  private void initKeyEventHandler( final PreferencesFx preferences ) {
224
    final var view = preferences.getView();
225
    final var nodes = view.getChildrenUnmodifiable();
226
    final var master = (MasterDetailPane) nodes.get( 0 );
227
    final var detail = (NavigationView) master.getDetailNode();
228
    final var pane = (DialogPane) view.getParent();
229
230
    detail.setOnKeyReleased( ( key ) -> {
231
      switch( key.getCode() ) {
232
        case ENTER -> ((Button) pane.lookupButton( OK )).fire();
233
        case ESCAPE -> ((Button) pane.lookupButton( CANCEL )).fire();
234
      }
235
    } );
174236
  }
175237
A src/main/java/com/keenwrite/preferences/SimpleFontControl.java
1
/* Copyright 2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.preferences;
3
4
import com.dlsc.formsfx.model.structure.StringField;
5
import com.dlsc.preferencesfx.formsfx.view.controls.SimpleControl;
6
import javafx.beans.property.DoubleProperty;
7
import javafx.beans.property.SimpleDoubleProperty;
8
import javafx.scene.control.Button;
9
import javafx.scene.control.ListView;
10
import javafx.scene.control.TextField;
11
import javafx.scene.input.KeyEvent;
12
import javafx.scene.layout.HBox;
13
import javafx.scene.layout.Region;
14
import javafx.scene.layout.StackPane;
15
import javafx.scene.text.Font;
16
import javafx.stage.Stage;
17
import org.controlsfx.dialog.FontSelectorDialog;
18
19
import static com.keenwrite.Constants.ICON_DIALOG;
20
import static com.keenwrite.StatusNotifier.clue;
21
import static java.lang.System.currentTimeMillis;
22
import static javafx.geometry.Pos.CENTER_LEFT;
23
import static javafx.scene.control.ButtonType.CANCEL;
24
import static javafx.scene.control.ButtonType.OK;
25
import static javafx.scene.input.KeyCode.ENTER;
26
import static javafx.scene.input.KeyCode.ESCAPE;
27
import static javafx.scene.layout.Priority.ALWAYS;
28
import static javafx.scene.text.Font.font;
29
import static javafx.scene.text.Font.getDefault;
30
31
/**
32
 * Responsible for provide users the ability to select a font using a friendly
33
 * font dialog.
34
 */
35
public class SimpleFontControl extends SimpleControl<StringField, StackPane> {
36
  private final Button mButton = new Button();
37
  private final String mButtonText;
38
  private final DoubleProperty mFontSize = new SimpleDoubleProperty();
39
  private final TextField mFontName = new TextField();
40
41
  public SimpleFontControl( final String buttonText ) {
42
    mButtonText = buttonText;
43
  }
44
45
  @Override
46
  public void initializeParts() {
47
    super.initializeParts();
48
49
    mFontName.setText( field.getValue() );
50
    mFontName.setPromptText( field.placeholderProperty().getValue() );
51
52
    final var fieldProperty = field.valueProperty();
53
    if( fieldProperty.get().equals( "null" ) ) {
54
      fieldProperty.set( "" );
55
    }
56
57
    mButton.setText( mButtonText );
58
    mButton.setOnAction( event -> {
59
      final var selected = !fieldProperty.get().trim().isEmpty();
60
      var initialFont = getDefault();
61
      if( selected ) {
62
        final var previousValue = fieldProperty.get();
63
        initialFont = font( previousValue );
64
      }
65
66
      createFontSelectorDialog( initialFont )
67
        .showAndWait()
68
        .ifPresent( ( font ) -> {
69
          mFontName.setText( font.getFamily() );
70
          mFontSize.set( font.getSize() );
71
        } );
72
    } );
73
74
    node = new StackPane();
75
  }
76
77
  @Override
78
  public void layoutParts() {
79
    node.getStyleClass().add( "simple-text-control" );
80
    fieldLabel.getStyleClass().addAll( field.getStyleClass() );
81
    fieldLabel.getStyleClass().add( "read-only-label" );
82
83
    final var box = new HBox();
84
    HBox.setHgrow( mFontName, ALWAYS );
85
    box.setAlignment( CENTER_LEFT );
86
    box.getChildren().addAll( fieldLabel, mFontName, mButton );
87
88
    node.getChildren().add( box );
89
  }
90
91
  @Override
92
  public void setupBindings() {
93
    super.setupBindings();
94
    mFontName.textProperty().bindBidirectional( field.userInputProperty() );
95
  }
96
97
  public DoubleProperty fontSizeProperty() {
98
    return mFontSize;
99
  }
100
101
  /**
102
   * Creates a dialog that displays a list of available font families,
103
   * sizes, and a button for font selection.
104
   *
105
   * @param font The default font to select initially.
106
   * @return A dialog to help the user select a different {@link Font}.
107
   */
108
  private FontSelectorDialog createFontSelectorDialog( final Font font ) {
109
    final var dialog = new FontSelectorDialog( font );
110
    final var pane = dialog.getDialogPane();
111
    final var buttonOk = ((Button) pane.lookupButton( OK ));
112
    final var buttonCancel = ((Button) pane.lookupButton( CANCEL ));
113
114
    buttonOk.setDefaultButton( true );
115
    buttonCancel.setCancelButton( true );
116
    pane.setOnKeyReleased( ( keyEvent ) -> {
117
      switch( keyEvent.getCode() ) {
118
        case ENTER -> buttonOk.fire();
119
        case ESCAPE -> buttonCancel.fire();
120
      }
121
    } );
122
123
    final var stage = (Stage) pane.getScene().getWindow();
124
    stage.getIcons().add( ICON_DIALOG );
125
126
    final var frontPanel = (Region) pane.getContent();
127
    for( final var node : frontPanel.getChildrenUnmodifiable() ) {
128
      if( node instanceof ListView ) {
129
        final var listView = (ListView<?>) node;
130
        final var handler = new ListViewHandler<>( listView );
131
        listView.setOnKeyPressed( handler::handle );
132
      }
133
    }
134
135
    return dialog;
136
  }
137
138
  /**
139
   * Responsible for handling key presses when selecting a font. Based on
140
   * <a href="https://stackoverflow.com/a/43604223/59087">Martin Široký</a>'s
141
   * answer.
142
   *
143
   * @param <T> The type of {@link ListView} to search.
144
   */
145
  private static final class ListViewHandler<T> {
146
    /**
147
     * Amount of time to wait between key presses that typing a subsequent
148
     * key is considered part of the same search, in milliseconds.
149
     */
150
    private static final int RESET_DELAY_MS = 1250;
151
152
    private String mNeedle = "";
153
    private int mSearchSkip = 0;
154
    private long mLastTyped = currentTimeMillis();
155
    private final ListView<T> mHaystack;
156
157
    private ListViewHandler( final ListView<T> listView ) {
158
      mHaystack = listView;
159
    }
160
161
    private void handle( final KeyEvent key ) {
162
      var ch = key.getText();
163
      final var code = key.getCode();
164
165
      if( ch == null || ch.isEmpty() || code == ESCAPE || code == ENTER ) {
166
        return;
167
      }
168
169
      ch = ch.toUpperCase();
170
171
      if( mNeedle.equals( ch ) ) {
172
        mSearchSkip++;
173
      }
174
      else {
175
        mNeedle = currentTimeMillis() - mLastTyped > RESET_DELAY_MS
176
          ? ch : mNeedle + ch;
177
      }
178
179
      mLastTyped = currentTimeMillis();
180
181
      boolean found = false;
182
      int skipped = 0;
183
184
      for( final T item : mHaystack.getItems() ) {
185
        final var straw = item.toString().toUpperCase();
186
187
        if( straw.startsWith( mNeedle ) ) {
188
          if( mSearchSkip > skipped ) {
189
            skipped++;
190
            continue;
191
          }
192
193
          mHaystack.getSelectionModel().select( item );
194
          final int index = mHaystack.getSelectionModel().getSelectedIndex();
195
          mHaystack.getFocusModel().focus( index );
196
          mHaystack.scrollTo( index );
197
          found = true;
198
          break;
199
        }
200
      }
201
202
      if( !found ) {
203
        clue( "Main.status.font.search.missing", mNeedle );
204
        mSearchSkip = 0;
205
      }
206
    }
207
  }
208
}
1209
M src/main/java/com/keenwrite/preferences/Workspace.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.preferences;
3
4
import com.keenwrite.Constants;
5
import com.keenwrite.sigils.Tokens;
6
import javafx.application.Platform;
7
import javafx.beans.property.*;
8
import org.apache.commons.configuration2.XMLConfiguration;
9
import org.apache.commons.configuration2.builder.fluent.Configurations;
10
import org.apache.commons.configuration2.io.FileHandler;
11
12
import java.io.File;
13
import java.util.HashSet;
14
import java.util.LinkedHashSet;
15
import java.util.Map;
16
import java.util.Set;
17
import java.util.function.BiConsumer;
18
import java.util.function.BooleanSupplier;
19
import java.util.function.Consumer;
20
import java.util.function.Function;
21
22
import static com.keenwrite.Bootstrap.APP_TITLE_LOWERCASE;
23
import static com.keenwrite.Constants.*;
24
import static com.keenwrite.Launcher.getVersion;
25
import static com.keenwrite.StatusBarNotifier.clue;
26
import static com.keenwrite.preferences.Key.key;
27
import static java.util.Map.entry;
28
import static javafx.application.Platform.runLater;
29
import static javafx.collections.FXCollections.observableSet;
30
31
/**
32
 * Responsible for defining behaviours for separate projects. A workspace has
33
 * the ability to save and restore a session, including the window dimensions,
34
 * tab setup, files, and user preferences.
35
 * <p>
36
 * The configuration must support hierarchical (nested) configuration nodes
37
 * to persist the user interface state. Although possible with a flat
38
 * configuration file, it's not nearly as simple or elegant.
39
 * </p>
40
 * <p>
41
 * Neither JSON nor HOCON support schema validation and versioning, which makes
42
 * XML the more suitable configuration file format. Schema validation and
43
 * versioning provide future-proofing and ease of reading and upgrading previous
44
 * versions of the configuration file.
45
 * </p>
46
 * <p>
47
 * Persistent preferences may be set directly by the user or indirectly by
48
 * the act of using the application.
49
 * </p>
50
 * <p>
51
 * Note the following definitions:
52
 * </p>
53
 * <dl>
54
 *   <dt>File</dt>
55
 *   <dd>References a file name (no path), path, or directory.</dd>
56
 *   <dt>Path</dt>
57
 *   <dd>Fully qualified file name, which includes all parent directories.</dd>
58
 *   <dt>Dir</dt>
59
 *   <dd>Directory without a file name ({@link File#isDirectory()} is true)
60
 *   .</dd>
61
 * </dl>
62
 */
63
public class Workspace {
64
  private static final Key KEY_ROOT = key( "workspace" );
65
66
  public static final Key KEY_META = key( KEY_ROOT, "meta" );
67
  public static final Key KEY_META_NAME = key( KEY_META, "name" );
68
  public static final Key KEY_META_VERSION = key( KEY_META, "version" );
69
70
  public static final Key KEY_R = key( KEY_ROOT, "r" );
71
  public static final Key KEY_R_SCRIPT = key( KEY_R, "script" );
72
  public static final Key KEY_R_DIR = key( KEY_R, "dir" );
73
  public static final Key KEY_R_DELIM = key( KEY_R, "delimiter" );
74
  public static final Key KEY_R_DELIM_BEGAN = key( KEY_R_DELIM, "began" );
75
  public static final Key KEY_R_DELIM_ENDED = key( KEY_R_DELIM, "ended" );
76
77
  public static final Key KEY_IMAGES = key( KEY_ROOT, "images" );
78
  public static final Key KEY_IMAGES_DIR = key( KEY_IMAGES, "dir" );
79
  public static final Key KEY_IMAGES_ORDER = key( KEY_IMAGES, "order" );
80
81
  public static final Key KEY_DEF = key( KEY_ROOT, "definition" );
82
  public static final Key KEY_DEF_PATH = key( KEY_DEF, "path" );
83
  public static final Key KEY_DEF_DELIM = key( KEY_DEF, "delimiter" );
84
  public static final Key KEY_DEF_DELIM_BEGAN = key( KEY_DEF_DELIM, "began" );
85
  public static final Key KEY_DEF_DELIM_ENDED = key( KEY_DEF_DELIM, "ended" );
86
87
  //@formatter:off
88
  public static final Key KEY_UI = key( KEY_ROOT, "ui" );
89
90
  public static final Key KEY_UI_RECENT = key( KEY_UI, "recent" );
91
  public static final Key KEY_UI_RECENT_DIR = key( KEY_UI_RECENT, "dir" );
92
  public static final Key KEY_UI_RECENT_DOCUMENT = key( KEY_UI_RECENT,"document" );
93
  public static final Key KEY_UI_RECENT_DEFINITION = key( KEY_UI_RECENT, "definition" );
94
95
  public static final Key KEY_UI_FILES = key( KEY_UI, "files" );
96
  public static final Key KEY_UI_FILES_PATH = key( KEY_UI_FILES, "path" );
97
98
  public static final Key KEY_UI_FONT = key( KEY_UI, "font" );
99
  public static final Key KEY_UI_FONT_LOCALE = key( KEY_UI_FONT, "locale" );
100
  public static final Key KEY_UI_FONT_EDITOR = key( KEY_UI_FONT, "editor" );
101
  public static final Key KEY_UI_FONT_EDITOR_SIZE = key( KEY_UI_FONT_EDITOR, "size" );
102
  public static final Key KEY_UI_FONT_PREVIEW = key( KEY_UI_FONT, "preview" );
103
  public static final Key KEY_UI_FONT_PREVIEW_SIZE = key( KEY_UI_FONT_PREVIEW, "size" );
104
105
  public static final Key KEY_UI_WINDOW = key( KEY_UI, "window" );
106
  public static final Key KEY_UI_WINDOW_X = key( KEY_UI_WINDOW, "x" );
107
  public static final Key KEY_UI_WINDOW_Y = key( KEY_UI_WINDOW, "y" );
108
  public static final Key KEY_UI_WINDOW_W = key( KEY_UI_WINDOW, "width" );
109
  public static final Key KEY_UI_WINDOW_H = key( KEY_UI_WINDOW, "height" );
110
  public static final Key KEY_UI_WINDOW_MAX = key( KEY_UI_WINDOW, "maximized" );
111
  public static final Key KEY_UI_WINDOW_FULL = key( KEY_UI_WINDOW, "full" );
112
113
  private final Map<Key, Property<?>> VALUES = Map.ofEntries(
114
    entry( KEY_META_VERSION, new SimpleStringProperty( getVersion() ) ),
115
    entry( KEY_META_NAME, new SimpleStringProperty( "default" ) ),
116
    
117
    entry( KEY_R_SCRIPT, new SimpleStringProperty( "" ) ),
118
    entry( KEY_R_DIR, new FileProperty( USER_DIRECTORY ) ),
119
    entry( KEY_R_DELIM_BEGAN, new SimpleStringProperty( R_DELIM_BEGAN_DEFAULT ) ),
120
    entry( KEY_R_DELIM_ENDED, new SimpleStringProperty( R_DELIM_ENDED_DEFAULT ) ),
121
    
122
    entry( KEY_IMAGES_DIR, new FileProperty( USER_DIRECTORY ) ),
123
    entry( KEY_IMAGES_ORDER, new SimpleStringProperty( PERSIST_IMAGES_DEFAULT ) ),
124
    
125
    entry( KEY_DEF_PATH, new FileProperty( DEFINITION_DEFAULT ) ),
126
    entry( KEY_DEF_DELIM_BEGAN, new SimpleStringProperty( DEF_DELIM_BEGAN_DEFAULT ) ),
127
    entry( KEY_DEF_DELIM_ENDED, new SimpleStringProperty( DEF_DELIM_ENDED_DEFAULT ) ),
128
    
129
    entry( KEY_UI_RECENT_DIR, new FileProperty( USER_DIRECTORY ) ),
130
    entry( KEY_UI_RECENT_DOCUMENT, new FileProperty( DOCUMENT_DEFAULT ) ),
131
    entry( KEY_UI_RECENT_DEFINITION, new FileProperty( DEFINITION_DEFAULT ) ),
132
    
133
    entry( KEY_UI_FONT_LOCALE, new LocaleProperty( LOCALE_DEFAULT ) ),
134
    entry( KEY_UI_FONT_EDITOR_SIZE, new SimpleDoubleProperty( FONT_SIZE_EDITOR_DEFAULT ) ),
135
    entry( KEY_UI_FONT_PREVIEW_SIZE, new SimpleDoubleProperty( FONT_SIZE_PREVIEW_DEFAULT ) ),
136
    
137
    entry( KEY_UI_WINDOW_X, new SimpleDoubleProperty( WINDOW_X_DEFAULT ) ),
138
    entry( KEY_UI_WINDOW_Y, new SimpleDoubleProperty( WINDOW_Y_DEFAULT ) ),
139
    entry( KEY_UI_WINDOW_W, new SimpleDoubleProperty( WINDOW_W_DEFAULT ) ),
140
    entry( KEY_UI_WINDOW_H, new SimpleDoubleProperty( WINDOW_H_DEFAULT ) ),
141
    entry( KEY_UI_WINDOW_MAX, new SimpleBooleanProperty() ),
142
    entry( KEY_UI_WINDOW_FULL, new SimpleBooleanProperty() )
143
  );
144
  //@formatter:on
145
146
  /**
147
   * Helps instantiate {@link Property} instances for XML configuration items.
148
   */
149
  private static final Map<Class<?>, Function<String, Object>> UNMARSHALL =
150
    Map.of(
151
      LocaleProperty.class, LocaleProperty::parseLocale,
152
      SimpleBooleanProperty.class, Boolean::parseBoolean,
153
      SimpleDoubleProperty.class, Double::parseDouble,
154
      SimpleFloatProperty.class, Float::parseFloat,
155
      FileProperty.class, File::new
156
    );
157
158
  private static final Map<Class<?>, Function<String, Object>> MARSHALL =
159
    Map.of(
160
      LocaleProperty.class, LocaleProperty::toLanguageTag
161
    );
162
163
  private final Map<Key, SetProperty<?>> SETS = Map.ofEntries(
164
    entry(
165
      KEY_UI_FILES_PATH,
166
      new SimpleSetProperty<>( observableSet( new HashSet<>() ) )
167
    )
168
  );
169
170
  /**
171
   * Creates a new {@link Workspace} that will attempt to load a configuration
172
   * file. If the configuration file cannot be loaded, the workspace settings
173
   * will return default values. This allows unit tests to provide an instance
174
   * of {@link Workspace} when necessary without encountering failures.
175
   */
176
  public Workspace() {
177
    load();
178
  }
179
180
  /**
181
   * Returns a value that represents a setting in the application that the user
182
   * may configure, either directly or indirectly.
183
   *
184
   * @param key The reference to the users' preference stored in deference
185
   *            of app reëntrance.
186
   * @return An observable property to be persisted.
187
   */
188
  @SuppressWarnings( "unchecked" )
189
  public <T, U extends Property<T>> U valuesProperty( final Key key ) {
190
    // The type that goes into the map must come out.
191
    return (U) VALUES.get( key );
192
  }
193
194
  /**
195
   * Returns a list of values that represent a setting in the application that
196
   * the user may configure, either directly or indirectly. The property
197
   * returned is backed by a mutable {@link Set}.
198
   *
199
   * @param key The {@link Key} associated with a preference value.
200
   * @return An observable property to be persisted.
201
   */
202
  @SuppressWarnings( "unchecked" )
203
  public <T> SetProperty<T> setsProperty( final Key key ) {
204
    // The type that goes into the map must come out.
205
    return (SetProperty<T>) SETS.get( key );
206
  }
207
208
  /**
209
   * Returns the {@link Boolean} preference value associated with the given
210
   * {@link Key}. The caller must be sure that the given {@link Key} is
211
   * associated with a value that matches the return type.
212
   *
213
   * @param key The {@link Key} associated with a preference value.
214
   * @return The value associated with the given {@link Key}.
215
   */
216
  public boolean toBoolean( final Key key ) {
217
    return (Boolean) valuesProperty( key ).getValue();
218
  }
219
220
  /**
221
   * Returns the {@link Double} preference value associated with the given
222
   * {@link Key}. The caller must be sure that the given {@link Key} is
223
   * associated with a value that matches the return type.
224
   *
225
   * @param key The {@link Key} associated with a preference value.
226
   * @return The value associated with the given {@link Key}.
227
   */
228
  public double toDouble( final Key key ) {
229
    return (Double) valuesProperty( key ).getValue();
230
  }
231
232
  public File toFile( final Key key ) {
233
    return fileProperty( key ).get();
234
  }
235
236
  public String toString( final Key key ) {
237
    return stringProperty( key ).get();
238
  }
239
240
  public Tokens toTokens( final Key began, final Key ended ) {
241
    return new Tokens( stringProperty( began ), stringProperty( ended ) );
242
  }
243
244
  @SuppressWarnings( "SameParameterValue" )
245
  public DoubleProperty doubleProperty( final Key key ) {
246
    return valuesProperty( key );
247
  }
248
249
  /**
250
   * Returns the {@link File} {@link Property} associated with the given
251
   * {@link Key} from the internal list of preference values. The caller
252
   * must be sure that the given {@link Key} is associated with a {@link File}
253
   * {@link Property}.
254
   *
255
   * @param key The {@link Key} associated with a preference value.
256
   * @return The value associated with the given {@link Key}.
257
   */
258
  public ObjectProperty<File> fileProperty( final Key key ) {
259
    return valuesProperty( key );
260
  }
261
262
  public LocaleProperty localeProperty( final Key key ) {
263
    return valuesProperty( key );
264
  }
265
266
  public StringProperty stringProperty( final Key key ) {
267
    return valuesProperty( key );
268
  }
269
270
  public void loadValueKeys( final Consumer<Key> consumer ) {
271
    VALUES.keySet().forEach( consumer );
272
  }
273
274
  public void loadSetKeys( final Consumer<Key> consumer ) {
275
    SETS.keySet().forEach( consumer );
276
  }
277
278
  /**
279
   * Calls the given consumer for all single-value keys. For lists, see
280
   * {@link #saveSets(BiConsumer)}.
281
   *
282
   * @param consumer Called to accept each preference key value.
283
   */
284
  public void saveValues( final BiConsumer<Key, Property<?>> consumer ) {
285
    VALUES.forEach( consumer );
286
  }
287
288
  /**
289
   * Calls the given consumer for all multi-value keys. For single items, see
290
   * {@link #saveValues(BiConsumer)}. Callers are responsible for iterating
291
   * over the list of items retrieved through this method.
292
   *
293
   * @param consumer Called to accept each preference key list.
294
   */
295
  public void saveSets( final BiConsumer<Key, SetProperty<?>> consumer ) {
296
    SETS.forEach( consumer );
297
  }
298
299
  /**
300
   * Delegates to {@link #listen(Key, ReadOnlyProperty, BooleanSupplier)},
301
   * providing a value of {@code true} for the {@link BooleanSupplier} to
302
   * indicate the property changes always take effect.
303
   *
304
   * @param key      The value to bind to the internal key property.
305
   * @param property The external property value that sets the internal value.
306
   */
307
  public <T> void listen( final Key key, final ReadOnlyProperty<T> property ) {
308
    listen( key, property, () -> true );
309
  }
310
311
  /**
312
   * Binds a read-only property to a value in the preferences. This allows
313
   * user interface properties to change and the preferences will be
314
   * synchronized automatically.
315
   * <p>
316
   * This calls {@link Platform#runLater(Runnable)} to ensure that all pending
317
   * application window states are finished before assessing whether property
318
   * changes should be applied. Without this, exiting the application while the
319
   * window is maximized would persist the window's maximum dimensions,
320
   * preventing restoration to its prior, non-maximum size.
321
   * </p>
322
   *
323
   * @param key      The value to bind to the internal key property.
324
   * @param property The external property value that sets the internal value.
325
   * @param enabled  Indicates whether property changes should be applied.
326
   */
327
  public <T> void listen(
328
    final Key key,
329
    final ReadOnlyProperty<T> property,
330
    final BooleanSupplier enabled ) {
331
    property.addListener(
332
      ( c, o, n ) -> runLater( () -> {
333
        if( enabled.getAsBoolean() ) {
334
          valuesProperty( key ).setValue( n );
335
        }
336
      } )
337
    );
338
  }
339
340
  /**
341
   * Saves the current workspace.
342
   */
343
  public void save() {
344
    try {
345
      final var config = new XMLConfiguration();
346
347
      // The root config key can only be set for an empty configuration file.
348
      config.setRootElementName( APP_TITLE_LOWERCASE );
349
350
      saveValues( ( key, property ) ->
351
                    config.setProperty( key.toString(), marshall( property ) )
352
      );
353
354
      saveSets(
355
        ( key, set ) -> {
356
          final var keyName = key.toString();
357
          set.forEach( ( value ) -> config.addProperty( keyName, value ) );
358
        }
359
      );
360
      new FileHandler( config ).save( FILE_PREFERENCES );
361
    } catch( final Exception ex ) {
362
      clue( ex );
363
    }
364
  }
365
366
  /**
367
   * Attempts to load the {@link Constants#FILE_PREFERENCES} configuration file.
368
   * If not found, this will fall back to an empty configuration file, leaving
369
   * the application to fill in default values.
370
   */
371
  private void load() {
372
    try {
373
      final var config = new Configurations().xml( FILE_PREFERENCES );
374
375
      loadValueKeys( ( key ) -> {
376
        final var configValue = config.getProperty( key.toString() );
377
        final var propertyValue = valuesProperty( key );
378
        propertyValue.setValue( unmarshall( propertyValue, configValue ) );
379
      } );
380
381
      loadSetKeys( ( key ) -> {
382
        final var configSet =
383
          new LinkedHashSet<>( config.getList( key.toString() ) );
384
        final var propertySet = setsProperty( key );
385
        propertySet.setValue( observableSet( configSet ) );
386
      } );
387
    } catch( final Exception ex ) {
388
      clue( ex );
389
    }
390
  }
391
392
  private Object unmarshall(
393
    final Property<?> property, final Object configValue ) {
394
    return UNMARSHALL
395
      .getOrDefault( property.getClass(), ( value ) -> value )
396
      .apply( configValue.toString() );
397
  }
398
399
  private Object marshall( final Property<?> property ) {
400
    return MARSHALL
401
      .getOrDefault( property.getClass(), ( v ) -> property.getValue() )
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.preferences;
3
4
import com.keenwrite.Constants;
5
import com.keenwrite.sigils.Tokens;
6
import javafx.application.Platform;
7
import javafx.beans.property.*;
8
import javafx.collections.ObservableList;
9
import org.apache.commons.configuration2.XMLConfiguration;
10
import org.apache.commons.configuration2.builder.fluent.Configurations;
11
import org.apache.commons.configuration2.io.FileHandler;
12
13
import java.io.File;
14
import java.util.*;
15
import java.util.function.BiConsumer;
16
import java.util.function.BooleanSupplier;
17
import java.util.function.Consumer;
18
import java.util.function.Function;
19
20
import static com.keenwrite.Bootstrap.APP_TITLE_LOWERCASE;
21
import static com.keenwrite.Constants.*;
22
import static com.keenwrite.Launcher.getVersion;
23
import static com.keenwrite.StatusNotifier.clue;
24
import static com.keenwrite.preferences.Key.key;
25
import static java.util.Map.entry;
26
import static javafx.application.Platform.runLater;
27
import static javafx.collections.FXCollections.observableArrayList;
28
import static javafx.collections.FXCollections.observableSet;
29
30
/**
31
 * Responsible for defining behaviours for separate projects. A workspace has
32
 * the ability to save and restore a session, including the window dimensions,
33
 * tab setup, files, and user preferences.
34
 * <p>
35
 * The configuration must support hierarchical (nested) configuration nodes
36
 * to persist the user interface state. Although possible with a flat
37
 * configuration file, it's not nearly as simple or elegant.
38
 * </p>
39
 * <p>
40
 * Neither JSON nor HOCON support schema validation and versioning, which makes
41
 * XML the more suitable configuration file format. Schema validation and
42
 * versioning provide future-proofing and ease of reading and upgrading previous
43
 * versions of the configuration file.
44
 * </p>
45
 * <p>
46
 * Persistent preferences may be set directly by the user or indirectly by
47
 * the act of using the application.
48
 * </p>
49
 * <p>
50
 * Note the following definitions:
51
 * </p>
52
 * <dl>
53
 *   <dt>File</dt>
54
 *   <dd>References a file name (no path), path, or directory.</dd>
55
 *   <dt>Path</dt>
56
 *   <dd>Fully qualified file name, which includes all parent directories.</dd>
57
 *   <dt>Dir</dt>
58
 *   <dd>Directory without a file name ({@link File#isDirectory()} is true)
59
 *   .</dd>
60
 * </dl>
61
 */
62
public class Workspace {
63
  private static final Key KEY_ROOT = key( "workspace" );
64
65
  public static final Key KEY_META = key( KEY_ROOT, "meta" );
66
  public static final Key KEY_META_NAME = key( KEY_META, "name" );
67
  public static final Key KEY_META_VERSION = key( KEY_META, "version" );
68
69
  public static final Key KEY_R = key( KEY_ROOT, "r" );
70
  public static final Key KEY_R_SCRIPT = key( KEY_R, "script" );
71
  public static final Key KEY_R_DIR = key( KEY_R, "dir" );
72
  public static final Key KEY_R_DELIM = key( KEY_R, "delimiter" );
73
  public static final Key KEY_R_DELIM_BEGAN = key( KEY_R_DELIM, "began" );
74
  public static final Key KEY_R_DELIM_ENDED = key( KEY_R_DELIM, "ended" );
75
76
  public static final Key KEY_IMAGES = key( KEY_ROOT, "images" );
77
  public static final Key KEY_IMAGES_DIR = key( KEY_IMAGES, "dir" );
78
  public static final Key KEY_IMAGES_ORDER = key( KEY_IMAGES, "order" );
79
80
  public static final Key KEY_DEF = key( KEY_ROOT, "definition" );
81
  public static final Key KEY_DEF_PATH = key( KEY_DEF, "path" );
82
  public static final Key KEY_DEF_DELIM = key( KEY_DEF, "delimiter" );
83
  public static final Key KEY_DEF_DELIM_BEGAN = key( KEY_DEF_DELIM, "began" );
84
  public static final Key KEY_DEF_DELIM_ENDED = key( KEY_DEF_DELIM, "ended" );
85
86
  //@formatter:off
87
  public static final Key KEY_UI = key( KEY_ROOT, "ui" );
88
89
  public static final Key KEY_UI_RECENT = key( KEY_UI, "recent" );
90
  public static final Key KEY_UI_RECENT_DIR = key( KEY_UI_RECENT, "dir" );
91
  public static final Key KEY_UI_RECENT_DOCUMENT = key( KEY_UI_RECENT,"document" );
92
  public static final Key KEY_UI_RECENT_DEFINITION = key( KEY_UI_RECENT, "definition" );
93
94
  public static final Key KEY_UI_FILES = key( KEY_UI, "files" );
95
  public static final Key KEY_UI_FILES_PATH = key( KEY_UI_FILES, "path" );
96
97
  public static final Key KEY_UI_FONT = key( KEY_UI, "font" );
98
  public static final Key KEY_UI_FONT_EDITOR = key( KEY_UI_FONT, "editor" );
99
  public static final Key KEY_UI_FONT_EDITOR_NAME = key( KEY_UI_FONT_EDITOR, "name" );
100
  public static final Key KEY_UI_FONT_EDITOR_SIZE = key( KEY_UI_FONT_EDITOR, "size" );
101
  public static final Key KEY_UI_FONT_PREVIEW = key( KEY_UI_FONT, "preview" );
102
  public static final Key KEY_UI_FONT_PREVIEW_NAME = key( KEY_UI_FONT_PREVIEW, "name" );
103
  public static final Key KEY_UI_FONT_PREVIEW_SIZE = key( KEY_UI_FONT_PREVIEW, "size" );
104
  public static final Key KEY_UI_FONT_PREVIEW_MONO = key( KEY_UI_FONT_PREVIEW, "mono" );
105
  public static final Key KEY_UI_FONT_PREVIEW_MONO_NAME = key( KEY_UI_FONT_PREVIEW_MONO, "name" );
106
  public static final Key KEY_UI_FONT_PREVIEW_MONO_SIZE = key( KEY_UI_FONT_PREVIEW_MONO, "size" );
107
108
  public static final Key KEY_LANGUAGE = key( KEY_ROOT, "language" );
109
  public static final Key KEY_LANG_LOCALE = key( KEY_LANGUAGE, "locale" );
110
111
  public static final Key KEY_UI_WINDOW = key( KEY_UI, "window" );
112
  public static final Key KEY_UI_WINDOW_X = key( KEY_UI_WINDOW, "x" );
113
  public static final Key KEY_UI_WINDOW_Y = key( KEY_UI_WINDOW, "y" );
114
  public static final Key KEY_UI_WINDOW_W = key( KEY_UI_WINDOW, "width" );
115
  public static final Key KEY_UI_WINDOW_H = key( KEY_UI_WINDOW, "height" );
116
  public static final Key KEY_UI_WINDOW_MAX = key( KEY_UI_WINDOW, "maximized" );
117
  public static final Key KEY_UI_WINDOW_FULL = key( KEY_UI_WINDOW, "full" );
118
119
  private final Map<Key, Property<?>> VALUES = Map.ofEntries(
120
    entry( KEY_META_VERSION, asStringProperty( getVersion() ) ),
121
    entry( KEY_META_NAME, asStringProperty( "default" ) ),
122
    
123
    entry( KEY_R_SCRIPT, asStringProperty( "" ) ),
124
    entry( KEY_R_DIR, asFileProperty( USER_DIRECTORY ) ),
125
    entry( KEY_R_DELIM_BEGAN, asStringProperty( R_DELIM_BEGAN_DEFAULT ) ),
126
    entry( KEY_R_DELIM_ENDED, asStringProperty( R_DELIM_ENDED_DEFAULT ) ),
127
    
128
    entry( KEY_IMAGES_DIR, asFileProperty( USER_DIRECTORY ) ),
129
    entry( KEY_IMAGES_ORDER, asStringProperty( PERSIST_IMAGES_DEFAULT ) ),
130
    
131
    entry( KEY_DEF_PATH, asFileProperty( DEFINITION_DEFAULT ) ),
132
    entry( KEY_DEF_DELIM_BEGAN, asStringProperty( DEF_DELIM_BEGAN_DEFAULT ) ),
133
    entry( KEY_DEF_DELIM_ENDED, asStringProperty( DEF_DELIM_ENDED_DEFAULT ) ),
134
    
135
    entry( KEY_UI_RECENT_DIR, asFileProperty( USER_DIRECTORY ) ),
136
    entry( KEY_UI_RECENT_DOCUMENT, asFileProperty( DOCUMENT_DEFAULT ) ),
137
    entry( KEY_UI_RECENT_DEFINITION, asFileProperty( DEFINITION_DEFAULT ) ),
138
    
139
    entry( KEY_LANG_LOCALE, asLocaleProperty( LOCALE_DEFAULT ) ),
140
    entry( KEY_UI_FONT_EDITOR_NAME, asStringProperty( FONT_NAME_EDITOR_DEFAULT ) ),
141
    entry( KEY_UI_FONT_EDITOR_SIZE, asDoubleProperty( FONT_SIZE_EDITOR_DEFAULT ) ),
142
    entry( KEY_UI_FONT_PREVIEW_NAME, asStringProperty( FONT_NAME_PREVIEW_DEFAULT ) ),
143
    entry( KEY_UI_FONT_PREVIEW_SIZE, asDoubleProperty( FONT_SIZE_PREVIEW_DEFAULT ) ),
144
    entry( KEY_UI_FONT_PREVIEW_MONO_NAME, asStringProperty( FONT_NAME_PREVIEW_MONO_NAME_DEFAULT ) ),
145
    entry( KEY_UI_FONT_PREVIEW_MONO_SIZE, asDoubleProperty( FONT_SIZE_PREVIEW_MONO_SIZE_DEFAULT ) ),
146
147
    entry( KEY_UI_WINDOW_X, asDoubleProperty( WINDOW_X_DEFAULT ) ),
148
    entry( KEY_UI_WINDOW_Y, asDoubleProperty( WINDOW_Y_DEFAULT ) ),
149
    entry( KEY_UI_WINDOW_W, asDoubleProperty( WINDOW_W_DEFAULT ) ),
150
    entry( KEY_UI_WINDOW_H, asDoubleProperty( WINDOW_H_DEFAULT ) ),
151
    entry( KEY_UI_WINDOW_MAX, asBooleanProperty() ),
152
    entry( KEY_UI_WINDOW_FULL, asBooleanProperty() )
153
  );
154
  //@formatter:on
155
156
  private StringProperty asStringProperty( final String defaultValue ) {
157
    return new SimpleStringProperty( defaultValue );
158
  }
159
160
  private DoubleProperty asDoubleProperty( final double defaultValue ) {
161
    return new SimpleDoubleProperty( defaultValue );
162
  }
163
164
  private BooleanProperty asBooleanProperty() {
165
    return new SimpleBooleanProperty();
166
  }
167
168
  private FileProperty asFileProperty( final File defaultValue ) {
169
    return new FileProperty( defaultValue );
170
  }
171
172
  @SuppressWarnings( "SameParameterValue" )
173
  private LocaleProperty asLocaleProperty( final Locale defaultValue ) {
174
    return new LocaleProperty( defaultValue );
175
  }
176
177
  /**
178
   * Helps instantiate {@link Property} instances for XML configuration items.
179
   */
180
  private static final Map<Class<?>, Function<String, Object>> UNMARSHALL =
181
    Map.of(
182
      LocaleProperty.class, LocaleProperty::parseLocale,
183
      SimpleBooleanProperty.class, Boolean::parseBoolean,
184
      SimpleDoubleProperty.class, Double::parseDouble,
185
      SimpleFloatProperty.class, Float::parseFloat,
186
      FileProperty.class, File::new
187
    );
188
189
  private static final Map<Class<?>, Function<String, Object>> MARSHALL =
190
    Map.of(
191
      LocaleProperty.class, LocaleProperty::toLanguageTag
192
    );
193
194
  private final Map<Key, SetProperty<?>> SETS = Map.ofEntries(
195
    entry(
196
      KEY_UI_FILES_PATH,
197
      new SimpleSetProperty<>( observableSet( new HashSet<>() ) )
198
    )
199
  );
200
201
  /**
202
   * Creates a new {@link Workspace} that will attempt to load a configuration
203
   * file. If the configuration file cannot be loaded, the workspace settings
204
   * will return default values. This allows unit tests to provide an instance
205
   * of {@link Workspace} when necessary without encountering failures.
206
   */
207
  public Workspace() {
208
    load();
209
  }
210
211
  /**
212
   * Creates an instance of {@link ObservableList} that is based on a
213
   * modifiable observable array list for the given items.
214
   *
215
   * @param items The items to wrap in an observable list.
216
   * @param <E>   The type of items to add to the list.
217
   * @return An observable property that can have its contents modified.
218
   */
219
  public static <E> ObservableList<E> listProperty( final Set<E> items ) {
220
    return new SimpleListProperty<>( observableArrayList( items ) );
221
  }
222
223
  /**
224
   * Returns a value that represents a setting in the application that the user
225
   * may configure, either directly or indirectly.
226
   *
227
   * @param key The reference to the users' preference stored in deference
228
   *            of app reëntrance.
229
   * @return An observable property to be persisted.
230
   */
231
  @SuppressWarnings( "unchecked" )
232
  public <T, U extends Property<T>> U valuesProperty( final Key key ) {
233
    // The type that goes into the map must come out.
234
    return (U) VALUES.get( key );
235
  }
236
237
  /**
238
   * Returns a list of values that represent a setting in the application that
239
   * the user may configure, either directly or indirectly. The property
240
   * returned is backed by a mutable {@link Set}.
241
   *
242
   * @param key The {@link Key} associated with a preference value.
243
   * @return An observable property to be persisted.
244
   */
245
  @SuppressWarnings( "unchecked" )
246
  public <T> SetProperty<T> setsProperty( final Key key ) {
247
    // The type that goes into the map must come out.
248
    return (SetProperty<T>) SETS.get( key );
249
  }
250
251
  /**
252
   * Returns the {@link Boolean} preference value associated with the given
253
   * {@link Key}. The caller must be sure that the given {@link Key} is
254
   * associated with a value that matches the return type.
255
   *
256
   * @param key The {@link Key} associated with a preference value.
257
   * @return The value associated with the given {@link Key}.
258
   */
259
  public boolean toBoolean( final Key key ) {
260
    return (Boolean) valuesProperty( key ).getValue();
261
  }
262
263
  /**
264
   * Returns the {@link Double} preference value associated with the given
265
   * {@link Key}. The caller must be sure that the given {@link Key} is
266
   * associated with a value that matches the return type.
267
   *
268
   * @param key The {@link Key} associated with a preference value.
269
   * @return The value associated with the given {@link Key}.
270
   */
271
  public double toDouble( final Key key ) {
272
    return (Double) valuesProperty( key ).getValue();
273
  }
274
275
  public File toFile( final Key key ) {
276
    return fileProperty( key ).get();
277
  }
278
279
  public String toString( final Key key ) {
280
    return stringProperty( key ).get();
281
  }
282
283
  public Tokens toTokens( final Key began, final Key ended ) {
284
    return new Tokens( stringProperty( began ), stringProperty( ended ) );
285
  }
286
287
  @SuppressWarnings( "SameParameterValue" )
288
  public DoubleProperty doubleProperty( final Key key ) {
289
    return valuesProperty( key );
290
  }
291
292
  /**
293
   * Returns the {@link File} {@link Property} associated with the given
294
   * {@link Key} from the internal list of preference values. The caller
295
   * must be sure that the given {@link Key} is associated with a {@link File}
296
   * {@link Property}.
297
   *
298
   * @param key The {@link Key} associated with a preference value.
299
   * @return The value associated with the given {@link Key}.
300
   */
301
  public ObjectProperty<File> fileProperty( final Key key ) {
302
    return valuesProperty( key );
303
  }
304
305
  public LocaleProperty localeProperty( final Key key ) {
306
    return valuesProperty( key );
307
  }
308
309
  /**
310
   * Returns the language locale setting for the {@link #KEY_LANG_LOCALE} key.
311
   *
312
   * @return The user's current locale setting.
313
   */
314
  public Locale getLocale() {
315
    return localeProperty( KEY_LANG_LOCALE ).toLocale();
316
  }
317
318
  public StringProperty stringProperty( final Key key ) {
319
    return valuesProperty( key );
320
  }
321
322
  public void loadValueKeys( final Consumer<Key> consumer ) {
323
    VALUES.keySet().forEach( consumer );
324
  }
325
326
  public void loadSetKeys( final Consumer<Key> consumer ) {
327
    SETS.keySet().forEach( consumer );
328
  }
329
330
  /**
331
   * Calls the given consumer for all single-value keys. For lists, see
332
   * {@link #saveSets(BiConsumer)}.
333
   *
334
   * @param consumer Called to accept each preference key value.
335
   */
336
  public void saveValues( final BiConsumer<Key, Property<?>> consumer ) {
337
    VALUES.forEach( consumer );
338
  }
339
340
  /**
341
   * Calls the given consumer for all multi-value keys. For single items, see
342
   * {@link #saveValues(BiConsumer)}. Callers are responsible for iterating
343
   * over the list of items retrieved through this method.
344
   *
345
   * @param consumer Called to accept each preference key list.
346
   */
347
  public void saveSets( final BiConsumer<Key, SetProperty<?>> consumer ) {
348
    SETS.forEach( consumer );
349
  }
350
351
  /**
352
   * Delegates to {@link #listen(Key, ReadOnlyProperty, BooleanSupplier)},
353
   * providing a value of {@code true} for the {@link BooleanSupplier} to
354
   * indicate the property changes always take effect.
355
   *
356
   * @param key      The value to bind to the internal key property.
357
   * @param property The external property value that sets the internal value.
358
   */
359
  public <T> void listen( final Key key, final ReadOnlyProperty<T> property ) {
360
    listen( key, property, () -> true );
361
  }
362
363
  /**
364
   * Binds a read-only property to a value in the preferences. This allows
365
   * user interface properties to change and the preferences will be
366
   * synchronized automatically.
367
   * <p>
368
   * This calls {@link Platform#runLater(Runnable)} to ensure that all pending
369
   * application window states are finished before assessing whether property
370
   * changes should be applied. Without this, exiting the application while the
371
   * window is maximized would persist the window's maximum dimensions,
372
   * preventing restoration to its prior, non-maximum size.
373
   * </p>
374
   *
375
   * @param key      The value to bind to the internal key property.
376
   * @param property The external property value that sets the internal value.
377
   * @param enabled  Indicates whether property changes should be applied.
378
   */
379
  public <T> void listen(
380
    final Key key,
381
    final ReadOnlyProperty<T> property,
382
    final BooleanSupplier enabled ) {
383
    property.addListener(
384
      ( c, o, n ) -> runLater( () -> {
385
        if( enabled.getAsBoolean() ) {
386
          valuesProperty( key ).setValue( n );
387
        }
388
      } )
389
    );
390
  }
391
392
  /**
393
   * Saves the current workspace.
394
   */
395
  public void save() {
396
    try {
397
      final var config = new XMLConfiguration();
398
399
      // The root config key can only be set for an empty configuration file.
400
      config.setRootElementName( APP_TITLE_LOWERCASE );
401
      valuesProperty( KEY_META_VERSION ).setValue( getVersion() );
402
403
      saveValues( ( key, property ) ->
404
                    config.setProperty( key.toString(), marshall( property ) )
405
      );
406
407
      saveSets( ( key, set ) -> {
408
        final var keyName = key.toString();
409
        set.forEach( ( value ) -> config.addProperty( keyName, value ) );
410
      } );
411
      new FileHandler( config ).save( FILE_PREFERENCES );
412
    } catch( final Exception ex ) {
413
      clue( ex );
414
    }
415
  }
416
417
  /**
418
   * Attempts to load the {@link Constants#FILE_PREFERENCES} configuration file.
419
   * If not found, this will fall back to an empty configuration file, leaving
420
   * the application to fill in default values.
421
   */
422
  private void load() {
423
    try {
424
      final var config = new Configurations().xml( FILE_PREFERENCES );
425
426
      loadValueKeys( ( key ) -> {
427
        final var configValue = config.getProperty( key.toString() );
428
429
        // Allow other properties to load, even if any are missing.
430
        if( configValue != null ) {
431
          final var propertyValue = valuesProperty( key );
432
          propertyValue.setValue( unmarshall( propertyValue, configValue ) );
433
        }
434
      } );
435
436
      loadSetKeys( ( key ) -> {
437
        final var configSet =
438
          new LinkedHashSet<>( config.getList( key.toString() ) );
439
        final var propertySet = setsProperty( key );
440
        propertySet.setValue( observableSet( configSet ) );
441
      } );
442
    } catch( final Exception ex ) {
443
      clue( ex );
444
    }
445
  }
446
447
  private Object unmarshall(
448
    final Property<?> property, final Object configValue ) {
449
    return UNMARSHALL
450
      .getOrDefault( property.getClass(), ( value ) -> value )
451
      .apply( configValue.toString() );
452
  }
453
454
  private Object marshall( final Property<?> property ) {
455
    return MARSHALL
456
      .getOrDefault( property.getClass(), ( __ ) -> property.getValue() )
402457
      .apply( property.getValue().toString() );
403458
  }
M src/main/java/com/keenwrite/preferences/XmlStorageHandler.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.preferences;
33
M src/main/java/com/keenwrite/preview/ChainedReplacedElementFactory.java
11
/* Copyright 2006 Patrick Wright
22
 * Copyright 2007 Wisconsin Court System
3
 * Copyright 2020 White Magic Software, Ltd.
3
 * Copyright 2020-2021 White Magic Software, Ltd.
44
 *
55
 * This program is free software; you can redistribute it and/or
...
3434
import static com.keenwrite.preview.SvgReplacedElementFactory.HTML_IMAGE;
3535
import static com.keenwrite.preview.SvgReplacedElementFactory.HTML_IMAGE_SRC;
36
import static com.keenwrite.processors.markdown.tex.TexNode.HTML_TEX;
36
import static com.keenwrite.processors.markdown.extensions.tex.TexNode.HTML_TEX;
3737
import static java.util.Arrays.asList;
3838
M src/main/java/com/keenwrite/preview/DomConverter.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.preview;
33
...
1414
import java.util.Map;
1515
16
import static com.keenwrite.StatusBarNotifier.clue;
16
import static com.keenwrite.StatusNotifier.clue;
1717
import static com.keenwrite.processors.text.TextReplacementFactory.replace;
1818
M src/main/java/com/keenwrite/preview/HtmlPanel.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.preview;
33
...
1818
import java.net.URI;
1919
20
import static com.keenwrite.StatusBarNotifier.clue;
20
import static com.keenwrite.StatusNotifier.clue;
2121
import static com.keenwrite.util.ProtocolScheme.getProtocol;
2222
import static java.awt.Desktop.Action.BROWSE;
M src/main/java/com/keenwrite/preview/HtmlPreview.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.preview;
33
44
import com.keenwrite.preferences.LocaleProperty;
55
import com.keenwrite.preferences.Workspace;
66
import javafx.beans.property.DoubleProperty;
7
import javafx.beans.property.StringProperty;
78
import javafx.embed.swing.SwingNode;
89
import org.xhtmlrenderer.render.Box;
...
1718
import static com.keenwrite.Constants.*;
1819
import static com.keenwrite.Messages.get;
19
import static com.keenwrite.preferences.Workspace.KEY_UI_FONT_LOCALE;
20
import static com.keenwrite.preferences.Workspace.KEY_UI_FONT_PREVIEW_SIZE;
20
import static com.keenwrite.preferences.Workspace.*;
2121
import static java.lang.Math.max;
2222
import static java.lang.String.format;
...
4141
  /**
4242
   * Render CSS using points (pt) not pixels (px) to reduce the chance of
43
   * poor rendering.
43
   * poor rendering. The {@link #head()} method fills out the placeholders.
4444
   */
4545
  private static final String HTML_HEAD =
4646
    """
4747
      <!DOCTYPE html>
4848
      <html lang='%s'><head><title> </title><meta charset='utf-8'>
4949
      <link rel='stylesheet' href='%s'>
5050
      <link rel='stylesheet' href='%s'>
51
      <style>body{font-size: %spt;}</style>
51
      <style>body{font-family:'%s';font-size: %spt;}</style>
5252
      <base href='%s'>
5353
      </head><body>
...
101101
      } );
102102
103
      fontNameProperty().addListener( ( c, o, n ) -> rerender() );
103104
      fontSizeProperty().addListener( ( c, o, n ) -> rerender() );
104105
    } );
...
145146
      HTML_STYLE_PREVIEW,
146147
      mLocaleUrl,
148
      getFontName(),
147149
      getFontSize(),
148150
      mBaseUriPath
...
277279
278280
  private LocaleProperty localeProperty() {
279
    return mWorkspace.localeProperty( KEY_UI_FONT_LOCALE );
281
    return mWorkspace.localeProperty( KEY_LANG_LOCALE );
282
  }
283
284
  private String getFontName() {
285
    return fontNameProperty().get();
286
  }
287
288
  private StringProperty fontNameProperty() {
289
    return mWorkspace.stringProperty( KEY_UI_FONT_PREVIEW_NAME );
280290
  }
281291
M src/main/java/com/keenwrite/preview/MathRenderer.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.preview;
33
44
import com.whitemagicsoftware.tex.*;
55
import com.whitemagicsoftware.tex.graphics.SvgDomGraphics2D;
66
import org.w3c.dom.Document;
77
88
import java.util.function.Supplier;
99
10
import static com.keenwrite.StatusBarNotifier.clue;
10
import static com.keenwrite.StatusNotifier.clue;
1111
1212
/**
M src/main/java/com/keenwrite/preview/RenderingSettings.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.preview;
33
M src/main/java/com/keenwrite/preview/SvgRasterizer.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.preview;
33
...
2424
import java.text.NumberFormat;
2525
26
import static com.keenwrite.StatusBarNotifier.clue;
26
import static com.keenwrite.StatusNotifier.clue;
2727
import static com.keenwrite.preview.RenderingSettings.RENDERING_HINTS;
2828
import static java.awt.image.BufferedImage.TYPE_INT_RGB;
M src/main/java/com/keenwrite/preview/SvgReplacedElementFactory.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.preview;
33
...
1616
import java.nio.file.Paths;
1717
18
import static com.keenwrite.StatusBarNotifier.clue;
18
import static com.keenwrite.StatusNotifier.clue;
1919
import static com.keenwrite.io.MediaType.*;
2020
import static com.keenwrite.preview.MathRenderer.MATH_RENDERER;
2121
import static com.keenwrite.preview.SvgRasterizer.BROKEN_IMAGE_PLACEHOLDER;
2222
import static com.keenwrite.preview.SvgRasterizer.rasterize;
23
import static com.keenwrite.processors.markdown.tex.TexNode.HTML_TEX;
23
import static com.keenwrite.processors.markdown.extensions.tex.TexNode.HTML_TEX;
2424
import static com.keenwrite.util.ProtocolScheme.getProtocol;
2525
M src/main/java/com/keenwrite/processors/DefinitionProcessor.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.processors;
33
M src/main/java/com/keenwrite/processors/ExecutorProcessor.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.processors;
33
M src/main/java/com/keenwrite/processors/HtmlPreviewProcessor.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.processors;
33
M src/main/java/com/keenwrite/processors/IdentityProcessor.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.processors;
33
44
/**
55
 * Responsible for transforming a string into itself. This is used at the
66
 * end of a processing chain when no more processing is required.
77
 */
88
public class IdentityProcessor extends ExecutorProcessor<String> {
9
  public static final IdentityProcessor INSTANCE = new IdentityProcessor();
9
  public static final IdentityProcessor IDENTITY = new IdentityProcessor();
1010
1111
  /**
M src/main/java/com/keenwrite/processors/InlineRProcessor.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.processors;
33
44
import com.keenwrite.preferences.Workspace;
5
import com.keenwrite.processors.markdown.MarkdownProcessor;
6
import com.vladsch.flexmark.ast.Paragraph;
7
import com.vladsch.flexmark.ast.Text;
5
import com.keenwrite.processors.markdown.extensions.r.ROutputProcessor;
6
import com.keenwrite.util.BoundedCache;
87
import javafx.beans.property.Property;
98
109
import javax.script.ScriptEngine;
1110
import javax.script.ScriptEngineManager;
1211
import java.io.File;
1312
import java.nio.file.Path;
14
import java.util.LinkedHashMap;
1513
import java.util.Map;
1614
import java.util.concurrent.atomic.AtomicBoolean;
1715
1816
import static com.keenwrite.Constants.STATUS_PARSE_ERROR;
19
import static com.keenwrite.StatusBarNotifier.clue;
17
import static com.keenwrite.Messages.get;
18
import static com.keenwrite.StatusNotifier.clue;
2019
import static com.keenwrite.preferences.Workspace.*;
2120
import static com.keenwrite.processors.text.TextReplacementFactory.replace;
2221
import static com.keenwrite.sigils.RSigilOperator.PREFIX;
2322
import static com.keenwrite.sigils.RSigilOperator.SUFFIX;
23
import static java.lang.Math.max;
2424
import static java.lang.Math.min;
2525
2626
/**
2727
 * Transforms a document containing R statements into Markdown.
2828
 */
2929
public final class InlineRProcessor extends DefinitionProcessor {
30
  /**
31
   * Constrain memory when typing new R expressions into the document.
32
   */
33
  private static final int MAX_CACHED_R_STATEMENTS = 512;
34
35
  private final MarkdownProcessor mMarkdownProcessor;
30
  private final Processor<String> mPostProcessor = new ROutputProcessor();
3631
3732
  /**
38
   * Where to put document inline evaluated R expressions.
33
   * Where to put document inline evaluated R expressions, constrained to
34
   * avoid running out of memory.
3935
   */
40
  private final Map<String, String> mEvalCache = new LinkedHashMap<>() {
41
    @Override
42
    protected boolean removeEldestEntry(
43
      final Map.Entry<String, String> eldest ) {
44
      return size() > MAX_CACHED_R_STATEMENTS;
45
    }
46
  };
36
  private final Map<String, String> mEvalCache =
37
    new BoundedCache<>( 512 );
4738
4839
  private static final ScriptEngine ENGINE =
...
6758
6859
    mWorkspace = context.getWorkspace();
69
    mMarkdownProcessor = MarkdownProcessor.create( context );
7060
7161
    bootstrapScriptProperty().addListener(
...
9282
   * any existing R functionality will not be overwritten if this method is
9383
   * called multiple times.
84
   *
85
   * @return {@code true} if initialization completed and all variables were
86
   * replaced; {@code false} if any variables remain.
9487
   */
95
  private void init() {
88
  public boolean init() {
9689
    final var bootstrap = getBootstrapScript();
9790
...
10598
      map.put( defBegan + "application.r.working.directory" + defEnded, dir );
10699
107
      eval( replace( bootstrap, map ) );
100
      final var replaced = replace( bootstrap, map );
101
      final var bIndex = replaced.indexOf( defBegan );
102
103
      //
104
      if( bIndex >= 0 ) {
105
        var eIndex = replaced.indexOf( defEnded );
106
        eIndex = (eIndex == -1) ? replaced.length() - 1 : max( bIndex, eIndex );
107
108
        final var def = replaced.substring( bIndex, eIndex );
109
        clue( "Main.status.error.bootstrap.eval", def );
110
111
        return false;
112
      }
113
      else {
114
        eval( replaced );
115
      }
108116
    }
117
118
    return true;
119
  }
120
121
  /**
122
   * Empties the cache.
123
   */
124
  public void clear() {
125
    mEvalCache.clear();
109126
  }
110127
...
162179
      if( currIndex > 1 ) {
163180
        // Extract the inline R statement to be evaluated.
164
        final String r = text.substring( prevIndex, currIndex );
181
        final var r = text.substring( prevIndex, currIndex );
165182
166183
        // Pass the R statement into the R engine for evaluation.
167184
        try {
168
          final var result = evalCached( r );
169
170185
          // Append the string representation of the result into the text.
171
          sb.append( result );
186
          sb.append( evalCached( r ) );
172187
        } catch( final Exception ex ) {
173188
          // Inform the user that there was a problem.
...
199214
   */
200215
  private String evalCached( final String r ) {
201
    return mEvalCache.computeIfAbsent( r, v -> evalHtml( r ) );
216
    return mEvalCache.computeIfAbsent( r, __ -> evalHtml( r ) );
202217
  }
203218
...
212227
   */
213228
  private String evalHtml( final String r ) {
214
    final var markdown = eval( r );
215
    var node = mMarkdownProcessor.toNode( markdown ).getFirstChild();
216
217
    if( node != null && node.isOrDescendantOfType( Paragraph.class ) ) {
218
      node = new Text( node.getChars() );
219
    }
220
221
    // Trimming prevents displaced commas and unwanted newlines.
222
    return mMarkdownProcessor.toHtml( node ).trim();
229
    return mPostProcessor.apply( eval( r ) );
223230
  }
224231
...
233240
      return ENGINE.eval( r ).toString();
234241
    } catch( final Exception ex ) {
235
      final var expr = r.substring( 0, min( r.length(), 30 ) );
236
      clue( "Main.status.error.r", expr, ex.getMessage() );
242
      final var expr = r.substring( 0, min( r.length(), 50 ) );
243
      clue( get( "Main.status.error.r", expr, ex.getMessage() ), ex );
237244
      return "";
238245
    }
M src/main/java/com/keenwrite/processors/PreformattedProcessor.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.processors;
33
M src/main/java/com/keenwrite/processors/Processor.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.processors;
33
M src/main/java/com/keenwrite/processors/ProcessorContext.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.processors;
33
44
import com.keenwrite.Constants;
55
import com.keenwrite.ExportFormat;
66
import com.keenwrite.io.FileType;
77
import com.keenwrite.preferences.Workspace;
88
import com.keenwrite.preview.HtmlPreview;
9
import com.keenwrite.processors.markdown.Caret;
9
import com.keenwrite.Caret;
1010
1111
import java.nio.file.Path;
...
4040
   */
4141
  public ProcessorContext(
42
      final HtmlPreview htmlPreview,
43
      final Map<String, String> resolvedMap,
44
      final Path path,
45
      final Caret caret,
46
      final ExportFormat exportFormat,
47
      final Workspace workspace ) {
42
    final HtmlPreview htmlPreview,
43
    final Map<String, String> resolvedMap,
44
    final Path path,
45
    final Caret caret,
46
    final ExportFormat exportFormat,
47
    final Workspace workspace ) {
4848
    assert htmlPreview != null;
4949
    assert resolvedMap != null;
...
6161
  }
6262
63
  @SuppressWarnings("SameParameterValue")
63
  @SuppressWarnings( "SameParameterValue" )
6464
  boolean isExportFormat( final ExportFormat format ) {
6565
    return mExportFormat == format;
...
105105
   * default user directory if the base path cannot be determined.
106106
   */
107
  public Path getBasePath() {
107
  public Path getBaseDir() {
108108
    final var path = getPath().toAbsolutePath().getParent();
109109
    return path == null ? DEFAULT_DIRECTORY : path;
M src/main/java/com/keenwrite/processors/ProcessorFactory.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.processors;
33
...
4444
4545
    final var processor = switch( context.getFileType() ) {
46
      case RMARKDOWN -> createRProcessor( successor );
47
      case SOURCE -> createMarkdownProcessor( successor );
46
      //case RMARKDOWN -> createRProcessor( successor );
47
      case SOURCE, RMARKDOWN -> createMarkdownProcessor( successor );
4848
      case RXML -> createRXMLProcessor( successor );
4949
      case XML -> createXMLProcessor( successor );
...
7373
   */
7474
  private Processor<String> createIdentityProcessor() {
75
    return IdentityProcessor.INSTANCE;
75
    return IdentityProcessor.IDENTITY;
7676
  }
7777
M src/main/java/com/keenwrite/processors/RVariableProcessor.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.processors;
33
...
3434
  @Override
3535
  protected Map<String, String> getDefinitions() {
36
    return toR( super.getDefinitions() );
36
    return entoken( super.getDefinitions() );
3737
  }
3838
3939
  /**
4040
   * Converts the given map from regular variables to R variables.
4141
   *
4242
   * @param map Map of variable names to values.
4343
   * @return Map of R variables.
4444
   */
45
  private Map<String, String> toR( final Map<String, String> map ) {
45
  private Map<String, String> entoken( final Map<String, String> map ) {
4646
    final var rMap = new HashMap<String, String>( map.size() );
4747
4848
    for( final var entry : map.entrySet() ) {
4949
      final var key = entry.getKey();
50
      rMap.put( mSigilOperator.entoken( key ), toRValue( map.get( key ) ) );
50
      rMap.put( mSigilOperator.entoken( key ), escape( map.get( key ) ) );
5151
    }
5252
5353
    return rMap;
5454
  }
5555
56
  private String toRValue( final String value ) {
56
  private String escape( final String value ) {
5757
    return '\'' + escape( value, '\'', "\\'" ) + '\'';
5858
  }
M src/main/java/com/keenwrite/processors/XmlProcessor.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.processors;
33
...
134134
   * @param xsl The stylesheet to use for transforming XML documents.
135135
   * @return The edited XML document transformed into another format (usually
136
   * markdown).
136
   * Markdown).
137137
   * @throws TransformerConfigurationException Could not create the transformer.
138138
   */
D src/main/java/com/keenwrite/processors/markdown/Caret.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.processors.markdown;
3
4
import com.keenwrite.util.GenericBuilder;
5
import javafx.beans.value.ObservableValue;
6
import org.fxmisc.richtext.StyleClassedTextArea;
7
import org.fxmisc.richtext.model.Paragraph;
8
import org.reactfx.collection.LiveList;
9
10
import java.util.Collection;
11
12
import static com.keenwrite.Constants.STATUS_BAR_LINE;
13
import static com.keenwrite.Messages.get;
14
15
/**
16
 * Represents the absolute, relative, and maximum position of the caret. The
17
 * caret position is a character offset into the text.
18
 */
19
public class Caret {
20
21
  public static GenericBuilder<Caret.Mutator, Caret> builder() {
22
    return GenericBuilder.of( Caret.Mutator::new, Caret::new );
23
  }
24
25
  /**
26
   * Used for building a new {@link Caret} instance.
27
   */
28
  public static class Mutator {
29
    /**
30
     * Caret's current paragraph index (i.e., current caret line number).
31
     */
32
    private ObservableValue<Integer> mParagraph;
33
34
    /**
35
     * Used to count the number of lines in the text editor document.
36
     */
37
    private LiveList<Paragraph<Collection<String>, String,
38
        Collection<String>>> mParagraphs;
39
40
    /**
41
     * Caret offset into the full text, represented as a string index.
42
     */
43
    private ObservableValue<Integer> mTextOffset;
44
45
    /**
46
     * Caret offset into the current paragraph, represented as a string index.
47
     */
48
    private ObservableValue<Integer> mParaOffset;
49
50
    /**
51
     * Total number of characters in the document.
52
     */
53
    private ObservableValue<Integer> mTextLength;
54
55
    /**
56
     * Configures this caret position using properties from the given editor.
57
     *
58
     * @param editor The text editor that has a caret with position properties.
59
     */
60
    public void setEditor( final StyleClassedTextArea editor ) {
61
      mParagraph = editor.currentParagraphProperty();
62
      mParagraphs = editor.getParagraphs();
63
      mParaOffset = editor.caretColumnProperty();
64
      mTextOffset = editor.caretPositionProperty();
65
      mTextLength = editor.lengthProperty();
66
    }
67
  }
68
69
  private final Mutator mMutator;
70
71
  /**
72
   * Force using the builder pattern.
73
   */
74
  private Caret( final Mutator mutator ) {
75
    assert mutator != null;
76
77
    mMutator = mutator;
78
  }
79
80
  /**
81
   * Allows observers to be notified when the value of the caret changes.
82
   *
83
   * @return An observer for the caret's document offset.
84
   */
85
  public ObservableValue<Integer> textOffsetProperty() {
86
    return mMutator.mTextOffset;
87
  }
88
89
  /**
90
   * Answers whether the caret's offset into the text is between the given
91
   * offsets.
92
   *
93
   * @param began Starting value compared against the caret's text offset.
94
   * @param ended Ending value compared against the caret's text offset.
95
   * @return {@code true} when the caret's text offset is between the given
96
   * values, inclusively (for either value).
97
   */
98
  public boolean isBetweenText( final int began, final int ended ) {
99
    final var offset = getTextOffset();
100
    return began <= offset && offset <= ended;
101
  }
102
103
  /**
104
   * Answers whether the caret's offset into the paragraph is before the given
105
   * offset.
106
   *
107
   * @param offset Compared against the caret's paragraph offset.
108
   * @return {@code true} the caret's offset is before the given offset.
109
   */
110
  public boolean isBeforeColumn( final int offset ) {
111
    return getParaOffset() < offset;
112
  }
113
114
  /**
115
   * Answers whether the caret's offset into the text is before the given
116
   * text offset.
117
   *
118
   * @param offset Compared against the caret's text offset.
119
   * @return {@code true} the caret's offset is after the given offset.
120
   */
121
  public boolean isAfterColumn( final int offset ) {
122
    return getParaOffset() > offset;
123
  }
124
125
  /**
126
   * Answers whether the caret's offset into the text exceeds the length of
127
   * the text.
128
   *
129
   * @return {@code true} when the caret is at the end of the text boundary.
130
   */
131
  public boolean isAfterText() {
132
    return getTextOffset() >= getTextLength();
133
  }
134
135
  public boolean isAfter( final int offset ) {
136
    return offset >= getTextOffset();
137
  }
138
139
  private int getParagraph() {
140
    return mMutator.mParagraph.getValue();
141
  }
142
143
  /**
144
   * Returns the number of lines in the text editor.
145
   *
146
   * @return The size of the text editor's paragraph list plus one.
147
   */
148
  private int getParagraphCount() {
149
    return mMutator.mParagraphs.size() + 1;
150
  }
151
152
  /**
153
   * Returns the absolute position of the caret within the entire document.
154
   *
155
   * @return A zero-based index of the caret position.
156
   */
157
  private int getTextOffset() {
158
    return mMutator.mTextOffset.getValue();
159
  }
160
161
  /**
162
   * Returns the position of the caret within the current paragraph being
163
   * edited.
164
   *
165
   * @return A zero-based index of the caret position relative to the
166
   * current paragraph.
167
   */
168
  private int getParaOffset() {
169
    return mMutator.mParaOffset.getValue();
170
  }
171
172
  /**
173
   * Returns the total number of characters in the document being edited.
174
   *
175
   * @return A zero-based count of the total characters in the document.
176
   */
177
  private int getTextLength() {
178
    return mMutator.mTextLength.getValue();
179
  }
180
181
  /**
182
   * Returns a human-readable string that shows the current caret position
183
   * within the text. Typically this will include the current line number,
184
   * the number of lines, and the character offset into the text.
185
   *
186
   * @return A string to present to an end user.
187
   */
188
  @Override
189
  public String toString() {
190
    return get( STATUS_BAR_LINE,
191
                getParagraph() + 1,
192
                getParagraphCount(),
193
                getTextOffset() + 1 );
194
  }
195
}
1961
D src/main/java/com/keenwrite/processors/markdown/CaretExtension.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.processors.markdown;
3
4
import com.keenwrite.Constants;
5
import com.vladsch.flexmark.html.AttributeProvider;
6
import com.vladsch.flexmark.html.AttributeProviderFactory;
7
import com.vladsch.flexmark.html.IndependentAttributeProviderFactory;
8
import com.vladsch.flexmark.html.renderer.AttributablePart;
9
import com.vladsch.flexmark.html.renderer.LinkResolverContext;
10
import com.vladsch.flexmark.util.ast.Node;
11
import com.vladsch.flexmark.util.data.MutableDataHolder;
12
import com.vladsch.flexmark.util.html.AttributeImpl;
13
import com.vladsch.flexmark.util.html.MutableAttributes;
14
import org.jetbrains.annotations.NotNull;
15
16
import static com.keenwrite.Constants.CARET_ID;
17
import static com.vladsch.flexmark.html.HtmlRenderer.Builder;
18
import static com.vladsch.flexmark.html.HtmlRenderer.HtmlRendererExtension;
19
20
/**
21
 * Responsible for giving most block-level elements a unique identifier
22
 * attribute. The identifier is used to coordinate scrolling.
23
 */
24
public class CaretExtension implements HtmlRendererExtension {
25
26
  private final Caret mCaret;
27
28
  private CaretExtension( final Caret caret ) {
29
    mCaret = caret;
30
  }
31
32
  public static CaretExtension create( final Caret caret ) {
33
    return new CaretExtension( caret );
34
  }
35
36
  @Override
37
  public void extend(
38
      final Builder builder, @NotNull final String rendererType ) {
39
    builder.attributeProviderFactory(
40
        IdAttributeProvider.createFactory( mCaret ) );
41
  }
42
43
  @Override
44
  public void rendererOptions( @NotNull final MutableDataHolder options ) {
45
  }
46
47
  /**
48
   * Responsible for creating the id attribute. This class is instantiated
49
   * once: for the HTML element containing the {@link Constants#CARET_ID}.
50
   */
51
  public static class IdAttributeProvider implements AttributeProvider {
52
    private final Caret mCaret;
53
54
    public IdAttributeProvider( final Caret caret ) {
55
      mCaret = caret;
56
    }
57
58
    private static AttributeProviderFactory createFactory(
59
      final Caret caret ) {
60
      return new IndependentAttributeProviderFactory() {
61
        @Override
62
        public @NotNull AttributeProvider apply(
63
          @NotNull final LinkResolverContext context ) {
64
          return new IdAttributeProvider( caret );
65
        }
66
      };
67
    }
68
69
    @Override
70
    public void setAttributes( @NotNull Node curr,
71
                               @NotNull AttributablePart part,
72
                               @NotNull MutableAttributes attributes ) {
73
      final var outside = mCaret.isAfterText() ? 1 : 0;
74
      final var began = curr.getStartOffset();
75
      final var ended = curr.getEndOffset() + outside;
76
      final var prev = curr.getPrevious();
77
78
      // If the caret is within the bounds of the current node or the
79
      // caret is within the bounds of the end of the previous node and
80
      // the start of the current node, then mark the current node with
81
      // a caret indicator.
82
      if( mCaret.isBetweenText( began, ended ) ||
83
        prev != null && mCaret.isBetweenText( prev.getEndOffset(), began ) ) {
84
        // This line empowers synchronizing the text editor with the preview.
85
        attributes.addValue( AttributeImpl.of( "id", CARET_ID ) );
86
      }
87
    }
88
  }
89
}
901
D src/main/java/com/keenwrite/processors/markdown/FencedBlockExtension.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.processors.markdown;
3
4
import com.keenwrite.processors.DefinitionProcessor;
5
import com.keenwrite.processors.IdentityProcessor;
6
import com.keenwrite.processors.ProcessorContext;
7
import com.vladsch.flexmark.ast.FencedCodeBlock;
8
import com.vladsch.flexmark.html.renderer.DelegatingNodeRendererFactory;
9
import com.vladsch.flexmark.html.renderer.NodeRenderer;
10
import com.vladsch.flexmark.html.renderer.NodeRenderingHandler;
11
import com.vladsch.flexmark.util.data.DataHolder;
12
import com.vladsch.flexmark.util.data.MutableDataHolder;
13
import com.vladsch.flexmark.util.sequence.BasedSequence;
14
import org.jetbrains.annotations.NotNull;
15
16
import java.io.ByteArrayOutputStream;
17
import java.util.HashSet;
18
import java.util.Set;
19
import java.util.zip.Deflater;
20
21
import static com.keenwrite.StatusBarNotifier.clue;
22
import static com.vladsch.flexmark.html.HtmlRenderer.Builder;
23
import static com.vladsch.flexmark.html.HtmlRenderer.HtmlRendererExtension;
24
import static com.vladsch.flexmark.html.renderer.LinkType.LINK;
25
import static java.lang.String.format;
26
import static java.util.Base64.getUrlEncoder;
27
import static java.util.zip.Deflater.BEST_COMPRESSION;
28
import static java.util.zip.Deflater.FULL_FLUSH;
29
30
/**
31
 * Responsible for converting textual diagram descriptions into HTML image
32
 * elements.
33
 */
34
public class FencedBlockExtension implements HtmlRendererExtension {
35
  private final static String DIAGRAM_STYLE = "diagram-";
36
  private final static int DIAGRAM_STYLE_LEN = DIAGRAM_STYLE.length();
37
38
  private final DefinitionProcessor mProcessor;
39
40
  public FencedBlockExtension( final ProcessorContext context ) {
41
    assert context != null;
42
    mProcessor = new DefinitionProcessor( IdentityProcessor.INSTANCE, context );
43
  }
44
45
  /**
46
   * Creates a new parser for fenced blocks. This calls out to a web service
47
   * to generate SVG files of text diagrams.
48
   * <p>
49
   * Internally, this creates a {@link DefinitionProcessor} to substitute
50
   * variable definitions. This is necessary because the order of processors
51
   * matters. If the {@link DefinitionProcessor} comes before an instance of
52
   * {@link MarkdownProcessor}, for example, then the caret position in the
53
   * preview pane will not align with the caret position in the editor
54
   * pane. The {@link MarkdownProcessor} must come before all else. However,
55
   * when parsing fenced blocks, the variables within the block must be
56
   * interpolated before being sent to the diagram web service.
57
   * </p>
58
   *
59
   * @param context Used to create a new {@link DefinitionProcessor}.
60
   * @return A new {@link FencedBlockExtension} capable of shunting ASCII
61
   * diagrams to a service for conversion to SVG.
62
   */
63
  public static FencedBlockExtension create( final ProcessorContext context ) {
64
    return new FencedBlockExtension( context );
65
  }
66
67
  @Override
68
  public void rendererOptions( @NotNull final MutableDataHolder options ) {
69
  }
70
71
  @Override
72
  public void extend(
73
    @NotNull final Builder builder, @NotNull final String rendererType ) {
74
    builder.nodeRendererFactory( new Factory() );
75
  }
76
77
  /**
78
   * Converts the given {@link BasedSequence} to a lowercase value.
79
   *
80
   * @param text The character string to convert to lowercase.
81
   * @return The lowercase text value, or the empty string for no text.
82
   */
83
  private static String sanitize( final BasedSequence text ) {
84
    assert text != null;
85
    return text.toString().toLowerCase();
86
  }
87
88
  private class CustomRenderer implements NodeRenderer {
89
90
    @Override
91
    public Set<NodeRenderingHandler<?>> getNodeRenderingHandlers() {
92
      final var set = new HashSet<NodeRenderingHandler<?>>();
93
94
      set.add( new NodeRenderingHandler<>(
95
        FencedCodeBlock.class, ( node, context, html ) -> {
96
        final var style = sanitize( node.getInfo() );
97
98
        if( style.startsWith( DIAGRAM_STYLE ) ) {
99
          final var type = style.substring( DIAGRAM_STYLE_LEN );
100
          final var content = node.getContentChars().normalizeEOL();
101
          final var text = FencedBlockExtension.this.mProcessor.apply( content );
102
          final var encoded = encode( text );
103
          final var source = format(
104
            "https://kroki.io/%s/svg/%s", type, encoded );
105
106
          final var link = context.resolveLink( LINK, source, false );
107
108
          html.attr( "src", source );
109
          html.withAttr( link );
110
          html.tagVoid( "img" );
111
        }
112
        else {
113
          context.delegateRender();
114
        }
115
      } ) );
116
117
      return set;
118
    }
119
120
    private byte[] compress( byte[] source ) {
121
      final var inLen = source.length;
122
      final var result = new byte[ inLen ];
123
      final var deflater = new Deflater( BEST_COMPRESSION );
124
125
      deflater.setInput( source, 0, inLen );
126
      deflater.finish();
127
      final var outLen = deflater.deflate( result, 0, inLen, FULL_FLUSH );
128
      deflater.end();
129
130
      try( final var out = new ByteArrayOutputStream() ) {
131
        out.write( result, 0, outLen );
132
        return out.toByteArray();
133
      } catch( final Exception ex ) {
134
        clue( ex );
135
        throw new RuntimeException( ex );
136
      }
137
    }
138
139
    private String encode( final String decoded ) {
140
      return getUrlEncoder().encodeToString( compress( decoded.getBytes() ) );
141
    }
142
  }
143
144
  private class Factory implements DelegatingNodeRendererFactory {
145
    public Factory() {}
146
147
    @NotNull
148
    @Override
149
    public NodeRenderer apply( @NotNull final DataHolder options ) {
150
      return new CustomRenderer();
151
    }
152
153
    /**
154
     * Return {@code null} to indicate this may delegate to the core renderer.
155
     */
156
    @Override
157
    public Set<Class<?>> getDelegates() {
158
      return null;
159
    }
160
  }
161
}
1621
D src/main/java/com/keenwrite/processors/markdown/ImageLinkExtension.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.processors.markdown;
3
4
import com.keenwrite.exceptions.MissingFileException;
5
import com.keenwrite.preferences.Workspace;
6
import com.vladsch.flexmark.ast.Image;
7
import com.vladsch.flexmark.html.IndependentLinkResolverFactory;
8
import com.vladsch.flexmark.html.LinkResolver;
9
import com.vladsch.flexmark.html.renderer.LinkResolverBasicContext;
10
import com.vladsch.flexmark.html.renderer.ResolvedLink;
11
import com.vladsch.flexmark.util.ast.Node;
12
import com.vladsch.flexmark.util.data.MutableDataHolder;
13
import org.jetbrains.annotations.NotNull;
14
import org.renjin.repackaged.guava.base.Splitter;
15
16
import java.io.File;
17
import java.nio.file.Path;
18
import java.nio.file.Paths;
19
20
import static com.keenwrite.StatusBarNotifier.clue;
21
import static com.keenwrite.preferences.Workspace.KEY_IMAGES_DIR;
22
import static com.keenwrite.preferences.Workspace.KEY_IMAGES_ORDER;
23
import static com.keenwrite.util.ProtocolScheme.getProtocol;
24
import static com.vladsch.flexmark.html.HtmlRenderer.Builder;
25
import static com.vladsch.flexmark.html.HtmlRenderer.HtmlRendererExtension;
26
import static com.vladsch.flexmark.html.renderer.LinkStatus.VALID;
27
import static java.lang.String.format;
28
29
/**
30
 * Responsible for ensuring that images can be rendered relative to a path.
31
 * This allows images to be located virtually anywhere.
32
 */
33
public class ImageLinkExtension implements HtmlRendererExtension {
34
35
  /**
36
   * Creates an extension capable of using a relative path to embed images.
37
   *
38
   * @param basePath  The directory to search for images, either directly or
39
   *                  through the images directory setting, not {@code null}.
40
   * @param workspace Contains user preferences for image directory and image
41
   *                  file name extension lookup order.
42
   * @return The new {@link ImageLinkExtension}, not {@code null}.
43
   */
44
  public static ImageLinkExtension create(
45
    @NotNull final Path basePath,
46
    @NotNull final Workspace workspace ) {
47
    return new ImageLinkExtension( basePath, workspace );
48
  }
49
50
  private final Path mBasePath;
51
  private final Workspace mWorkspace;
52
53
  private ImageLinkExtension(
54
    @NotNull final Path basePath, @NotNull final Workspace workspace ) {
55
    mBasePath = basePath;
56
    mWorkspace = workspace;
57
  }
58
59
  @Override
60
  public void rendererOptions( @NotNull final MutableDataHolder options ) {
61
  }
62
63
  @Override
64
  public void extend(
65
    @NotNull final Builder builder, @NotNull final String rendererType ) {
66
    builder.linkResolverFactory( new Factory() );
67
  }
68
69
  private class Factory extends IndependentLinkResolverFactory {
70
    @Override
71
    public @NotNull LinkResolver apply(
72
      @NotNull final LinkResolverBasicContext context ) {
73
      return new ImageLinkResolver();
74
    }
75
  }
76
77
  private class ImageLinkResolver implements LinkResolver {
78
    public ImageLinkResolver() {
79
    }
80
81
    @NotNull
82
    @Override
83
    public ResolvedLink resolveLink(
84
      @NotNull final Node node,
85
      @NotNull final LinkResolverBasicContext context,
86
      @NotNull final ResolvedLink link ) {
87
      return node instanceof Image ? resolve( link ) : link;
88
    }
89
90
    private ResolvedLink resolve( final ResolvedLink link ) {
91
      var uri = link.getUrl();
92
      final var protocol = getProtocol( uri );
93
94
      if( protocol.isHttp() ) {
95
        return valid( link, uri );
96
      }
97
98
      // Determine the fully-qualified file name (fqfn).
99
      final var fqfn = Paths.get( getBasePath().toString(), uri ).toFile();
100
101
      if( fqfn.isFile() ) {
102
        return valid( link, uri );
103
      }
104
105
      // At this point either the image directory is qualified or needs to be
106
      // qualified using the image prefix, as set in the user preferences.
107
      try {
108
        final var imagePrefix = getImagePrefix();
109
        final var basePath = getBasePath().resolve( imagePrefix );
110
111
        final var imagePathPrefix = Path.of( basePath.toString(), uri );
112
        final var suffixes = getImageExtensions();
113
        boolean missing = true;
114
115
        // Iterate over the user's preferred image file type extensions.
116
        for( final var ext : Splitter.on( ' ' ).split( suffixes ) ) {
117
          final var imagePath = format( "%s.%s", imagePathPrefix, ext );
118
          final var file = new File( imagePath );
119
120
          if( file.exists() ) {
121
            uri += '.' + ext;
122
            final var path = Path.of( imagePrefix.toString(), uri );
123
            uri = path.normalize().toString();
124
            missing = false;
125
            break;
126
          }
127
        }
128
129
        if( missing ) {
130
          throw new MissingFileException( imagePathPrefix + ".*" );
131
        }
132
133
        return valid( link, uri );
134
      } catch( final Exception ex ) {
135
        clue( ex );
136
      }
137
138
      return link;
139
    }
140
141
    private ResolvedLink valid( final ResolvedLink link, final String url ) {
142
      return link.withStatus( VALID ).withUrl( url );
143
    }
144
145
    private Path getImagePrefix() {
146
      return mWorkspace.toFile( KEY_IMAGES_DIR ).toPath();
147
    }
148
149
    private String getImageExtensions() {
150
      return mWorkspace.toString( KEY_IMAGES_ORDER );
151
    }
152
153
    private Path getBasePath() {
154
      return mBasePath;
155
    }
156
  }
157
}
1581
M src/main/java/com/keenwrite/processors/markdown/MarkdownProcessor.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.processors.markdown;
33
4
import com.keenwrite.ExportFormat;
54
import com.keenwrite.io.MediaType;
6
import com.keenwrite.preferences.Workspace;
7
import com.keenwrite.processors.*;
8
import com.keenwrite.processors.markdown.r.RExtension;
5
import com.keenwrite.processors.ExecutorProcessor;
6
import com.keenwrite.processors.Processor;
7
import com.keenwrite.processors.ProcessorContext;
8
import com.keenwrite.processors.markdown.extensions.FencedBlockExtension;
9
import com.keenwrite.processors.markdown.extensions.ImageLinkExtension;
10
import com.keenwrite.processors.markdown.extensions.caret.CaretExtension;
11
import com.keenwrite.processors.markdown.extensions.r.RExtension;
12
import com.keenwrite.processors.markdown.extensions.tex.TeXExtension;
913
import com.vladsch.flexmark.ext.definition.DefinitionExtension;
1014
import com.vladsch.flexmark.ext.gfm.strikethrough.StrikethroughSubscriptExtension;
...
1923
import com.vladsch.flexmark.util.misc.Extension;
2024
21
import java.nio.file.Path;
22
import java.util.Collection;
23
import java.util.HashSet;
25
import java.util.ArrayList;
26
import java.util.List;
2427
25
import static com.keenwrite.Constants.DEFAULT_DIRECTORY;
26
import static com.keenwrite.ExportFormat.NONE;
2728
import static com.keenwrite.io.MediaType.TEXT_R_MARKDOWN;
2829
import static com.keenwrite.io.MediaType.TEXT_R_XML;
30
import static com.keenwrite.processors.IdentityProcessor.IDENTITY;
2931
3032
/**
3133
 * Responsible for parsing a Markdown document and rendering it as HTML.
3234
 */
3335
public class MarkdownProcessor extends ExecutorProcessor<String> {
36
37
  private static final List<Extension> DEFAULT_EXTENSIONS =
38
    createDefaultExtensions();
3439
3540
  private final IParse mParser;
3641
  private final IRender mRenderer;
3742
3843
  private MarkdownProcessor(
3944
    final Processor<String> successor,
40
    final Collection<Extension> extensions ) {
45
    final List<Extension> extensions ) {
4146
    super( successor );
4247
4348
    mParser = Parser.builder().extensions( extensions ).build();
4449
    mRenderer = HtmlRenderer.builder().extensions( extensions ).build();
45
  }
46
47
  public static MarkdownProcessor create( final Workspace workspace ) {
48
    return create( IdentityProcessor.INSTANCE, workspace, DEFAULT_DIRECTORY );
4950
  }
5051
5152
  public static MarkdownProcessor create( final ProcessorContext context ) {
52
    return create( IdentityProcessor.INSTANCE, context );
53
  }
54
55
  public static MarkdownProcessor create(
56
    final Processor<String> successor,
57
    final Workspace workspace,
58
    final Path dir ) {
59
    final var extensions = createExtensions( NONE, workspace, dir );
60
    return new MarkdownProcessor( successor, extensions );
53
    return create( IDENTITY, context );
6154
  }
6255
6356
  public static MarkdownProcessor create(
6457
    final Processor<String> successor, final ProcessorContext context ) {
6558
    final var extensions = createExtensions( context );
6659
    return new MarkdownProcessor( successor, extensions );
60
  }
61
62
  private static List<Extension> createEmptyExtensions() {
63
    return new ArrayList<>();
64
  }
65
66
  /**
67
   * Instantiates a number of extensions to be applied when parsing. These
68
   * are typically typographic extensions that convert characters into
69
   * HTML entities.
70
   *
71
   * @return A {@link List} of {@link Extension} instances that
72
   * change the {@link Parser}'s behaviour.
73
   */
74
  private static List<Extension> createDefaultExtensions() {
75
    final List<Extension> extensions = new ArrayList<>();
76
    extensions.add( DefinitionExtension.create() );
77
    extensions.add( StrikethroughSubscriptExtension.create() );
78
    extensions.add( SuperscriptExtension.create() );
79
    extensions.add( TablesExtension.create() );
80
    extensions.add( TypographicExtension.create() );
81
    return extensions;
6782
  }
6883
...
7893
   * @param context Contains necessary information needed to create extensions
7994
   *                used by the Markdown parser.
80
   * @return {@link Collection} of extensions invoked when parsing Markdown.
95
   * @return {@link List} of extensions invoked when parsing Markdown.
8196
   */
82
  private static Collection<Extension> createExtensions(
97
  private static List<Extension> createExtensions(
8398
    final ProcessorContext context ) {
84
    final var path  = context.getPath();
85
    final var dir = context.getBasePath();
86
    final var format = context.getExportFormat();
87
    final var workspace = context.getWorkspace();
88
    final var extensions = createExtensions( format, workspace, dir );
99
    final var extensions = createEmptyExtensions();
100
    final var editorFile = context.getPath();
89101
90
    final var mediaType = MediaType.valueFrom( path );
102
    final var mediaType = MediaType.valueFrom( editorFile );
91103
    if( mediaType == TEXT_R_MARKDOWN || mediaType == TEXT_R_XML ) {
92
      extensions.add( RExtension.create() );
104
      extensions.add( RExtension.create( context ) );
93105
    }
94106
107
    extensions.addAll( DEFAULT_EXTENSIONS );
108
    extensions.add( ImageLinkExtension.create( context ) );
109
    extensions.add( TeXExtension.create( context ) );
95110
    extensions.add( FencedBlockExtension.create( context ) );
96
    extensions.add( CaretExtension.create( context.getCaret() ) );
97
98
    return extensions;
99
  }
100
101
  /**
102
   * Creates parser extensions that tweak the parsing engine based on various
103
   * conditions. For example, this will add a new {@link TeXExtension} that
104
   * can export TeX as either SVG or TeX macros. The tweak also includes the
105
   * ability to keep inline R statements, rather than convert them to inline
106
   * code elements, so that the {@link InlineRProcessor} can interpret the
107
   * R statements.
108
   *
109
   * @param dir    Directory for referencing image files via relative paths
110
   *               and dynamic file types.
111
   * @param format TeX export format to use when generating HTMl documents.
112
   * @return {@link Collection} of extensions invoked when parsing Markdown.
113
   */
114
  private static Collection<Extension> createExtensions(
115
    final ExportFormat format, final Workspace workspace, final Path dir ) {
116
    final var extensions = createDefaultExtensions();
117
118
    extensions.add( ImageLinkExtension.create( dir, workspace ) );
119
    extensions.add( TeXExtension.create( format ) );
120
121
    return extensions;
122
  }
111
    extensions.add( CaretExtension.create( context ) );
123112
124
  /**
125
   * Instantiates a number of extensions to be applied when parsing. These
126
   * are typically typographic extensions that convert characters into
127
   * HTML entities.
128
   *
129
   * @return A {@link Collection} of {@link Extension} instances that
130
   * change the {@link Parser}'s behaviour.
131
   */
132
  private static Collection<Extension> createDefaultExtensions() {
133
    final var extensions = new HashSet<Extension>();
134
    extensions.add( DefinitionExtension.create() );
135
    extensions.add( StrikethroughSubscriptExtension.create() );
136
    extensions.add( SuperscriptExtension.create() );
137
    extensions.add( TablesExtension.create() );
138
    extensions.add( TypographicExtension.create() );
139113
    return extensions;
140114
  }
...
149123
  @Override
150124
  public String apply( final String markdown ) {
151
    return toHtml( markdown );
125
    return toHtml( parse( markdown ) );
152126
  }
153127
154128
  /**
155
   * Returns the AST in the form of a node for the given markdown document. This
129
   * Returns the AST in the form of a node for the given Markdown document. This
156130
   * can be used, for example, to determine if a hyperlink exists inside of a
157131
   * paragraph.
158132
   *
159
   * @param markdown The markdown to convert into an AST.
160
   * @return The markdown AST for the given text (usually a paragraph).
133
   * @param markdown The Markdown to convert into an AST.
134
   * @return The Markdown AST for the given text (usually a paragraph).
161135
   */
162136
  public Node toNode( final String markdown ) {
...
175149
176150
  /**
177
   * Helper method to create an AST given some markdown.
151
   * Helper method to create an AST given some Markdown.
178152
   *
179
   * @param markdown The markdown to parse.
180
   * @return The root node of the markdown tree.
153
   * @param markdown The Markdown to parse.
154
   * @return The root node of the Markdown tree.
181155
   */
182156
  private Node parse( final String markdown ) {
183157
    return getParser().parse( markdown );
184
  }
185
186
  /**
187
   * Converts a string of markdown into HTML.
188
   *
189
   * @param markdown The markdown text to convert to HTML, must not be null.
190
   * @return The markdown rendered as an HTML document.
191
   */
192
  private String toHtml( final String markdown ) {
193
    return toHtml( parse( markdown ) );
194158
  }
195159
D src/main/java/com/keenwrite/processors/markdown/TeXExtension.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.processors.markdown;
3
4
import com.keenwrite.ExportFormat;
5
import com.keenwrite.processors.markdown.tex.TeXInlineDelimiterProcessor;
6
import com.keenwrite.processors.markdown.tex.TexNodeRenderer.Factory;
7
import com.vladsch.flexmark.html.HtmlRenderer;
8
import com.vladsch.flexmark.parser.Parser;
9
import com.vladsch.flexmark.util.data.MutableDataHolder;
10
import com.vladsch.flexmark.util.misc.Extension;
11
import org.jetbrains.annotations.NotNull;
12
13
import static com.vladsch.flexmark.html.HtmlRenderer.HtmlRendererExtension;
14
import static com.vladsch.flexmark.parser.Parser.ParserExtension;
15
16
/**
17
 * Responsible for wrapping delimited TeX code in Markdown into an XML element
18
 * that the HTML renderer can handle. For example, {@code $E=mc^2$} becomes
19
 * {@code <tex>E=mc^2</tex>} when passed to HTML renderer. The HTML renderer
20
 * is responsible for converting the TeX code for display. This avoids inserting
21
 * SVG code into the Markdown document, which the parser would then have to
22
 * iterate---a <em>very</em> wasteful operation that impacts front-end
23
 * performance.
24
 */
25
public class TeXExtension implements ParserExtension, HtmlRendererExtension {
26
  /**
27
   * Controls how the node renderer produces TeX code within HTML output.
28
   */
29
  private final ExportFormat mExportFormat;
30
31
  /**
32
   * Creates an extension capable of handling delimited TeX code in Markdown.
33
   *
34
   * @return The new {@link TeXExtension}, never {@code null}.
35
   */
36
  public static TeXExtension create( final ExportFormat format ) {
37
    return new TeXExtension( format );
38
  }
39
40
  /**
41
   * Force using the {@link #create(ExportFormat)} method for consistency with
42
   * the other {@link Extension} creation invocations.
43
   */
44
  private TeXExtension( final ExportFormat exportFormat ) {
45
    mExportFormat = exportFormat;
46
  }
47
48
  /**
49
   * Adds the TeX extension for HTML document export types.
50
   *
51
   * @param builder      The document builder.
52
   * @param rendererType Indicates the document type to be built.
53
   */
54
  @Override
55
  public void extend( @NotNull final HtmlRenderer.Builder builder,
56
                      @NotNull final String rendererType ) {
57
    if( "HTML".equalsIgnoreCase( rendererType ) ) {
58
      builder.nodeRendererFactory( new Factory( mExportFormat ) );
59
    }
60
  }
61
62
  @Override
63
  public void extend( final Parser.Builder builder ) {
64
    builder.customDelimiterProcessor( new TeXInlineDelimiterProcessor() );
65
  }
66
67
  @Override
68
  public void rendererOptions( @NotNull final MutableDataHolder options ) {
69
  }
70
71
  @Override
72
  public void parserOptions( final MutableDataHolder options ) {
73
  }
74
}
751
A src/main/java/com/keenwrite/processors/markdown/extensions/FencedBlockExtension.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.processors.markdown.extensions;
3
4
import com.keenwrite.processors.DefinitionProcessor;
5
import com.keenwrite.processors.ProcessorContext;
6
import com.keenwrite.processors.markdown.MarkdownProcessor;
7
import com.vladsch.flexmark.ast.FencedCodeBlock;
8
import com.vladsch.flexmark.html.renderer.DelegatingNodeRendererFactory;
9
import com.vladsch.flexmark.html.renderer.NodeRenderer;
10
import com.vladsch.flexmark.html.renderer.NodeRenderingHandler;
11
import com.vladsch.flexmark.util.data.DataHolder;
12
import com.vladsch.flexmark.util.sequence.BasedSequence;
13
import org.jetbrains.annotations.NotNull;
14
15
import java.io.ByteArrayOutputStream;
16
import java.util.HashSet;
17
import java.util.Set;
18
import java.util.zip.Deflater;
19
20
import static com.keenwrite.StatusNotifier.clue;
21
import static com.keenwrite.processors.IdentityProcessor.IDENTITY;
22
import static com.vladsch.flexmark.html.HtmlRenderer.Builder;
23
import static com.vladsch.flexmark.html.renderer.LinkType.LINK;
24
import static java.lang.String.format;
25
import static java.util.Base64.getUrlEncoder;
26
import static java.util.zip.Deflater.BEST_COMPRESSION;
27
import static java.util.zip.Deflater.FULL_FLUSH;
28
29
/**
30
 * Responsible for converting textual diagram descriptions into HTML image
31
 * elements.
32
 */
33
public class FencedBlockExtension extends HtmlRendererAdapter {
34
  private final static String DIAGRAM_STYLE = "diagram-";
35
  private final static int DIAGRAM_STYLE_LEN = DIAGRAM_STYLE.length();
36
37
  private final DefinitionProcessor mProcessor;
38
39
  public FencedBlockExtension( final ProcessorContext context ) {
40
    assert context != null;
41
    mProcessor = new DefinitionProcessor( IDENTITY, context );
42
  }
43
44
  /**
45
   * Creates a new parser for fenced blocks. This calls out to a web service
46
   * to generate SVG files of text diagrams.
47
   * <p>
48
   * Internally, this creates a {@link DefinitionProcessor} to substitute
49
   * variable definitions. This is necessary because the order of processors
50
   * matters. If the {@link DefinitionProcessor} comes before an instance of
51
   * {@link MarkdownProcessor}, for example, then the caret position in the
52
   * preview pane will not align with the caret position in the editor
53
   * pane. The {@link MarkdownProcessor} must come before all else. However,
54
   * when parsing fenced blocks, the variables within the block must be
55
   * interpolated before being sent to the diagram web service.
56
   * </p>
57
   *
58
   * @param context Used to create a new {@link DefinitionProcessor}.
59
   * @return A new {@link FencedBlockExtension} capable of shunting ASCII
60
   * diagrams to a service for conversion to SVG.
61
   */
62
  public static FencedBlockExtension create( final ProcessorContext context ) {
63
    return new FencedBlockExtension( context );
64
  }
65
66
  @Override
67
  public void extend(
68
    @NotNull final Builder builder, @NotNull final String rendererType ) {
69
    builder.nodeRendererFactory( new Factory() );
70
  }
71
72
  /**
73
   * Converts the given {@link BasedSequence} to a lowercase value.
74
   *
75
   * @param text The character string to convert to lowercase.
76
   * @return The lowercase text value, or the empty string for no text.
77
   */
78
  private static String sanitize( final BasedSequence text ) {
79
    assert text != null;
80
    return text.toString().toLowerCase();
81
  }
82
83
  private class CustomRenderer implements NodeRenderer {
84
85
    @Override
86
    public Set<NodeRenderingHandler<?>> getNodeRenderingHandlers() {
87
      final var set = new HashSet<NodeRenderingHandler<?>>();
88
89
      set.add( new NodeRenderingHandler<>(
90
        FencedCodeBlock.class, ( node, context, html ) -> {
91
        final var style = sanitize( node.getInfo() );
92
93
        if( style.startsWith( DIAGRAM_STYLE ) ) {
94
          final var type = style.substring( DIAGRAM_STYLE_LEN );
95
          final var content = node.getContentChars().normalizeEOL();
96
          final var text = FencedBlockExtension.this.mProcessor.apply( content );
97
          final var encoded = encode( text );
98
          final var source = format(
99
            "https://kroki.io/%s/svg/%s", type, encoded );
100
101
          final var link = context.resolveLink( LINK, source, false );
102
103
          html.attr( "src", source );
104
          html.withAttr( link );
105
          html.tagVoid( "img" );
106
        }
107
        else {
108
          context.delegateRender();
109
        }
110
      } ) );
111
112
      return set;
113
    }
114
115
    private byte[] compress( byte[] source ) {
116
      final var inLen = source.length;
117
      final var result = new byte[ inLen ];
118
      final var compressor = new Deflater( BEST_COMPRESSION );
119
120
      compressor.setInput( source, 0, inLen );
121
      compressor.finish();
122
      final var outLen = compressor.deflate( result, 0, inLen, FULL_FLUSH );
123
      compressor.end();
124
125
      try( final var out = new ByteArrayOutputStream() ) {
126
        out.write( result, 0, outLen );
127
        return out.toByteArray();
128
      } catch( final Exception ex ) {
129
        clue( ex );
130
        throw new RuntimeException( ex );
131
      }
132
    }
133
134
    private String encode( final String decoded ) {
135
      return getUrlEncoder().encodeToString( compress( decoded.getBytes() ) );
136
    }
137
  }
138
139
  private class Factory implements DelegatingNodeRendererFactory {
140
    public Factory() {}
141
142
    @NotNull
143
    @Override
144
    public NodeRenderer apply( @NotNull final DataHolder options ) {
145
      return new CustomRenderer();
146
    }
147
148
    /**
149
     * Return {@code null} to indicate this may delegate to the core renderer.
150
     */
151
    @Override
152
    public Set<Class<?>> getDelegates() {
153
      return null;
154
    }
155
  }
156
}
1157
A src/main/java/com/keenwrite/processors/markdown/extensions/HtmlRendererAdapter.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.processors.markdown.extensions;
3
4
import com.vladsch.flexmark.util.data.MutableDataHolder;
5
import org.jetbrains.annotations.NotNull;
6
7
import static com.vladsch.flexmark.html.HtmlRenderer.HtmlRendererExtension;
8
9
/**
10
 * Hides the {@link #rendererOptions(MutableDataHolder)} from subclasses
11
 * that would otherwise implement the {@link HtmlRendererExtension} interface.
12
 */
13
public abstract class HtmlRendererAdapter implements HtmlRendererExtension {
14
  /**
15
   * Empty, unused.
16
   *
17
   * @param options Ignored.
18
   */
19
  @Override
20
  public void rendererOptions( @NotNull final MutableDataHolder options ) {
21
  }
22
}
123
A src/main/java/com/keenwrite/processors/markdown/extensions/ImageLinkExtension.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.processors.markdown.extensions;
3
4
import com.keenwrite.exceptions.MissingFileException;
5
import com.keenwrite.preferences.Workspace;
6
import com.keenwrite.processors.ProcessorContext;
7
import com.vladsch.flexmark.ast.Image;
8
import com.vladsch.flexmark.html.IndependentLinkResolverFactory;
9
import com.vladsch.flexmark.html.LinkResolver;
10
import com.vladsch.flexmark.html.renderer.LinkResolverBasicContext;
11
import com.vladsch.flexmark.html.renderer.ResolvedLink;
12
import com.vladsch.flexmark.util.ast.Node;
13
import org.jetbrains.annotations.NotNull;
14
import org.renjin.repackaged.guava.base.Splitter;
15
16
import java.io.File;
17
import java.nio.file.Path;
18
import java.nio.file.Paths;
19
20
import static com.keenwrite.StatusNotifier.clue;
21
import static com.keenwrite.preferences.Workspace.KEY_IMAGES_DIR;
22
import static com.keenwrite.preferences.Workspace.KEY_IMAGES_ORDER;
23
import static com.keenwrite.util.ProtocolScheme.getProtocol;
24
import static com.vladsch.flexmark.html.HtmlRenderer.Builder;
25
import static com.vladsch.flexmark.html.renderer.LinkStatus.VALID;
26
import static java.lang.String.format;
27
28
/**
29
 * Responsible for ensuring that images can be rendered relative to a path.
30
 * This allows images to be located virtually anywhere.
31
 */
32
public class ImageLinkExtension extends HtmlRendererAdapter {
33
34
  private final Path mBaseDir;
35
  private final Workspace mWorkspace;
36
37
  private ImageLinkExtension( @NotNull final ProcessorContext context ) {
38
    mBaseDir = context.getBaseDir();
39
    mWorkspace = context.getWorkspace();
40
  }
41
42
  /**
43
   * Creates an extension capable of using a relative path to embed images.
44
   *
45
   * @param context Contains the base directory to search in for images.
46
   * @return The new {@link ImageLinkExtension}, not {@code null}.
47
   */
48
  public static ImageLinkExtension create(
49
    @NotNull final ProcessorContext context ) {
50
    return new ImageLinkExtension( context );
51
  }
52
53
  @Override
54
  public void extend(
55
    @NotNull final Builder builder, @NotNull final String rendererType ) {
56
    builder.linkResolverFactory( new Factory() );
57
  }
58
59
  private class Factory extends IndependentLinkResolverFactory {
60
    @Override
61
    public @NotNull LinkResolver apply(
62
      @NotNull final LinkResolverBasicContext context ) {
63
      return new ImageLinkResolver();
64
    }
65
  }
66
67
  private class ImageLinkResolver implements LinkResolver {
68
    public ImageLinkResolver() {
69
    }
70
71
    @NotNull
72
    @Override
73
    public ResolvedLink resolveLink(
74
      @NotNull final Node node,
75
      @NotNull final LinkResolverBasicContext context,
76
      @NotNull final ResolvedLink link ) {
77
      return node instanceof Image ? resolve( link ) : link;
78
    }
79
80
    private ResolvedLink resolve( final ResolvedLink link ) {
81
      var uri = link.getUrl();
82
      final var protocol = getProtocol( uri );
83
84
      if( protocol.isHttp() ) {
85
        return valid( link, uri );
86
      }
87
88
      // Determine the fully-qualified file name (fqfn).
89
      final var fqfn = Paths.get( getBaseDir().toString(), uri ).toFile();
90
91
      if( fqfn.isFile() ) {
92
        return valid( link, uri );
93
      }
94
95
      // At this point either the image directory is qualified or needs to be
96
      // qualified using the image prefix, as set in the user preferences.
97
      try {
98
        final var imagePrefix = getImagePrefix();
99
        final var baseDir = getBaseDir().resolve( imagePrefix );
100
101
        final var imagePrefixDir = Path.of( baseDir.toString(), uri );
102
        final var suffixes = getImageExtensions();
103
        boolean missing = true;
104
105
        // Iterate over the user's preferred image file type extensions.
106
        for( final var ext : Splitter.on( ' ' ).split( suffixes ) ) {
107
          final var imagePath = format( "%s.%s", imagePrefixDir, ext );
108
          final var file = new File( imagePath );
109
110
          if( file.exists() ) {
111
            uri += '.' + ext;
112
            final var path = Path.of( imagePrefix.toString(), uri );
113
            uri = path.normalize().toString();
114
            missing = false;
115
            break;
116
          }
117
        }
118
119
        if( missing ) {
120
          throw new MissingFileException( imagePrefixDir + ".*" );
121
        }
122
123
        return valid( link, uri );
124
      } catch( final Exception ex ) {
125
        clue( ex );
126
      }
127
128
      return link;
129
    }
130
131
    private ResolvedLink valid( final ResolvedLink link, final String url ) {
132
      return link.withStatus( VALID ).withUrl( url );
133
    }
134
135
    private Path getImagePrefix() {
136
      return mWorkspace.toFile( KEY_IMAGES_DIR ).toPath();
137
    }
138
139
    private String getImageExtensions() {
140
      return mWorkspace.toString( KEY_IMAGES_ORDER );
141
    }
142
143
    private Path getBaseDir() {
144
      return mBaseDir;
145
    }
146
  }
147
}
1148
A src/main/java/com/keenwrite/processors/markdown/extensions/caret/CaretExtension.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.processors.markdown.extensions.caret;
3
4
import com.keenwrite.Caret;
5
import com.keenwrite.Constants;
6
import com.keenwrite.processors.ProcessorContext;
7
import com.keenwrite.processors.markdown.extensions.HtmlRendererAdapter;
8
import com.vladsch.flexmark.html.AttributeProvider;
9
import com.vladsch.flexmark.html.AttributeProviderFactory;
10
import com.vladsch.flexmark.html.IndependentAttributeProviderFactory;
11
import com.vladsch.flexmark.html.renderer.AttributablePart;
12
import com.vladsch.flexmark.html.renderer.LinkResolverContext;
13
import com.vladsch.flexmark.util.ast.Node;
14
import com.vladsch.flexmark.util.html.AttributeImpl;
15
import com.vladsch.flexmark.util.html.MutableAttributes;
16
import org.jetbrains.annotations.NotNull;
17
18
import static com.keenwrite.Constants.CARET_ID;
19
import static com.vladsch.flexmark.html.HtmlRenderer.Builder;
20
21
/**
22
 * Responsible for giving most block-level elements a unique identifier
23
 * attribute. The identifier is used to coordinate scrolling.
24
 */
25
public class CaretExtension extends HtmlRendererAdapter {
26
27
  private final Caret mCaret;
28
29
  private CaretExtension( final ProcessorContext context ) {
30
    mCaret = context.getCaret();
31
  }
32
33
  public static CaretExtension create( final ProcessorContext context ) {
34
    return new CaretExtension( context );
35
  }
36
37
  @Override
38
  public void extend(
39
    final Builder builder, @NotNull final String rendererType ) {
40
    builder.attributeProviderFactory(
41
      IdAttributeProvider.createFactory( mCaret ) );
42
  }
43
44
  /**
45
   * Responsible for creating the id attribute. This class is instantiated
46
   * once: for the HTML element containing the {@link Constants#CARET_ID}.
47
   */
48
  public static class IdAttributeProvider implements AttributeProvider {
49
    private final Caret mCaret;
50
51
    public IdAttributeProvider( final Caret caret ) {
52
      mCaret = caret;
53
    }
54
55
    private static AttributeProviderFactory createFactory(
56
      final Caret caret ) {
57
      return new IndependentAttributeProviderFactory() {
58
        @Override
59
        public @NotNull AttributeProvider apply(
60
          @NotNull final LinkResolverContext context ) {
61
          return new IdAttributeProvider( caret );
62
        }
63
      };
64
    }
65
66
    @Override
67
    public void setAttributes( @NotNull Node curr,
68
                               @NotNull AttributablePart part,
69
                               @NotNull MutableAttributes attributes ) {
70
      final var outside = mCaret.isAfterText() ? 1 : 0;
71
      final var began = curr.getStartOffset();
72
      final var ended = curr.getEndOffset() + outside;
73
      final var prev = curr.getPrevious();
74
75
      // If the caret is within the bounds of the current node or the
76
      // caret is within the bounds of the end of the previous node and
77
      // the start of the current node, then mark the current node with
78
      // a caret indicator.
79
      if( mCaret.isBetweenText( began, ended ) ||
80
        prev != null && mCaret.isBetweenText( prev.getEndOffset(), began ) ) {
81
        // This line empowers synchronizing the text editor with the preview.
82
        attributes.addValue( AttributeImpl.of( "id", CARET_ID ) );
83
      }
84
    }
85
  }
86
}
187
A src/main/java/com/keenwrite/processors/markdown/extensions/r/RExtension.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.processors.markdown.extensions.r;
3
4
import com.keenwrite.processors.*;
5
import com.keenwrite.sigils.RSigilOperator;
6
import com.vladsch.flexmark.ast.Text;
7
import com.vladsch.flexmark.parser.InlineParserExtensionFactory;
8
import com.vladsch.flexmark.parser.InlineParserFactory;
9
import com.vladsch.flexmark.parser.delimiter.DelimiterProcessor;
10
import com.vladsch.flexmark.parser.internal.InlineParserImpl;
11
import com.vladsch.flexmark.parser.internal.LinkRefProcessorData;
12
import com.vladsch.flexmark.util.data.DataHolder;
13
import com.vladsch.flexmark.util.data.MutableDataHolder;
14
15
import java.util.BitSet;
16
import java.util.List;
17
import java.util.Map;
18
19
import static com.keenwrite.processors.IdentityProcessor.IDENTITY;
20
import static com.vladsch.flexmark.parser.Parser.Builder;
21
import static com.vladsch.flexmark.parser.Parser.ParserExtension;
22
23
/**
24
 * Responsible for processing inline R statements (denoted using the
25
 * {@link RSigilOperator#PREFIX}) to prevent them from being converted to
26
 * HTML {@code <code>} elements and stop them from interfering with TeX
27
 * statements. Note that TeX statements are processed using a Markdown
28
 * extension, rather than an implementation of {@link Processor}. For this
29
 * reason, some pre-conversion is necessary.
30
 */
31
public final class RExtension implements ParserExtension {
32
  private final InlineParserFactory FACTORY = CustomParser::new;
33
34
  private final Processor<String> mProcessor;
35
  private final InlineRProcessor mInlineRProcessor;
36
  private boolean mReady;
37
38
  private RExtension( final ProcessorContext context ) {
39
    final var irp = new InlineRProcessor( IDENTITY, context );
40
    final var rvp = new RVariableProcessor( irp, context );
41
    mProcessor = new ExecutorProcessor<>( rvp );
42
    mInlineRProcessor = irp;
43
  }
44
45
  /**
46
   * Creates an extension capable of intercepting R code blocks and preventing
47
   * them from being converted into HTML {@code <code>} elements.
48
   */
49
  public static RExtension create( final ProcessorContext context ) {
50
    return new RExtension( context );
51
  }
52
53
  @Override
54
  public void extend( final Builder builder ) {
55
    builder.customInlineParserFactory( FACTORY );
56
  }
57
58
  @Override
59
  public void parserOptions( final MutableDataHolder options ) {
60
  }
61
62
  /**
63
   * Prevents rendering {@code `r} statements as inline HTML {@code <code>}
64
   * blocks, which allows the {@link InlineRProcessor} to post-process the
65
   * text prior to display in the preview pane. This intervention assists
66
   * with decoupling the caret from the Markdown content so that the two
67
   * can vary independently in the architecture while permitting synchronization
68
   * of the editor and preview pane.
69
   * <p>
70
   * The text is therefore processed twice: once by flexmark-java and once by
71
   * {@link InlineRProcessor}.
72
   * </p>
73
   */
74
  private class CustomParser extends InlineParserImpl {
75
    private CustomParser(
76
      final DataHolder options,
77
      final BitSet specialCharacters,
78
      final BitSet delimiterCharacters,
79
      final Map<Character, DelimiterProcessor> delimiterProcessors,
80
      final LinkRefProcessorData referenceLinkProcessors,
81
      final List<InlineParserExtensionFactory> inlineParserExtensions ) {
82
      super( options,
83
             specialCharacters,
84
             delimiterCharacters,
85
             delimiterProcessors,
86
             referenceLinkProcessors,
87
             inlineParserExtensions );
88
      mReady = mInlineRProcessor.init();
89
    }
90
91
    /**
92
     * The superclass handles a number backtick parsing edge cases; this method
93
     * changes the behaviour to retain R code snippets, identified by
94
     * {@link RSigilOperator#PREFIX}, so that subsequent processing can
95
     * invoke R. If other languages are added, the {@link CustomParser} will
96
     * have to be rewritten to identify more than merely R.
97
     *
98
     * @return The return value from {@link super#parseBackticks()}.
99
     * @inheritDoc
100
     */
101
    @Override
102
    protected final boolean parseBackticks() {
103
      final var foundTicks = super.parseBackticks();
104
105
      if( foundTicks && mReady ) {
106
        final var blockNode = getBlock();
107
        final var codeNode = blockNode.getLastChild();
108
109
        if( codeNode != null ) {
110
          final var code = codeNode.getChars().toString();
111
112
          if( code.startsWith( RSigilOperator.PREFIX ) ) {
113
            codeNode.unlink();
114
            blockNode.appendChild( new Text( mProcessor.apply( code ) ) );
115
          }
116
        }
117
      }
118
119
      return foundTicks;
120
    }
121
  }
122
}
1123
A src/main/java/com/keenwrite/processors/markdown/extensions/r/ROutputProcessor.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.processors.markdown.extensions.r;
3
4
import com.keenwrite.processors.ExecutorProcessor;
5
import com.keenwrite.processors.InlineRProcessor;
6
import com.keenwrite.processors.markdown.MarkdownProcessor;
7
import com.keenwrite.processors.markdown.extensions.tex.TeXExtension;
8
import com.vladsch.flexmark.ast.Paragraph;
9
import com.vladsch.flexmark.ast.Text;
10
import com.vladsch.flexmark.html.HtmlRenderer;
11
import com.vladsch.flexmark.parser.Parser;
12
import com.vladsch.flexmark.util.ast.IParse;
13
import com.vladsch.flexmark.util.ast.IRender;
14
15
/**
16
 * Responsible for parsing the output from an R eval statement. This class
17
 * is used to avoid an circular dependency whereby the {@link InlineRProcessor}
18
 * must treat the output from an R function call as Markdown, which would
19
 * otherwise require a {@link MarkdownProcessor} instance; however, the
20
 * {@link MarkdownProcessor} class gives precedence to its extensions, which
21
 * means the {@link TeXExtension} will be executed <em>before</em> the
22
 * {@link InlineRProcessor}, thereby being exposed to backticks in a TeX
23
 * macro---a syntax error. To break the cycle, the {@link InlineRProcessor}
24
 * uses this class instead of {@link MarkdownProcessor}.
25
 */
26
public class ROutputProcessor extends ExecutorProcessor<String> {
27
  private final IParse mParser = Parser.builder().build();
28
  private final IRender mRenderer = HtmlRenderer.builder().build();
29
30
  @Override
31
  public String apply( final String markdown ) {
32
    var node = mParser.parse( markdown ).getFirstChild();
33
34
    if( node == null ) {
35
      node = new Text();
36
    }
37
    else if( node.isOrDescendantOfType( Paragraph.class ) ) {
38
      node = new Text( node.getChars() );
39
    }
40
41
    // Trimming prevents displaced commas and unwanted newlines.
42
    return mRenderer.render( node ).trim();
43
  }
44
}
145
A src/main/java/com/keenwrite/processors/markdown/extensions/tex/TeXExtension.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.processors.markdown.extensions.tex;
3
4
import com.keenwrite.ExportFormat;
5
import com.keenwrite.processors.ProcessorContext;
6
import com.keenwrite.processors.markdown.extensions.HtmlRendererAdapter;
7
import com.keenwrite.processors.markdown.extensions.tex.TexNodeRenderer.Factory;
8
import com.vladsch.flexmark.html.HtmlRenderer;
9
import com.vladsch.flexmark.parser.Parser;
10
import com.vladsch.flexmark.util.data.MutableDataHolder;
11
import org.jetbrains.annotations.NotNull;
12
13
import static com.vladsch.flexmark.parser.Parser.ParserExtension;
14
15
/**
16
 * Responsible for wrapping delimited TeX code in Markdown into an XML element
17
 * that the HTML renderer can handle. For example, {@code $E=mc^2$} becomes
18
 * {@code <tex>E=mc^2</tex>} when passed to HTML renderer. The HTML renderer
19
 * is responsible for converting the TeX code for display. This avoids inserting
20
 * SVG code into the Markdown document, which the parser would then have to
21
 * iterate---a <em>very</em> wasteful operation that impacts front-end
22
 * performance.
23
 */
24
public class TeXExtension extends HtmlRendererAdapter
25
  implements ParserExtension {
26
27
  /**
28
   * Controls how the node renderer produces TeX code within HTML output.
29
   */
30
  private final ExportFormat mExportFormat;
31
32
  private TeXExtension( final ProcessorContext context ) {
33
    mExportFormat = context.getExportFormat();
34
  }
35
36
  /**
37
   * Creates an extension capable of handling delimited TeX code in Markdown.
38
   *
39
   * @return The new {@link TeXExtension}, never {@code null}.
40
   */
41
  public static TeXExtension create( final ProcessorContext context ) {
42
    return new TeXExtension( context );
43
  }
44
45
  /**
46
   * Adds the TeX extension for HTML document export types.
47
   *
48
   * @param builder      The document builder.
49
   * @param rendererType Indicates the document type to be built.
50
   */
51
  @Override
52
  public void extend( @NotNull final HtmlRenderer.Builder builder,
53
                      @NotNull final String rendererType ) {
54
    if( "HTML".equalsIgnoreCase( rendererType ) ) {
55
      builder.nodeRendererFactory( new Factory( mExportFormat ) );
56
    }
57
  }
58
59
  @Override
60
  public void extend( final Parser.Builder builder ) {
61
    builder.customDelimiterProcessor( new TeXInlineDelimiterProcessor() );
62
  }
63
64
  @Override
65
  public void parserOptions( final MutableDataHolder options ) {
66
  }
67
}
168
A src/main/java/com/keenwrite/processors/markdown/extensions/tex/TeXInlineDelimiterProcessor.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.processors.markdown.extensions.tex;
3
4
import com.vladsch.flexmark.parser.InlineParser;
5
import com.vladsch.flexmark.parser.core.delimiter.Delimiter;
6
import com.vladsch.flexmark.parser.delimiter.DelimiterProcessor;
7
import com.vladsch.flexmark.parser.delimiter.DelimiterRun;
8
import com.vladsch.flexmark.util.ast.Node;
9
10
public class TeXInlineDelimiterProcessor implements DelimiterProcessor {
11
12
  @Override
13
  public void process( final Delimiter opener,
14
                       final Delimiter closer,
15
                       final int delimitersUsed ) {
16
    final var node = new TexNode();
17
    opener.moveNodesBetweenDelimitersTo( node, closer );
18
  }
19
20
  @Override
21
  public char getOpeningCharacter() {
22
    return '$';
23
  }
24
25
  @Override
26
  public char getClosingCharacter() {
27
    return '$';
28
  }
29
30
  @Override
31
  public int getMinLength() {
32
    return 1;
33
  }
34
35
  /**
36
   * Allow for $ or $$.
37
   *
38
   * @param opener One or more opening delimiter characters.
39
   * @param closer One or more closing delimiter characters.
40
   * @return The number of delimiters to use to determine whether a valid
41
   * opening delimiter expression is found.
42
   */
43
  @Override
44
  public int getDelimiterUse(
45
      final DelimiterRun opener, final DelimiterRun closer ) {
46
    return 1;
47
  }
48
49
  @Override
50
  public boolean canBeOpener( final String before,
51
                              final String after,
52
                              final boolean leftFlanking,
53
                              final boolean rightFlanking,
54
                              final boolean beforeIsPunctuation,
55
                              final boolean afterIsPunctuation,
56
                              final boolean beforeIsWhitespace,
57
                              final boolean afterIsWhiteSpace ) {
58
    return leftFlanking;
59
  }
60
61
  @Override
62
  public boolean canBeCloser( final String before,
63
                              final String after,
64
                              final boolean leftFlanking,
65
                              final boolean rightFlanking,
66
                              final boolean beforeIsPunctuation,
67
                              final boolean afterIsPunctuation,
68
                              final boolean beforeIsWhitespace,
69
                              final boolean afterIsWhiteSpace ) {
70
    return rightFlanking;
71
  }
72
73
  @Override
74
  public Node unmatchedDelimiterNode(
75
      final InlineParser inlineParser, final DelimiterRun delimiter ) {
76
    return null;
77
  }
78
79
  @Override
80
  public boolean skipNonOpenerCloser() {
81
    return false;
82
  }
83
}
184
A src/main/java/com/keenwrite/processors/markdown/extensions/tex/TexNode.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.processors.markdown.extensions.tex;
3
4
import com.vladsch.flexmark.ast.DelimitedNodeImpl;
5
6
public class TexNode extends DelimitedNodeImpl {
7
  /**
8
   * TeX expression wrapped in a {@code <tex>} element.
9
   */
10
  public static final String HTML_TEX = "tex";
11
12
  public static final String TOKEN_OPEN = "$";
13
  public static final String TOKEN_CLOSE = "$";
14
15
  public TexNode() {
16
  }
17
}
118
A src/main/java/com/keenwrite/processors/markdown/extensions/tex/TexNodeRenderer.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.processors.markdown.extensions.tex;
3
4
import com.keenwrite.ExportFormat;
5
import com.keenwrite.preview.SvgRasterizer;
6
import com.vladsch.flexmark.html.HtmlWriter;
7
import com.vladsch.flexmark.html.renderer.NodeRenderer;
8
import com.vladsch.flexmark.html.renderer.NodeRendererContext;
9
import com.vladsch.flexmark.html.renderer.NodeRendererFactory;
10
import com.vladsch.flexmark.html.renderer.NodeRenderingHandler;
11
import com.vladsch.flexmark.util.ast.Node;
12
import com.vladsch.flexmark.util.data.DataHolder;
13
import org.jetbrains.annotations.NotNull;
14
import org.jetbrains.annotations.Nullable;
15
16
import java.util.Set;
17
18
import static com.keenwrite.preview.MathRenderer.MATH_RENDERER;
19
import static com.keenwrite.processors.markdown.extensions.tex.TexNode.*;
20
21
public class TexNodeRenderer {
22
23
  public static class Factory implements NodeRendererFactory {
24
    private final ExportFormat mExportFormat;
25
26
    public Factory( final ExportFormat exportFormat ) {
27
      mExportFormat = exportFormat;
28
    }
29
30
    @NotNull
31
    @Override
32
    public NodeRenderer apply( @NotNull DataHolder options ) {
33
      return switch( mExportFormat ) {
34
        case HTML_TEX_SVG -> new TexSvgNodeRenderer();
35
        case HTML_TEX_DELIMITED, MARKDOWN_PLAIN -> new TexDelimNodeRenderer();
36
        case NONE -> new TexElementNodeRenderer();
37
      };
38
    }
39
  }
40
41
  private static abstract class AbstractTexNodeRenderer
42
      implements NodeRenderer {
43
44
    @Override
45
    public @Nullable Set<NodeRenderingHandler<?>> getNodeRenderingHandlers() {
46
      final var h = new NodeRenderingHandler<>( TexNode.class, this::render );
47
      return Set.of( h );
48
    }
49
50
    /**
51
     * Subclasses implement this method to render the content of {@link TexNode}
52
     * instances as per their associated {@link ExportFormat}.
53
     *
54
     * @param node    {@link Node} containing text content of a math formula.
55
     * @param context Configuration information (unused).
56
     * @param html    Where to write the rendered output.
57
     */
58
    abstract void render( final TexNode node,
59
                          final NodeRendererContext context,
60
                          final HtmlWriter html );
61
  }
62
63
  /**
64
   * Responsible for rendering a TeX node as an HTML {@code <tex>}
65
   * element. This is the default behaviour.
66
   */
67
  private static class TexElementNodeRenderer extends AbstractTexNodeRenderer {
68
    void render( final TexNode node,
69
                 final NodeRendererContext context,
70
                 final HtmlWriter html ) {
71
      html.tag( HTML_TEX );
72
      html.raw( node.getText() );
73
      html.closeTag( HTML_TEX );
74
    }
75
  }
76
77
  /**
78
   * Responsible for rendering a TeX node as an HTML {@code <svg>}
79
   * element.
80
   */
81
  private static class TexSvgNodeRenderer extends AbstractTexNodeRenderer {
82
    void render( final TexNode node,
83
                 final NodeRendererContext context,
84
                 final HtmlWriter html ) {
85
      final var tex = node.getText().toStringOrNull();
86
      final var doc = MATH_RENDERER.render( tex == null ? "" : tex );
87
      final var svg = SvgRasterizer.toSvg( doc.getDocumentElement() );
88
      html.raw( svg );
89
    }
90
  }
91
92
  /**
93
   * Responsible for rendering a TeX node as text bracketed by $ tokens.
94
   */
95
  private static class TexDelimNodeRenderer extends AbstractTexNodeRenderer {
96
    void render( final TexNode node,
97
                 final NodeRendererContext context,
98
                 final HtmlWriter html ) {
99
      html.raw( TOKEN_OPEN );
100
      html.raw( node.getText() );
101
      html.raw( TOKEN_CLOSE );
102
    }
103
  }
104
}
1105
D src/main/java/com/keenwrite/processors/markdown/r/RExtension.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.processors.markdown.r;
3
4
import com.keenwrite.processors.InlineRProcessor;
5
import com.keenwrite.sigils.RSigilOperator;
6
import com.vladsch.flexmark.ast.Text;
7
import com.vladsch.flexmark.parser.InlineParserExtensionFactory;
8
import com.vladsch.flexmark.parser.InlineParserFactory;
9
import com.vladsch.flexmark.parser.Parser;
10
import com.vladsch.flexmark.parser.delimiter.DelimiterProcessor;
11
import com.vladsch.flexmark.parser.internal.InlineParserImpl;
12
import com.vladsch.flexmark.parser.internal.LinkRefProcessorData;
13
import com.vladsch.flexmark.util.data.DataHolder;
14
import com.vladsch.flexmark.util.data.MutableDataHolder;
15
16
import java.util.BitSet;
17
import java.util.List;
18
import java.util.Map;
19
20
/**
21
 * Responsible for preventing the Markdown engine from interpreting inline
22
 * backticks as inline code elements. This is required so that inline R code
23
 * can be executed after conversion of Markdown to HTML but before the HTML
24
 * is previewed (or exported).
25
 */
26
public final class RExtension implements Parser.ParserExtension {
27
  private static final InlineParserFactory FACTORY = CustomParser::new;
28
29
  private RExtension() {
30
  }
31
32
  /**
33
   * Creates an extension capable of intercepting R code blocks and preventing
34
   * them from being converted into HTML {@code <code>} elements.
35
   */
36
  public static RExtension create() {
37
    return new RExtension();
38
  }
39
40
  @Override
41
  public void extend( final Parser.Builder builder ) {
42
    builder.customInlineParserFactory( FACTORY );
43
  }
44
45
  @Override
46
  public void parserOptions( final MutableDataHolder options ) {
47
  }
48
49
  /**
50
   * Prevents rendering {@code `r} statements as inline HTML {@code <code>}
51
   * blocks, which allows the {@link InlineRProcessor} to post-process the
52
   * text prior to display in the preview pane. This intervention assists
53
   * with decoupling the caret from the Markdown content so that the two
54
   * can vary independently in the architecture while permitting synchronization
55
   * of the editor and preview pane.
56
   * <p>
57
   * The text is therefore processed twice: once by flexmark-java and once by
58
   * {@link InlineRProcessor}.
59
   * </p>
60
   */
61
  private static class CustomParser extends InlineParserImpl {
62
    private CustomParser(
63
      final DataHolder options,
64
      final BitSet specialCharacters,
65
      final BitSet delimiterCharacters,
66
      final Map<Character, DelimiterProcessor> delimiterProcessors,
67
      final LinkRefProcessorData referenceLinkProcessors,
68
      final List<InlineParserExtensionFactory> inlineParserExtensions ) {
69
      super( options,
70
             specialCharacters,
71
             delimiterCharacters,
72
             delimiterProcessors,
73
             referenceLinkProcessors,
74
             inlineParserExtensions );
75
    }
76
77
    /**
78
     * The superclass handles a number backtick parsing edge cases; this method
79
     * changes the behaviour to retain R code snippets, identified by
80
     * {@link RSigilOperator#PREFIX}, so that subsequent processing can
81
     * invoke R. If other languages are added, the {@link CustomParser} will
82
     * have to be rewritten to identify more than merely R.
83
     *
84
     * @return The return value from {@link super#parseBackticks()}.
85
     * @inheritDoc
86
     */
87
    @Override
88
    protected final boolean parseBackticks() {
89
      final var foundTicks = super.parseBackticks();
90
91
      if( foundTicks ) {
92
        final var blockNode = getBlock();
93
        final var codeNode = blockNode.getLastChild();
94
95
        if( codeNode != null ) {
96
          final var code = codeNode.getChars();
97
98
          if( code.startsWith( RSigilOperator.PREFIX ) ) {
99
            codeNode.unlink();
100
            blockNode.appendChild( new Text( code ) );
101
          }
102
        }
103
      }
104
105
      return foundTicks;
106
    }
107
  }
108
}
1091
D src/main/java/com/keenwrite/processors/markdown/tex/TeXInlineDelimiterProcessor.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.processors.markdown.tex;
3
4
import com.vladsch.flexmark.parser.InlineParser;
5
import com.vladsch.flexmark.parser.core.delimiter.Delimiter;
6
import com.vladsch.flexmark.parser.delimiter.DelimiterProcessor;
7
import com.vladsch.flexmark.parser.delimiter.DelimiterRun;
8
import com.vladsch.flexmark.util.ast.Node;
9
10
public class TeXInlineDelimiterProcessor implements DelimiterProcessor {
11
12
  @Override
13
  public void process( final Delimiter opener,
14
                       final Delimiter closer,
15
                       final int delimitersUsed ) {
16
    final var node = new TexNode();
17
    opener.moveNodesBetweenDelimitersTo( node, closer );
18
  }
19
20
  @Override
21
  public char getOpeningCharacter() {
22
    return '$';
23
  }
24
25
  @Override
26
  public char getClosingCharacter() {
27
    return '$';
28
  }
29
30
  @Override
31
  public int getMinLength() {
32
    return 1;
33
  }
34
35
  /**
36
   * Allow for $ or $$.
37
   *
38
   * @param opener One or more opening delimiter characters.
39
   * @param closer One or more closing delimiter characters.
40
   * @return The number of delimiters to use to determine whether a valid
41
   * opening delimiter expression is found.
42
   */
43
  @Override
44
  public int getDelimiterUse(
45
      final DelimiterRun opener, final DelimiterRun closer ) {
46
    return 1;
47
  }
48
49
  @Override
50
  public boolean canBeOpener( final String before,
51
                              final String after,
52
                              final boolean leftFlanking,
53
                              final boolean rightFlanking,
54
                              final boolean beforeIsPunctuation,
55
                              final boolean afterIsPunctuation,
56
                              final boolean beforeIsWhitespace,
57
                              final boolean afterIsWhiteSpace ) {
58
    return leftFlanking;
59
  }
60
61
  @Override
62
  public boolean canBeCloser( final String before,
63
                              final String after,
64
                              final boolean leftFlanking,
65
                              final boolean rightFlanking,
66
                              final boolean beforeIsPunctuation,
67
                              final boolean afterIsPunctuation,
68
                              final boolean beforeIsWhitespace,
69
                              final boolean afterIsWhiteSpace ) {
70
    return rightFlanking;
71
  }
72
73
  @Override
74
  public Node unmatchedDelimiterNode(
75
      final InlineParser inlineParser, final DelimiterRun delimiter ) {
76
    return null;
77
  }
78
79
  @Override
80
  public boolean skipNonOpenerCloser() {
81
    return false;
82
  }
83
}
841
D src/main/java/com/keenwrite/processors/markdown/tex/TexNode.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.processors.markdown.tex;
3
4
import com.vladsch.flexmark.ast.DelimitedNodeImpl;
5
6
public class TexNode extends DelimitedNodeImpl {
7
  /**
8
   * TeX expression wrapped in a {@code <tex>} element.
9
   */
10
  public static final String HTML_TEX = "tex";
11
12
  public static final String TOKEN_OPEN = "$";
13
  public static final String TOKEN_CLOSE = "$";
14
15
  public TexNode() {
16
  }
17
}
181
D src/main/java/com/keenwrite/processors/markdown/tex/TexNodeRenderer.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.processors.markdown.tex;
3
4
import com.keenwrite.ExportFormat;
5
import com.keenwrite.preview.SvgRasterizer;
6
import com.vladsch.flexmark.html.HtmlWriter;
7
import com.vladsch.flexmark.html.renderer.NodeRenderer;
8
import com.vladsch.flexmark.html.renderer.NodeRendererContext;
9
import com.vladsch.flexmark.html.renderer.NodeRendererFactory;
10
import com.vladsch.flexmark.html.renderer.NodeRenderingHandler;
11
import com.vladsch.flexmark.util.ast.Node;
12
import com.vladsch.flexmark.util.data.DataHolder;
13
import org.jetbrains.annotations.NotNull;
14
import org.jetbrains.annotations.Nullable;
15
16
import java.util.Set;
17
18
import static com.keenwrite.preview.MathRenderer.MATH_RENDERER;
19
import static com.keenwrite.processors.markdown.tex.TexNode.*;
20
21
public class TexNodeRenderer {
22
23
  public static class Factory implements NodeRendererFactory {
24
    private final ExportFormat mExportFormat;
25
26
    public Factory( final ExportFormat exportFormat ) {
27
      mExportFormat = exportFormat;
28
    }
29
30
    @NotNull
31
    @Override
32
    public NodeRenderer apply( @NotNull DataHolder options ) {
33
      return switch( mExportFormat ) {
34
        case HTML_TEX_SVG -> new TexSvgNodeRenderer();
35
        case HTML_TEX_DELIMITED, MARKDOWN_PLAIN -> new TexDelimNodeRenderer();
36
        case NONE -> new TexElementNodeRenderer();
37
      };
38
    }
39
  }
40
41
  private static abstract class AbstractTexNodeRenderer
42
      implements NodeRenderer {
43
44
    @Override
45
    public @Nullable Set<NodeRenderingHandler<?>> getNodeRenderingHandlers() {
46
      final var h = new NodeRenderingHandler<>( TexNode.class, this::render );
47
      return Set.of( h );
48
    }
49
50
    /**
51
     * Subclasses implement this method to render the content of {@link TexNode}
52
     * instances as per their associated {@link ExportFormat}.
53
     *
54
     * @param node    {@link Node} containing text content of a math formula.
55
     * @param context Configuration information (unused).
56
     * @param html    Where to write the rendered output.
57
     */
58
    abstract void render( final TexNode node,
59
                          final NodeRendererContext context,
60
                          final HtmlWriter html );
61
  }
62
63
  /**
64
   * Responsible for rendering a TeX node as an HTML {@code <tex>}
65
   * element. This is the default behaviour.
66
   */
67
  private static class TexElementNodeRenderer extends AbstractTexNodeRenderer {
68
    void render( final TexNode node,
69
                 final NodeRendererContext context,
70
                 final HtmlWriter html ) {
71
      html.tag( HTML_TEX );
72
      html.raw( node.getText() );
73
      html.closeTag( HTML_TEX );
74
    }
75
  }
76
77
  /**
78
   * Responsible for rendering a TeX node as an HTML {@code <svg>}
79
   * element.
80
   */
81
  private static class TexSvgNodeRenderer extends AbstractTexNodeRenderer {
82
    void render( final TexNode node,
83
                 final NodeRendererContext context,
84
                 final HtmlWriter html ) {
85
      final var tex = node.getText().toStringOrNull();
86
      final var doc = MATH_RENDERER.render( tex == null ? "" : tex );
87
      final var svg = SvgRasterizer.toSvg( doc.getDocumentElement() );
88
      html.raw( svg );
89
    }
90
  }
91
92
  /**
93
   * Responsible for rendering a TeX node as text bracketed by $ tokens.
94
   */
95
  private static class TexDelimNodeRenderer extends AbstractTexNodeRenderer {
96
    void render( final TexNode node,
97
                 final NodeRendererContext context,
98
                 final HtmlWriter html ) {
99
      html.raw( TOKEN_OPEN );
100
      html.raw( node.getText() );
101
      html.raw( TOKEN_CLOSE );
102
    }
103
  }
104
}
1051
M src/main/java/com/keenwrite/processors/text/AbstractTextReplacer.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.processors.text;
33
M src/main/java/com/keenwrite/processors/text/AhoCorasickReplacer.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.processors.text;
33
M src/main/java/com/keenwrite/processors/text/StringUtilsReplacer.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.processors.text;
33
M src/main/java/com/keenwrite/processors/text/TextReplacementFactory.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.processors.text;
33
M src/main/java/com/keenwrite/processors/text/TextReplacer.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.processors.text;
33
M src/main/java/com/keenwrite/search/SearchModel.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.search;
33
M src/main/java/com/keenwrite/service/Service.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.service;
33
M src/main/java/com/keenwrite/service/Settings.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.service;
33
M src/main/java/com/keenwrite/service/Snitch.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.service;
33
M src/main/java/com/keenwrite/service/events/Notification.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.service.events;
33
M src/main/java/com/keenwrite/service/events/Notifier.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.service.events;
33
M src/main/java/com/keenwrite/service/events/impl/ButtonOrderPane.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.service.events.impl;
33
...
1414
 */
1515
public class ButtonOrderPane extends DialogPane {
16
  public ButtonOrderPane() {
17
  }
1618
1719
  @Override
1820
  protected Node createButtonBar() {
1921
    final var node = (ButtonBar) super.createButtonBar();
2022
    node.setButtonOrder( getButtonOrder() );
2123
    return node;
2224
  }
2325
2426
  private String getButtonOrder() {
25
    return getSetting( "dialog.alert.button.order.windows",
26
                       BUTTON_ORDER_WINDOWS );
27
  }
28
29
  @SuppressWarnings("SameParameterValue")
30
  private String getSetting( final String key, final String defaultValue ) {
31
    return sSettings.getSetting( key, defaultValue );
27
    return sSettings.getSetting(
28
      "dialog.alert.button.order.windows", BUTTON_ORDER_WINDOWS );
3229
  }
3330
}
M src/main/java/com/keenwrite/service/events/impl/DefaultNotification.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.service.events.impl;
33
M src/main/java/com/keenwrite/service/events/impl/DefaultNotifier.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.service.events.impl;
33
...
1010
import java.nio.file.Path;
1111
12
import static com.keenwrite.Constants.ICON_DIALOG_NODE;
1213
import static com.keenwrite.Messages.get;
1314
import static javafx.scene.control.Alert.AlertType.CONFIRMATION;
...
6869
    alert.setContentText( message.getContent() );
6970
    alert.initOwner( parent );
71
    alert.setGraphic( ICON_DIALOG_NODE );
7072
7173
    return alert;
M src/main/java/com/keenwrite/service/impl/DefaultSettings.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.service.impl;
33
M src/main/java/com/keenwrite/service/impl/DefaultSnitch.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.service.impl;
33
...
1313
1414
import static com.keenwrite.Constants.APP_WATCHDOG_TIMEOUT;
15
import static com.keenwrite.StatusBarNotifier.clue;
15
import static com.keenwrite.StatusNotifier.clue;
1616
import static java.nio.file.StandardWatchEventKinds.ENTRY_MODIFY;
1717
M src/main/java/com/keenwrite/sigils/RSigilOperator.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.sigils;
33
M src/main/java/com/keenwrite/sigils/SigilOperator.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.sigils;
33
M src/main/java/com/keenwrite/sigils/Tokens.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.sigils;
33
M src/main/java/com/keenwrite/sigils/YamlSigilOperator.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.sigils;
33
M src/main/java/com/keenwrite/spelling/api/SpellCheckListener.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.spelling.api;
33
M src/main/java/com/keenwrite/spelling/api/SpellChecker.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.spelling.api;
33
M src/main/java/com/keenwrite/spelling/api/package-info.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved.
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved.
22
 *
33
 * Redistribution and use in source and binary forms, with or without
M src/main/java/com/keenwrite/spelling/impl/PermissiveSpeller.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.spelling.impl;
33
M src/main/java/com/keenwrite/spelling/impl/SymSpellSpeller.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.spelling.impl;
33
...
1818
1919
import static com.keenwrite.Constants.LEXICONS_DIRECTORY;
20
import static com.keenwrite.StatusBarNotifier.clue;
20
import static com.keenwrite.StatusNotifier.clue;
2121
import static io.gitlab.rxp90.jsymspell.SymSpell.Verbosity;
2222
import static io.gitlab.rxp90.jsymspell.SymSpell.Verbosity.ALL;
M src/main/java/com/keenwrite/spelling/impl/TextEditorSpeller.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.spelling.impl;
33
M src/main/java/com/keenwrite/spelling/impl/package-info.java
1
/* Copyright 2020 White Magic Software, Ltd.
1
/* Copyright 2020-2021 White Magic Software, Ltd.
22
 *
33
 * All rights reserved.
M src/main/java/com/keenwrite/ui/actions/Action.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.ui.actions;
33
M src/main/java/com/keenwrite/ui/actions/ApplicationActions.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.ui.actions;
3
4
import com.keenwrite.ExportFormat;
5
import com.keenwrite.MainPane;
6
import com.keenwrite.editors.TextDefinition;
7
import com.keenwrite.editors.TextEditor;
8
import com.keenwrite.preferences.PreferencesController;
9
import com.keenwrite.preferences.Workspace;
10
import com.keenwrite.processors.ProcessorContext;
11
import com.keenwrite.search.SearchModel;
12
import com.keenwrite.ui.controls.SearchBar;
13
import javafx.scene.control.Alert;
14
import javafx.scene.image.ImageView;
15
import javafx.stage.Window;
16
import javafx.stage.WindowEvent;
17
18
import static com.keenwrite.Bootstrap.APP_TITLE;
19
import static com.keenwrite.Constants.ICON_DIALOG;
20
import static com.keenwrite.ExportFormat.*;
21
import static com.keenwrite.Messages.get;
22
import static com.keenwrite.StatusBarNotifier.clue;
23
import static com.keenwrite.StatusBarNotifier.getStatusBar;
24
import static com.keenwrite.preferences.Workspace.KEY_UI_RECENT_DIR;
25
import static com.keenwrite.processors.ProcessorFactory.createProcessors;
26
import static java.nio.file.Files.writeString;
27
import static javafx.event.Event.fireEvent;
28
import static javafx.scene.control.Alert.AlertType.INFORMATION;
29
import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST;
30
31
/**
32
 * Responsible for abstracting how functionality is mapped to the application.
33
 * This allows users to customize accelerator keys and will provide pluggable
34
 * functionality so that different text markup languages can change documents
35
 * using their respective syntax.
36
 */
37
@SuppressWarnings("NonAsciiCharacters")
38
public class ApplicationActions {
39
  private static final String STYLE_SEARCH = "search";
40
41
  /**
42
   * When an action is executed, this is one of the recipients.
43
   */
44
  private final MainPane mMainPane;
45
46
  /**
47
   * Tracks finding text in the active document.
48
   */
49
  private final SearchModel mSearchModel;
50
51
  public ApplicationActions( final MainPane mainPane ) {
52
    mMainPane = mainPane;
53
    mSearchModel = new SearchModel();
54
    mSearchModel.matchOffsetProperty().addListener( ( c, o, n ) -> {
55
      final var editor = getActiveTextEditor();
56
57
      // Clear highlighted areas before adding highlighting to a new region.
58
      if( o != null ) {
59
        editor.unstylize( STYLE_SEARCH );
60
      }
61
62
      if( n != null ) {
63
        editor.moveTo( n.getStart() );
64
        editor.stylize( n, STYLE_SEARCH );
65
      }
66
    } );
67
68
    // When the active text editor changes, update the haystack.
69
    mMainPane.activeTextEditorProperty().addListener(
70
      ( c, o, n ) -> mSearchModel.search( getActiveTextEditor().getText() )
71
    );
72
  }
73
74
  public void file‿new() {
75
    getMainPane().newTextEditor();
76
  }
77
78
  public void file‿open() {
79
    getMainPane().open( createFileChooser().openFiles() );
80
  }
81
82
  public void file‿close() {
83
    getMainPane().close();
84
  }
85
86
  public void file‿close_all() {
87
    getMainPane().closeAll();
88
  }
89
90
  public void file‿save() {
91
    getMainPane().save();
92
  }
93
94
  public void file‿save_as() {
95
    final var file = createFileChooser().saveAs();
96
    file.ifPresent( ( f ) -> getMainPane().saveAs( f ) );
97
  }
98
99
  public void file‿save_all() {
100
    getMainPane().saveAll();
101
  }
102
103
  public void file‿export‿html_svg() {
104
    file‿export( HTML_TEX_SVG );
105
  }
106
107
  public void file‿export‿html_tex() {
108
    file‿export( HTML_TEX_DELIMITED );
109
  }
110
111
  public void file‿export‿markdown() {
112
    file‿export( MARKDOWN_PLAIN );
113
  }
114
115
  private void file‿export( final ExportFormat format ) {
116
    final var editor = getActiveTextEditor();
117
    final var context = createProcessorContext( editor );
118
    final var chain = createProcessors( context );
119
    final var doc = editor.getText();
120
    final var export = chain.apply( doc );
121
    final var filename = format.toExportFilename( editor.getPath() );
122
    final var chooser = createFileChooser();
123
    final var file = chooser.exportAs( filename );
124
125
    file.ifPresent( ( f ) -> {
126
      try {
127
        writeString( f.toPath(), export );
128
        final var m = get( "Main.status.export.success", f.toString() );
129
        clue( m );
130
      } catch( final Exception e ) {
131
        clue( e );
132
      }
133
    } );
134
  }
135
136
  private ProcessorContext createProcessorContext( final TextEditor editor ) {
137
    return getMainPane().createProcessorContext( editor );
138
  }
139
140
  public void file‿exit() {
141
    final var window = getWindow();
142
    fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) );
143
  }
144
145
  public void edit‿undo() {
146
    getActiveTextEditor().undo();
147
  }
148
149
  public void edit‿redo() {
150
    getActiveTextEditor().redo();
151
  }
152
153
  public void edit‿cut() {
154
    getActiveTextEditor().cut();
155
  }
156
157
  public void edit‿copy() {
158
    getActiveTextEditor().copy();
159
  }
160
161
  public void edit‿paste() {
162
    getActiveTextEditor().paste();
163
  }
164
165
  public void edit‿select_all() {
166
    getActiveTextEditor().selectAll();
167
  }
168
169
  public void edit‿find() {
170
    final var nodes = getStatusBar().getLeftItems();
171
172
    if( nodes.isEmpty() ) {
173
      final var searchBar = new SearchBar();
174
175
      searchBar.matchIndexProperty().bind( mSearchModel.matchIndexProperty() );
176
      searchBar.matchCountProperty().bind( mSearchModel.matchCountProperty() );
177
178
      searchBar.setOnCancelAction( ( event ) -> {
179
        final var editor = getActiveTextEditor();
180
        nodes.remove( searchBar );
181
        editor.unstylize( STYLE_SEARCH );
182
        editor.getNode().requestFocus();
183
      } );
184
185
      searchBar.addInputListener( ( c, o, n ) -> {
186
        if( n != null && !n.isEmpty() ) {
187
          mSearchModel.search( n, getActiveTextEditor().getText() );
188
        }
189
      } );
190
191
      searchBar.setOnNextAction( ( event ) -> edit‿find_next() );
192
      searchBar.setOnPrevAction( ( event ) -> edit‿find_prev() );
193
194
      nodes.add( searchBar );
195
      searchBar.requestFocus();
196
    }
197
    else {
198
      nodes.clear();
199
    }
200
  }
201
202
  public void edit‿find_next() {
203
    mSearchModel.advance();
204
  }
205
206
  public void edit‿find_prev() {
207
    mSearchModel.retreat();
208
  }
209
210
  public void edit‿preferences() {
211
    new PreferencesController( getWorkspace() ).show();
212
  }
213
214
  public void format‿bold() {
215
    getActiveTextEditor().bold();
216
  }
217
218
  public void format‿italic() {
219
    getActiveTextEditor().italic();
220
  }
221
222
  public void format‿superscript() {
223
    getActiveTextEditor().superscript();
224
  }
225
226
  public void format‿subscript() {
227
    getActiveTextEditor().subscript();
228
  }
229
230
  public void format‿strikethrough() {
231
    getActiveTextEditor().strikethrough();
232
  }
233
234
  public void insert‿blockquote() {
235
    getActiveTextEditor().blockquote();
236
  }
237
238
  public void insert‿code() {
239
    getActiveTextEditor().code();
240
  }
241
242
  public void insert‿fenced_code_block() {
243
    getActiveTextEditor().fencedCodeBlock();
244
  }
245
246
  public void insert‿link() {
247
    createMarkdownDialog().insertLink( getActiveTextEditor().getTextArea() );
248
  }
249
250
  public void insert‿image() {
251
    createMarkdownDialog().insertImage( getActiveTextEditor().getTextArea() );
252
  }
253
254
  private MarkdownCommands createMarkdownDialog() {
255
    return new MarkdownCommands(
256
      getMainPane(), getActiveTextEditor().getPath() );
257
  }
258
259
  public void insert‿heading_1() {
260
    insert‿heading( 1 );
261
  }
262
263
  public void insert‿heading_2() {
264
    insert‿heading( 2 );
265
  }
266
267
  public void insert‿heading_3() {
268
    insert‿heading( 3 );
269
  }
270
271
  private void insert‿heading( final int level ) {
272
    getActiveTextEditor().heading( level );
273
  }
274
275
  public void insert‿unordered_list() {
276
    getActiveTextEditor().unorderedList();
277
  }
278
279
  public void insert‿ordered_list() {
280
    getActiveTextEditor().orderedList();
281
  }
282
283
  public void insert‿horizontal_rule() {
284
    getActiveTextEditor().horizontalRule();
285
  }
286
287
  public void definition‿create() {
288
    getActiveTextDefinition().createDefinition();
289
  }
290
291
  public void definition‿rename() {
292
    getActiveTextDefinition().renameDefinition();
293
  }
294
295
  public void definition‿delete() {
296
    getActiveTextDefinition().deleteDefinitions();
297
  }
298
299
  public void definition‿autoinsert() {
300
    getMainPane().autoinsert();
301
  }
302
303
  public void view‿refresh() {
304
    getMainPane().viewRefresh();
305
  }
306
307
  public void view‿preview() {
308
    getMainPane().viewPreview();
309
  }
310
311
  public void help‿about() {
312
    final Alert alert = new Alert( INFORMATION );
313
    alert.setTitle( get( "Dialog.about.title", APP_TITLE ) );
314
    alert.setHeaderText( get( "Dialog.about.header", APP_TITLE ) );
315
    alert.setContentText( get( "Dialog.about.content" ) );
316
    alert.setGraphic( new ImageView( ICON_DIALOG ) );
317
    alert.initOwner( getWindow() );
318
    alert.showAndWait();
319
  }
320
321
  private FileChooserCommand createFileChooser() {
322
    final var dir = getWorkspace().fileProperty( KEY_UI_RECENT_DIR );
323
    return new FileChooserCommand( getWindow(), dir );
324
  }
325
326
  private MainPane getMainPane() {
327
    return mMainPane;
328
  }
329
330
  private TextEditor getActiveTextEditor() {
331
    return getMainPane().getActiveTextEditor();
332
  }
333
334
  private TextDefinition getActiveTextDefinition() {
335
    return getMainPane().getActiveTextDefinition();
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.ui.actions;
3
4
import com.keenwrite.ExportFormat;
5
import com.keenwrite.MainPane;
6
import com.keenwrite.MainScene;
7
import com.keenwrite.StatusNotifier;
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.preferences.PreferencesController;
13
import com.keenwrite.preferences.Workspace;
14
import com.keenwrite.processors.markdown.MarkdownProcessor;
15
import com.keenwrite.search.SearchModel;
16
import com.keenwrite.ui.controls.SearchBar;
17
import com.keenwrite.ui.dialogs.ImageDialog;
18
import com.keenwrite.ui.dialogs.LinkDialog;
19
import com.vladsch.flexmark.ast.Link;
20
import javafx.scene.control.Alert;
21
import javafx.scene.control.Dialog;
22
import javafx.stage.Window;
23
import javafx.stage.WindowEvent;
24
25
import static com.keenwrite.Bootstrap.APP_TITLE;
26
import static com.keenwrite.Constants.ICON_DIALOG_NODE;
27
import static com.keenwrite.ExportFormat.*;
28
import static com.keenwrite.Messages.get;
29
import static com.keenwrite.StatusNotifier.clue;
30
import static com.keenwrite.StatusNotifier.getStatusBar;
31
import static com.keenwrite.preferences.Workspace.KEY_UI_RECENT_DIR;
32
import static com.keenwrite.processors.ProcessorFactory.createProcessors;
33
import static java.nio.file.Files.writeString;
34
import static javafx.event.Event.fireEvent;
35
import static javafx.scene.control.Alert.AlertType.INFORMATION;
36
import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST;
37
38
/**
39
 * Responsible for abstracting how functionality is mapped to the application.
40
 * This allows users to customize accelerator keys and will provide pluggable
41
 * functionality so that different text markup languages can change documents
42
 * using their respective syntax.
43
 */
44
@SuppressWarnings( "NonAsciiCharacters" )
45
public class ApplicationActions {
46
  private static final String STYLE_SEARCH = "search";
47
48
  /**
49
   * When an action is executed, this is one of the recipients.
50
   */
51
  private final MainPane mMainPane;
52
53
  private final MainScene mMainScene;
54
55
  /**
56
   * Tracks finding text in the active document.
57
   */
58
  private final SearchModel mSearchModel;
59
60
  public ApplicationActions( final MainScene scene, final MainPane pane ) {
61
    mMainScene = scene;
62
    mMainPane = pane;
63
    mSearchModel = new SearchModel();
64
    mSearchModel.matchOffsetProperty().addListener( ( c, o, n ) -> {
65
      final var editor = getActiveTextEditor();
66
67
      // Clear highlighted areas before adding highlighting to a new region.
68
      if( o != null ) {
69
        editor.unstylize( STYLE_SEARCH );
70
      }
71
72
      if( n != null ) {
73
        editor.moveTo( n.getStart() );
74
        editor.stylize( n, STYLE_SEARCH );
75
      }
76
    } );
77
78
    // When the active text editor changes, update the haystack.
79
    mMainPane.activeTextEditorProperty().addListener(
80
      ( c, o, n ) -> mSearchModel.search( getActiveTextEditor().getText() )
81
    );
82
  }
83
84
  public void file‿new() {
85
    getMainPane().newTextEditor();
86
  }
87
88
  public void file‿open() {
89
    getMainPane().open( createFileChooser().openFiles() );
90
  }
91
92
  public void file‿close() {
93
    getMainPane().close();
94
  }
95
96
  public void file‿close_all() {
97
    getMainPane().closeAll();
98
  }
99
100
  public void file‿save() {
101
    getMainPane().save();
102
  }
103
104
  public void file‿save_as() {
105
    final var file = createFileChooser().saveAs();
106
    file.ifPresent( ( f ) -> getMainPane().saveAs( f ) );
107
  }
108
109
  public void file‿save_all() {
110
    getMainPane().saveAll();
111
  }
112
113
  public void file‿export‿html_svg() {
114
    file‿export( HTML_TEX_SVG );
115
  }
116
117
  public void file‿export‿html_tex() {
118
    file‿export( HTML_TEX_DELIMITED );
119
  }
120
121
  public void file‿export‿markdown() {
122
    file‿export( MARKDOWN_PLAIN );
123
  }
124
125
  private void file‿export( final ExportFormat format ) {
126
    final var main = getMainPane();
127
    final var context = main.createProcessorContext();
128
    final var chain = createProcessors( context );
129
    final var editor = main.getActiveTextEditor();
130
    final var doc = editor.getText();
131
    final var export = chain.apply( doc );
132
    final var filename = format.toExportFilename( editor.getPath() );
133
    final var chooser = createFileChooser();
134
    final var file = chooser.exportAs( filename );
135
136
    file.ifPresent( ( f ) -> {
137
      try {
138
        writeString( f.toPath(), export );
139
        final var m = get( "Main.status.export.success", f.toString() );
140
        clue( m );
141
      } catch( final Exception ex ) {
142
        clue( ex );
143
      }
144
    } );
145
  }
146
147
  public void file‿exit() {
148
    final var window = getWindow();
149
    fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) );
150
  }
151
152
  public void edit‿undo() {
153
    getActiveTextEditor().undo();
154
  }
155
156
  public void edit‿redo() {
157
    getActiveTextEditor().redo();
158
  }
159
160
  public void edit‿cut() {
161
    getActiveTextEditor().cut();
162
  }
163
164
  public void edit‿copy() {
165
    getActiveTextEditor().copy();
166
  }
167
168
  public void edit‿paste() {
169
    getActiveTextEditor().paste();
170
  }
171
172
  public void edit‿select_all() {
173
    getActiveTextEditor().selectAll();
174
  }
175
176
  public void edit‿find() {
177
    final var nodes = getStatusBar().getLeftItems();
178
179
    if( nodes.isEmpty() ) {
180
      final var searchBar = new SearchBar();
181
182
      searchBar.matchIndexProperty().bind( mSearchModel.matchIndexProperty() );
183
      searchBar.matchCountProperty().bind( mSearchModel.matchCountProperty() );
184
185
      searchBar.setOnCancelAction( ( event ) -> {
186
        final var editor = getActiveTextEditor();
187
        nodes.remove( searchBar );
188
        editor.unstylize( STYLE_SEARCH );
189
        editor.getNode().requestFocus();
190
      } );
191
192
      searchBar.addInputListener( ( c, o, n ) -> {
193
        if( n != null && !n.isEmpty() ) {
194
          mSearchModel.search( n, getActiveTextEditor().getText() );
195
        }
196
      } );
197
198
      searchBar.setOnNextAction( ( event ) -> edit‿find_next() );
199
      searchBar.setOnPrevAction( ( event ) -> edit‿find_prev() );
200
201
      nodes.add( searchBar );
202
      searchBar.requestFocus();
203
    }
204
    else {
205
      nodes.clear();
206
    }
207
  }
208
209
  public void edit‿find_next() {
210
    mSearchModel.advance();
211
  }
212
213
  public void edit‿find_prev() {
214
    mSearchModel.retreat();
215
  }
216
217
  public void edit‿preferences() {
218
    new PreferencesController( getWorkspace() ).show();
219
  }
220
221
  public void format‿bold() {
222
    getActiveTextEditor().bold();
223
  }
224
225
  public void format‿italic() {
226
    getActiveTextEditor().italic();
227
  }
228
229
  public void format‿superscript() {
230
    getActiveTextEditor().superscript();
231
  }
232
233
  public void format‿subscript() {
234
    getActiveTextEditor().subscript();
235
  }
236
237
  public void format‿strikethrough() {
238
    getActiveTextEditor().strikethrough();
239
  }
240
241
  public void insert‿blockquote() {
242
    getActiveTextEditor().blockquote();
243
  }
244
245
  public void insert‿code() {
246
    getActiveTextEditor().code();
247
  }
248
249
  public void insert‿fenced_code_block() {
250
    getActiveTextEditor().fencedCodeBlock();
251
  }
252
253
  public void insert‿link() {
254
    insertObject( createLinkDialog() );
255
  }
256
257
  public void insert‿image() {
258
    insertObject( createImageDialog() );
259
  }
260
261
  private void insertObject( final Dialog<String> dialog ) {
262
    final var textArea = getActiveTextEditor().getTextArea();
263
    dialog.showAndWait().ifPresent( textArea::replaceSelection );
264
  }
265
266
  private Dialog<String> createLinkDialog() {
267
    return new LinkDialog( getWindow(), createHyperlinkModel() );
268
  }
269
270
  private Dialog<String> createImageDialog() {
271
    final var path = getActiveTextEditor().getPath();
272
    final var parentDir = path.getParent();
273
    return new ImageDialog( getWindow(), parentDir );
274
  }
275
276
  /**
277
   * Returns one of: selected text, word under cursor, or parsed hyperlink from
278
   * the Markdown AST.
279
   *
280
   * @return An instance containing the link URL and display text.
281
   */
282
  private HyperlinkModel createHyperlinkModel() {
283
    final var context = getMainPane().createProcessorContext();
284
    final var editor = getActiveTextEditor();
285
    final var textArea = editor.getTextArea();
286
    final var selectedText = textArea.getSelectedText();
287
288
    // Convert current paragraph to Markdown nodes.
289
    final var mp = MarkdownProcessor.create( context );
290
    final var p = textArea.getCurrentParagraph();
291
    final var paragraph = textArea.getText( p );
292
    final var node = mp.toNode( paragraph );
293
    final var visitor = new LinkVisitor( textArea.getCaretColumn() );
294
    final var link = visitor.process( node );
295
296
    if( link != null ) {
297
      textArea.selectRange( p, link.getStartOffset(), p, link.getEndOffset() );
298
    }
299
300
    return createHyperlinkModel( link, selectedText );
301
  }
302
303
  private HyperlinkModel createHyperlinkModel(
304
    final Link link, final String selection ) {
305
306
    return link == null
307
      ? new HyperlinkModel( selection, "https://localhost" )
308
      : new HyperlinkModel( link );
309
  }
310
311
  public void insert‿heading_1() {
312
    insert‿heading( 1 );
313
  }
314
315
  public void insert‿heading_2() {
316
    insert‿heading( 2 );
317
  }
318
319
  public void insert‿heading_3() {
320
    insert‿heading( 3 );
321
  }
322
323
  private void insert‿heading( final int level ) {
324
    getActiveTextEditor().heading( level );
325
  }
326
327
  public void insert‿unordered_list() {
328
    getActiveTextEditor().unorderedList();
329
  }
330
331
  public void insert‿ordered_list() {
332
    getActiveTextEditor().orderedList();
333
  }
334
335
  public void insert‿horizontal_rule() {
336
    getActiveTextEditor().horizontalRule();
337
  }
338
339
  public void definition‿create() {
340
    getActiveTextDefinition().createDefinition();
341
  }
342
343
  public void definition‿rename() {
344
    getActiveTextDefinition().renameDefinition();
345
  }
346
347
  public void definition‿delete() {
348
    getActiveTextDefinition().deleteDefinitions();
349
  }
350
351
  public void definition‿autoinsert() {
352
    getMainPane().autoinsert();
353
  }
354
355
  public void view‿refresh() {
356
    getMainPane().viewRefresh();
357
  }
358
359
  public void view‿preview() {
360
    getMainPane().viewPreview();
361
  }
362
363
  public void view‿menubar() {
364
    getMainScene().toggleMenuBar();
365
  }
366
367
  public void view‿toolbar() {
368
    getMainScene().toggleToolBar();
369
  }
370
371
  public void view‿statusbar() {
372
    getMainScene().toggleStatusBar();
373
  }
374
375
  public void view‿issues() {
376
    StatusNotifier.viewIssues();
377
  }
378
379
  public void help‿about() {
380
    final Alert alert = new Alert( INFORMATION );
381
    alert.setTitle( get( "Dialog.about.title", APP_TITLE ) );
382
    alert.setHeaderText( get( "Dialog.about.header", APP_TITLE ) );
383
    alert.setContentText( get( "Dialog.about.content" ) );
384
    alert.setGraphic( ICON_DIALOG_NODE );
385
    alert.initOwner( getWindow() );
386
    alert.showAndWait();
387
  }
388
389
  private FileChooserCommand createFileChooser() {
390
    final var dir = getWorkspace().fileProperty( KEY_UI_RECENT_DIR );
391
    return new FileChooserCommand( getWindow(), dir );
392
  }
393
394
  private TextEditor getActiveTextEditor() {
395
    return getMainPane().getActiveTextEditor();
396
  }
397
398
  private TextDefinition getActiveTextDefinition() {
399
    return getMainPane().getActiveTextDefinition();
400
  }
401
402
  private MainScene getMainScene() {
403
    return mMainScene;
404
  }
405
406
  private MainPane getMainPane() {
407
    return mMainPane;
336408
  }
337409
A src/main/java/com/keenwrite/ui/actions/ApplicationBars.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.ui.actions;
3
4
import javafx.event.ActionEvent;
5
import javafx.event.EventHandler;
6
import javafx.scene.Node;
7
import javafx.scene.control.Menu;
8
import javafx.scene.control.MenuBar;
9
import javafx.scene.control.MenuItem;
10
import javafx.scene.control.ToolBar;
11
12
import java.util.HashMap;
13
import java.util.Map;
14
15
import static com.keenwrite.Messages.get;
16
17
/**
18
 * Responsible for wiring all application actions to menus, toolbar buttons,
19
 * and keyboard shortcuts.
20
 */
21
public class ApplicationBars {
22
23
  private static final Map<String, Action> sMap = new HashMap<>( 64 );
24
25
  /**
26
   * Empty constructor.
27
   */
28
  public ApplicationBars() {
29
  }
30
31
  /**
32
   * Creates the main application affordances.
33
   *
34
   * @param actions The {@link ApplicationActions} that map user interface
35
   *                selections to executable code.
36
   * @return An instance of {@link Node} that contains the menu and toolbar.
37
   */
38
  public static Node createMenuBar( final ApplicationActions actions ) {
39
    final var SEPARATOR_ACTION = new SeparatorAction();
40
41
    //@formatter:off
42
    return new MenuBar(
43
    createMenu(
44
      get( "Main.menu.file" ),
45
      addAction( "file.new", e -> actions.file‿new() ),
46
      addAction( "file.open", e -> actions.file‿open() ),
47
      SEPARATOR_ACTION,
48
      addAction( "file.close", e -> actions.file‿close() ),
49
      addAction( "file.close_all", e -> actions.file‿close_all() ),
50
      SEPARATOR_ACTION,
51
      addAction( "file.save", e -> actions.file‿save() ),
52
      addAction( "file.save_as", e -> actions.file‿save_as() ),
53
      addAction( "file.save_all", e -> actions.file‿save_all() ),
54
      SEPARATOR_ACTION,
55
      addAction( "file.export", e -> {} )
56
        .addSubActions(
57
          addAction( "file.export.html_svg", e -> actions.file‿export‿html_svg() ),
58
          addAction( "file.export.html_tex", e -> actions.file‿export‿html_tex() ),
59
          addAction( "file.export.markdown", e -> actions.file‿export‿markdown() )
60
        ),
61
      SEPARATOR_ACTION,
62
      addAction( "file.exit", e -> actions.file‿exit() )
63
    ),
64
    createMenu(
65
      get( "Main.menu.edit" ),
66
      SEPARATOR_ACTION,
67
      addAction( "edit.undo", e -> actions.edit‿undo() ),
68
      addAction( "edit.redo", e -> actions.edit‿redo() ),
69
      SEPARATOR_ACTION,
70
      addAction( "edit.cut", e -> actions.edit‿cut() ),
71
      addAction( "edit.copy", e -> actions.edit‿copy() ),
72
      addAction( "edit.paste", e -> actions.edit‿paste() ),
73
      addAction( "edit.select_all", e -> actions.edit‿select_all() ),
74
      SEPARATOR_ACTION,
75
      addAction( "edit.find", e -> actions.edit‿find() ),
76
      addAction( "edit.find_next", e -> actions.edit‿find_next() ),
77
      addAction( "edit.find_prev", e -> actions.edit‿find_prev() ),
78
      SEPARATOR_ACTION,
79
      addAction( "edit.preferences", e -> actions.edit‿preferences() )
80
    ),
81
    createMenu(
82
      get( "Main.menu.format" ),
83
      addAction( "format.bold", e -> actions.format‿bold() ),
84
      addAction( "format.italic", e -> actions.format‿italic() ),
85
      addAction( "format.superscript", e -> actions.format‿superscript() ),
86
      addAction( "format.subscript", e -> actions.format‿subscript() ),
87
      addAction( "format.strikethrough", e -> actions.format‿strikethrough() )
88
    ),
89
    createMenu(
90
      get( "Main.menu.insert" ),
91
      addAction( "insert.blockquote", e -> actions.insert‿blockquote() ),
92
      addAction( "insert.code", e -> actions.insert‿code() ),
93
      addAction( "insert.fenced_code_block", e -> actions.insert‿fenced_code_block() ),
94
      SEPARATOR_ACTION,
95
      addAction( "insert.link", e -> actions.insert‿link() ),
96
      addAction( "insert.image", e -> actions.insert‿image() ),
97
      SEPARATOR_ACTION,
98
      addAction( "insert.heading_1", e -> actions.insert‿heading_1() ),
99
      addAction( "insert.heading_2", e -> actions.insert‿heading_2() ),
100
      addAction( "insert.heading_3", e -> actions.insert‿heading_3() ),
101
      SEPARATOR_ACTION,
102
      addAction( "insert.unordered_list", e -> actions.insert‿unordered_list() ),
103
      addAction( "insert.ordered_list", e -> actions.insert‿ordered_list() ),
104
      addAction( "insert.horizontal_rule", e -> actions.insert‿horizontal_rule() )
105
    ),
106
    createMenu(
107
      get( "Main.menu.definition" ),
108
      addAction( "definition.insert", e -> actions.definition‿autoinsert() ),
109
      SEPARATOR_ACTION,
110
      addAction( "definition.create", e -> actions.definition‿create() ),
111
      addAction( "definition.rename", e -> actions.definition‿rename() ),
112
      addAction( "definition.delete", e -> actions.definition‿delete() )
113
    ),
114
    createMenu(
115
      get( "Main.menu.view" ),
116
      addAction( "view.refresh", e -> actions.view‿refresh() ),
117
      SEPARATOR_ACTION,
118
      addAction( "view.issues", e -> actions.view‿issues() ),
119
      addAction( "view.preview", e -> actions.view‿preview() ),
120
      SEPARATOR_ACTION,
121
      addAction( "view.toolbar", e -> actions.view‿toolbar() ),
122
      addAction( "view.statusbar", e -> actions.view‿statusbar() ),
123
      addAction( "view.menubar", e -> actions.view‿menubar() )
124
    ),
125
    createMenu(
126
      get( "Main.menu.help" ),
127
      addAction( "help.about", e -> actions.help‿about() )
128
    ) );
129
    //@formatter:on
130
  }
131
132
  public static Node createToolBar() {
133
    final var SEPARATOR_ACTION = new SeparatorAction();
134
135
    return createToolBar(
136
      getAction( "file.new" ),
137
      getAction( "file.open" ),
138
      getAction( "file.save" ),
139
      SEPARATOR_ACTION,
140
      getAction( "edit.undo" ),
141
      getAction( "edit.redo" ),
142
      getAction( "edit.cut" ),
143
      getAction( "edit.copy" ),
144
      getAction( "edit.paste" ),
145
      SEPARATOR_ACTION,
146
      getAction( "format.bold" ),
147
      getAction( "format.italic" ),
148
      getAction( "format.superscript" ),
149
      getAction( "format.subscript" ),
150
      getAction( "insert.blockquote" ),
151
      getAction( "insert.code" ),
152
      getAction( "insert.fenced_code_block" ),
153
      SEPARATOR_ACTION,
154
      getAction( "insert.link" ),
155
      getAction( "insert.image" ),
156
      SEPARATOR_ACTION,
157
      getAction( "insert.heading_1" ),
158
      SEPARATOR_ACTION,
159
      getAction( "insert.unordered_list" ),
160
      getAction( "insert.ordered_list" )
161
    );
162
  }
163
164
  /**
165
   * Adds a new action to the list of actions.
166
   *
167
   * @param key     The name of the action to register in {@link #sMap}.
168
   * @param handler Performs the action upon request.
169
   * @return The newly registered action.
170
   */
171
  private static Action addAction(
172
    final String key, final EventHandler<ActionEvent> handler ) {
173
    assert key != null;
174
    assert handler != null;
175
176
    final var action = Action
177
      .builder()
178
      .setId( key )
179
      .setHandler( handler )
180
      .build();
181
182
    sMap.put( key, action );
183
184
    return action;
185
  }
186
187
  private static Action getAction( final String key ) {
188
    return sMap.get( key );
189
  }
190
191
  public static Menu createMenu(
192
    final String text, final MenuAction... actions ) {
193
    return new Menu( text, null, createMenuItems( actions ) );
194
  }
195
196
  public static MenuItem[] createMenuItems( final MenuAction... actions ) {
197
    final var menuItems = new MenuItem[ actions.length ];
198
199
    for( var i = 0; i < actions.length; i++ ) {
200
      menuItems[ i ] = actions[ i ].createMenuItem();
201
    }
202
203
    return menuItems;
204
  }
205
206
  private static ToolBar createToolBar( final MenuAction... actions ) {
207
    return new ToolBar( createToolBarButtons( actions ) );
208
  }
209
210
  private static Node[] createToolBarButtons( final MenuAction... actions ) {
211
    final var len = actions.length;
212
    final var nodes = new Node[ len ];
213
214
    for( var i = 0; i < len; i++ ) {
215
      nodes[ i ] = actions[ i ].createToolBarNode();
216
    }
217
218
    return nodes;
219
  }
220
}
1221
D src/main/java/com/keenwrite/ui/actions/ApplicationMenuBar.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.ui.actions;
3
4
import javafx.event.ActionEvent;
5
import javafx.event.EventHandler;
6
import javafx.scene.Node;
7
import javafx.scene.control.Menu;
8
import javafx.scene.control.MenuBar;
9
import javafx.scene.control.MenuItem;
10
import javafx.scene.control.ToolBar;
11
import javafx.scene.layout.VBox;
12
13
import java.util.HashMap;
14
import java.util.Map;
15
16
import static com.keenwrite.Messages.get;
17
18
/**
19
 * Responsible for wiring all application actions to menus, toolbar buttons,
20
 * and keyboard shortcuts.
21
 */
22
public class ApplicationMenuBar {
23
24
  private final Map<String, Action> mMap = new HashMap<>( 64 );
25
26
  /**
27
   * Empty constructor.
28
   */
29
  public ApplicationMenuBar() {
30
  }
31
32
  /**
33
   * Creates the main application affordances.
34
   *
35
   * @param actions The {@link ApplicationActions} that map user interface
36
   *                selections to executable code.
37
   * @return An instance of {@link Node} that contains the menu and toolbar.
38
   */
39
  public Node createMenuBar( final ApplicationActions actions ) {
40
    final var SEPARATOR_ACTION = new SeparatorAction();
41
42
    //@formatter:off
43
    final var menuBar = new MenuBar(
44
    createMenu(
45
      get( "Main.menu.file" ),
46
      addAction( "file.new", e -> actions.file‿new() ),
47
      addAction( "file.open", e -> actions.file‿open() ),
48
      SEPARATOR_ACTION,
49
      addAction( "file.close", e -> actions.file‿close() ),
50
      addAction( "file.close_all", e -> actions.file‿close_all() ),
51
      SEPARATOR_ACTION,
52
      addAction( "file.save", e -> actions.file‿save() ),
53
      addAction( "file.save_as", e -> actions.file‿save_as() ),
54
      addAction( "file.save_all", e -> actions.file‿save_all() ),
55
      SEPARATOR_ACTION,
56
      addAction( "file.export", e -> {} )
57
        .addSubActions(
58
          addAction( "file.export.html_svg", e -> actions.file‿export‿html_svg() ),
59
          addAction( "file.export.html_tex", e -> actions.file‿export‿html_tex() ),
60
          addAction( "file.export.markdown", e -> actions.file‿export‿markdown() )
61
        ),
62
      SEPARATOR_ACTION,
63
      addAction( "file.exit", e -> actions.file‿exit() )
64
    ),
65
    createMenu(
66
      get( "Main.menu.edit" ),
67
      SEPARATOR_ACTION,
68
      addAction( "edit.undo", e -> actions.edit‿undo() ),
69
      addAction( "edit.redo", e -> actions.edit‿redo() ),
70
      SEPARATOR_ACTION,
71
      addAction( "edit.cut", e -> actions.edit‿cut() ),
72
      addAction( "edit.copy", e -> actions.edit‿copy() ),
73
      addAction( "edit.paste", e -> actions.edit‿paste() ),
74
      addAction( "edit.select_all", e -> actions.edit‿select_all() ),
75
      SEPARATOR_ACTION,
76
      addAction( "edit.find", e -> actions.edit‿find() ),
77
      addAction( "edit.find_next", e -> actions.edit‿find_next() ),
78
      addAction( "edit.find_prev", e -> actions.edit‿find_prev() ),
79
      SEPARATOR_ACTION,
80
      addAction( "edit.preferences", e -> actions.edit‿preferences() )
81
    ),
82
    createMenu(
83
      get( "Main.menu.format" ),
84
      addAction( "format.bold", e -> actions.format‿bold() ),
85
      addAction( "format.italic", e -> actions.format‿italic() ),
86
      addAction( "format.superscript", e -> actions.format‿superscript() ),
87
      addAction( "format.subscript", e -> actions.format‿subscript() ),
88
      addAction( "format.strikethrough", e -> actions.format‿strikethrough() )
89
    ),
90
    createMenu(
91
      get( "Main.menu.insert" ),
92
      addAction( "insert.blockquote", e -> actions.insert‿blockquote() ),
93
      addAction( "insert.code", e -> actions.insert‿code() ),
94
      addAction( "insert.fenced_code_block", e -> actions.insert‿fenced_code_block() ),
95
      SEPARATOR_ACTION,
96
      addAction( "insert.link", e -> actions.insert‿link() ),
97
      addAction( "insert.image", e -> actions.insert‿image() ),
98
      SEPARATOR_ACTION,
99
      addAction( "insert.heading_1", e -> actions.insert‿heading_1() ),
100
      addAction( "insert.heading_2", e -> actions.insert‿heading_2() ),
101
      addAction( "insert.heading_3", e -> actions.insert‿heading_3() ),
102
      SEPARATOR_ACTION,
103
      addAction( "insert.unordered_list", e -> actions.insert‿unordered_list() ),
104
      addAction( "insert.ordered_list", e -> actions.insert‿ordered_list() ),
105
      addAction( "insert.horizontal_rule", e -> actions.insert‿horizontal_rule() )
106
    ),
107
    createMenu(
108
      get( "Main.menu.definition" ),
109
      addAction( "definition.insert", e -> actions.definition‿autoinsert() ),
110
      SEPARATOR_ACTION,
111
      addAction( "definition.create", e -> actions.definition‿create() ),
112
      addAction( "definition.rename", e -> actions.definition‿rename() ),
113
      addAction( "definition.delete", e -> actions.definition‿delete() )
114
    ),
115
    createMenu(
116
      get( "Main.menu.view" ),
117
      addAction( "view.refresh", e -> actions.view‿refresh() ),
118
      SEPARATOR_ACTION,
119
      addAction( "view.preview", e -> actions.view‿preview() )
120
    ),
121
    createMenu(
122
      get( "Main.menu.help" ),
123
      addAction( "help.about", e -> actions.help‿about() )
124
    ) );
125
    //@formatter:on
126
127
    //@formatter:off
128
    final var toolBar = createToolBar(
129
      getAction( "file.new" ),
130
      getAction( "file.open" ),
131
      getAction( "file.save" ),
132
      SEPARATOR_ACTION,
133
      getAction( "edit.undo" ),
134
      getAction( "edit.redo" ),
135
      getAction( "edit.cut" ),
136
      getAction( "edit.copy" ),
137
      getAction( "edit.paste" ),
138
      SEPARATOR_ACTION,
139
      getAction( "format.bold" ),
140
      getAction( "format.italic" ),
141
      getAction( "format.superscript" ),
142
      getAction( "format.subscript" ),
143
      getAction( "insert.blockquote" ),
144
      getAction( "insert.code" ),
145
      getAction( "insert.fenced_code_block" ),
146
      SEPARATOR_ACTION,
147
      getAction( "insert.link" ),
148
      getAction( "insert.image" ),
149
      SEPARATOR_ACTION,
150
      getAction( "insert.heading_1" ),
151
      SEPARATOR_ACTION,
152
      getAction( "insert.unordered_list" ),
153
      getAction( "insert.ordered_list" )
154
    );
155
    //@formatter:on
156
157
    return new VBox( menuBar, toolBar );
158
  }
159
160
  /**
161
   * Adds a new action to the list of actions.
162
   *
163
   * @param key     The name of the action to register in {@link #mMap}.
164
   * @param handler Performs the action upon request.
165
   * @return The newly registered action.
166
   */
167
  private Action addAction(
168
    final String key, final EventHandler<ActionEvent> handler ) {
169
    assert key != null;
170
    assert handler != null;
171
172
    final var action = Action
173
      .builder()
174
      .setId( key )
175
      .setHandler( handler )
176
      .build();
177
178
    mMap.put( key, action );
179
180
    return action;
181
  }
182
183
  private Action getAction( final String key ) {
184
    return mMap.get( key );
185
  }
186
187
  public static Menu createMenu(
188
    final String text, final MenuAction... actions ) {
189
    return new Menu( text, null, createMenuItems( actions ) );
190
  }
191
192
  public static MenuItem[] createMenuItems( final MenuAction... actions ) {
193
    final var menuItems = new MenuItem[ actions.length ];
194
195
    for( var i = 0; i < actions.length; i++ ) {
196
      menuItems[ i ] = actions[ i ].createMenuItem();
197
    }
198
199
    return menuItems;
200
  }
201
202
  private static ToolBar createToolBar( final MenuAction... actions ) {
203
    return new ToolBar( createToolBarButtons( actions ) );
204
  }
205
206
  private static Node[] createToolBarButtons( final MenuAction... actions ) {
207
    final var len = actions.length;
208
    final var nodes = new Node[ len ];
209
210
    for( var i = 0; i < len; i++ ) {
211
      nodes[ i ] = actions[ i ].createToolBarNode();
212
    }
213
214
    return nodes;
215
  }
216
}
2171
M src/main/java/com/keenwrite/ui/actions/FileChooserCommand.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.ui.actions;
33
D src/main/java/com/keenwrite/ui/actions/MarkdownCommands.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.ui.actions;
3
4
import com.keenwrite.MainPane;
5
import com.keenwrite.editors.markdown.HyperlinkModel;
6
import com.keenwrite.editors.markdown.LinkVisitor;
7
import com.keenwrite.preferences.Workspace;
8
import com.keenwrite.processors.markdown.MarkdownProcessor;
9
import com.keenwrite.ui.dialogs.ImageDialog;
10
import com.keenwrite.ui.dialogs.LinkDialog;
11
import com.vladsch.flexmark.ast.Link;
12
import javafx.scene.control.Dialog;
13
import javafx.stage.Window;
14
import org.fxmisc.richtext.StyleClassedTextArea;
15
16
import java.nio.file.Path;
17
18
/**
19
 * TODO: Integrate the methods into {@link ApplicationActions}
20
 *
21
 * @deprecated Migrate into {@link ApplicationActions}.
22
 */
23
@Deprecated
24
public class MarkdownCommands {
25
26
  private final MainPane mParent;
27
  private final Path mBase;
28
29
  public MarkdownCommands( final MainPane parent, final Path path ) {
30
    mParent = parent;
31
    mBase = path.getParent();
32
  }
33
34
  public void insertLink( final StyleClassedTextArea textArea ) {
35
    insertObject( createLinkDialog( textArea ), textArea );
36
  }
37
38
  public void insertImage( final StyleClassedTextArea textArea ) {
39
    insertObject( createImageDialog(), textArea );
40
  }
41
42
  /**
43
   * Returns one of: selected text, word under cursor, or parsed hyperlink from
44
   * the markdown AST.
45
   *
46
   * @return An instance containing the link URL and display text.
47
   */
48
  private HyperlinkModel getHyperlink( final StyleClassedTextArea textArea ) {
49
    final var selectedText = textArea.getSelectedText();
50
51
    // Get the current paragraph, convert to Markdown nodes.
52
    final var mp = MarkdownProcessor.create( getWorkspace() );
53
    final var p = textArea.getCurrentParagraph();
54
    final var paragraph = textArea.getText( p );
55
    final var node = mp.toNode( paragraph );
56
    final var visitor = new LinkVisitor( textArea.getCaretColumn() );
57
    final var link = visitor.process( node );
58
59
    if( link != null ) {
60
      textArea.selectRange( p, link.getStartOffset(), p, link.getEndOffset() );
61
    }
62
63
    return createHyperlinkModel(
64
      link, selectedText, "https://localhost"
65
    );
66
  }
67
68
  @SuppressWarnings("SameParameterValue")
69
  private HyperlinkModel createHyperlinkModel(
70
    final Link link, final String selection, final String url ) {
71
72
    return link == null
73
      ? new HyperlinkModel( selection, url )
74
      : new HyperlinkModel( link );
75
  }
76
77
  private Dialog<String> createLinkDialog(
78
    final StyleClassedTextArea textArea ) {
79
    return new LinkDialog( getWindow(), getHyperlink( textArea ) );
80
  }
81
82
  private Dialog<String> createImageDialog() {
83
    return new ImageDialog( getWindow(), getParentPath() );
84
  }
85
86
  private void insertObject(
87
    final Dialog<String> dialog, final StyleClassedTextArea textArea ) {
88
    dialog.showAndWait().ifPresent( textArea::replaceSelection );
89
  }
90
91
  private Path getParentPath() {
92
    return mBase;
93
  }
94
95
  private Workspace getWorkspace() {
96
    return mParent.getWorkspace();
97
  }
98
99
  private Window getWindow() {
100
    return mParent.getWindow();
101
  }
102
}
1031
M src/main/java/com/keenwrite/ui/actions/MenuAction.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.ui.actions;
33
M src/main/java/com/keenwrite/ui/actions/SeparatorAction.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.ui.actions;
33
M src/main/java/com/keenwrite/ui/actions/package-info.java
1
/* Copyright 2020 White Magic Software, Ltd.
1
/* Copyright 2020-2021 White Magic Software, Ltd.
22
 *
33
 * All rights reserved.
M src/main/java/com/keenwrite/ui/adapters/DocumentAdapter.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.ui.adapters;
33
44
import org.xhtmlrenderer.event.DocumentListener;
55
6
import static com.keenwrite.StatusBarNotifier.clue;
6
import static com.keenwrite.StatusNotifier.clue;
77
88
/**
M src/main/java/com/keenwrite/ui/adapters/ReplacedElementAdapter.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.ui.adapters;
33
M src/main/java/com/keenwrite/ui/controls/EscapeTextField.java
3434
3535
/**
36
 * Responsible for escaping/unescaping characters for markdown.
36
 * Responsible for escaping/unescaping characters for Markdown.
3737
 */
3838
public class EscapeTextField extends TextField {
M src/main/java/com/keenwrite/ui/controls/SearchBar.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.ui.controls;
33
M src/main/java/com/keenwrite/ui/dialogs/AbstractDialog.java
1
/*
2
 * Copyright 2017 White Magic Software, Ltd.
3
 *
4
 * All rights reserved.
5
 *
6
 * Redistribution and use in source and binary forms, with or without
7
 * modification, are permitted provided that the following conditions are met:
8
 *
9
 *  o Redistributions of source code must retain the above copyright
10
 *    notice, this list of conditions and the following disclaimer.
11
 *
12
 *  o Redistributions in binary form must reproduce the above copyright
13
 *    notice, this list of conditions and the following disclaimer in the
14
 *    documentation and/or other materials provided with the distribution.
15
 *
16
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
17
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
18
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
19
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
20
 * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
21
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
22
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
23
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
24
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
25
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
26
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
27
 */
1
/* Copyright 2017-2021 White Magic Software, Ltd. -- All rights reserved. */
282
package com.keenwrite.ui.dialogs;
293
30
import static com.keenwrite.Messages.get;
314
import com.keenwrite.service.events.impl.ButtonOrderPane;
32
import static javafx.scene.control.ButtonType.CANCEL;
33
import static javafx.scene.control.ButtonType.OK;
345
import javafx.scene.control.Dialog;
6
import javafx.stage.Stage;
357
import javafx.stage.Window;
8
9
import static com.keenwrite.Constants.ICON_DIALOG;
10
import static com.keenwrite.Messages.get;
11
import static javafx.scene.control.ButtonType.CANCEL;
12
import static javafx.scene.control.ButtonType.OK;
3613
3714
/**
...
5835
    initDialogButtons();
5936
    initComponents();
37
    initIcon( (Stage) owner );
6038
  }
6139
...
7149
    setDialogPane( new ButtonOrderPane() );
7250
  }
73
  
51
7452
  /**
7553
   * Set an OK and CANCEL button on the dialog.
7654
   */
7755
  protected void initDialogButtons() {
7856
    getDialogPane().getButtonTypes().addAll( OK, CANCEL );
7957
  }
8058
8159
  /**
82
   * Attaches a setOnCloseRequest to the dialog's [X] button so that the user
60
   * Attaches a close request to the dialog's [X] button so that the user
8361
   * can always close the window, even if there's an error.
8462
   */
8563
  protected final void initCloseAction() {
8664
    final Window window = getDialogPane().getScene().getWindow();
8765
    window.setOnCloseRequest( event -> window.hide() );
66
  }
67
68
  private void initIcon( final Stage owner ) {
69
    owner.getIcons().add( ICON_DIALOG );
8870
  }
8971
}
M src/main/java/com/keenwrite/ui/dialogs/ImageDialog.java
4444
4545
/**
46
 * Dialog to enter a markdown image.
46
 * Dialog to enter a Markdown image.
4747
 */
4848
public class ImageDialog extends AbstractDialog<String> {
M src/main/java/com/keenwrite/ui/dialogs/LinkDialog.java
4444
4545
/**
46
 * Dialog to enter a markdown link.
46
 * Dialog to enter a Markdown link.
4747
 */
4848
public class LinkDialog extends AbstractDialog<String> {
M src/main/java/com/keenwrite/ui/listeners/CaretListener.java
22
33
import com.keenwrite.editors.TextEditor;
4
import com.keenwrite.processors.markdown.Caret;
4
import com.keenwrite.Caret;
55
import javafx.beans.property.ReadOnlyObjectProperty;
66
import javafx.beans.value.ChangeListener;
A src/main/java/com/keenwrite/ui/logging/LogView.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.ui.logging;
3
4
import com.keenwrite.MainApp;
5
import javafx.beans.property.SimpleStringProperty;
6
import javafx.beans.property.StringProperty;
7
import javafx.collections.ObservableList;
8
import javafx.scene.control.*;
9
import javafx.scene.input.ClipboardContent;
10
import javafx.scene.input.KeyCodeCombination;
11
import javafx.stage.Stage;
12
13
import java.time.LocalDateTime;
14
import java.util.TreeSet;
15
import java.util.stream.Collectors;
16
17
import static com.keenwrite.Constants.ICON_DIALOG;
18
import static com.keenwrite.Constants.NEWLINE;
19
import static com.keenwrite.Messages.get;
20
import static java.time.LocalDateTime.now;
21
import static java.time.format.DateTimeFormatter.ofPattern;
22
import static java.util.Arrays.stream;
23
import static javafx.collections.FXCollections.observableArrayList;
24
import static javafx.event.ActionEvent.ACTION;
25
import static javafx.scene.control.Alert.AlertType.INFORMATION;
26
import static javafx.scene.control.ButtonType.OK;
27
import static javafx.scene.control.SelectionMode.MULTIPLE;
28
import static javafx.scene.input.Clipboard.getSystemClipboard;
29
import static javafx.scene.input.KeyCode.C;
30
import static javafx.scene.input.KeyCode.INSERT;
31
import static javafx.scene.input.KeyCombination.CONTROL_ANY;
32
import static javafx.stage.Modality.NONE;
33
34
/**
35
 * Responsible for logging application issues to {@link TableView} entries.
36
 */
37
public class LogView extends Alert {
38
  /**
39
   * Number of error messages to retain in the {@link TableView}, must be
40
   * greater than zero.
41
   */
42
  private static final int CACHE_SIZE = 150;
43
44
  private final ObservableList<LogEntry> mEntries = observableArrayList();
45
  private final TableView<LogEntry> mTable = new TableView<>( mEntries );
46
47
  public LogView() {
48
    super( INFORMATION );
49
    setTitle( get( "App.action.view.issues.text" ) );
50
    initModality( NONE );
51
    initTableView();
52
    setResizable( true );
53
    initButtons();
54
    initIcon();
55
    initActions();
56
  }
57
58
  /**
59
   * Brings the dialog to the foreground, showing it if needed.
60
   */
61
  public void view() {
62
    super.show();
63
    getStage().toFront();
64
  }
65
66
  /**
67
   * Removes all the entries from the list.
68
   */
69
  public void clear() {
70
    mEntries.clear();
71
  }
72
73
  public void log( final String message ) {
74
    log( new LogEntry( message ) );
75
  }
76
77
  public void log( final Throwable error ) {
78
    log( new LogEntry( error ) );
79
  }
80
81
  public void log( final String message, final Throwable trace ) {
82
    log( new LogEntry( message, trace ) );
83
  }
84
85
  private void log( final LogEntry logEntry ) {
86
    mEntries.add( logEntry );
87
88
    while( mEntries.size() > CACHE_SIZE ) {
89
      mEntries.remove( 0 );
90
    }
91
92
    mTable.scrollTo( logEntry );
93
  }
94
95
  private void initTableView() {
96
    final var ctrlC = new KeyCodeCombination( C, CONTROL_ANY );
97
    final var ctrlInsert = new KeyCodeCombination( INSERT, CONTROL_ANY );
98
99
    final var colDate = new TableColumn<LogEntry, String>( "Date" );
100
    final var colMessage = new TableColumn<LogEntry, String>( "Message" );
101
    final var colTrace = new TableColumn<LogEntry, String>( "Trace" );
102
103
    colDate.setCellValueFactory( log -> log.getValue().dateProperty() );
104
    colMessage.setCellValueFactory( log -> log.getValue().messageProperty() );
105
    colTrace.setCellValueFactory( log -> log.getValue().traceProperty() );
106
107
    final var columns = mTable.getColumns();
108
    columns.add( colDate );
109
    columns.add( colMessage );
110
    columns.add( colTrace );
111
112
    mTable.setMaxWidth( Double.MAX_VALUE );
113
    mTable.setPrefWidth( 1024 );
114
    mTable.getSelectionModel().setSelectionMode( MULTIPLE );
115
    mTable.setOnKeyPressed( event -> {
116
      if( ctrlC.match( event ) || ctrlInsert.match( event ) ) {
117
        copyToClipboard( mTable );
118
      }
119
    } );
120
121
    final var pane = getDialogPane();
122
    pane.setContent( mTable );
123
  }
124
125
  private void initButtons() {
126
    final var pane = getDialogPane();
127
    final var CLEAR = new ButtonType( "CLEAR" );
128
    pane.getButtonTypes().add( CLEAR );
129
130
    final var buttonOk = (Button) pane.lookupButton( OK );
131
    final var buttonClear = (Button) pane.lookupButton( CLEAR );
132
133
    buttonOk.setDefaultButton( true );
134
    buttonClear.addEventFilter( ACTION, event -> {
135
      clear();
136
      event.consume();
137
    } );
138
139
    pane.setOnKeyReleased( t -> {
140
      switch( t.getCode() ) {
141
        case ENTER, ESCAPE -> buttonOk.fire();
142
      }
143
    } );
144
  }
145
146
  private void initIcon() {
147
    final var stage = getStage();
148
    stage.getIcons().add( ICON_DIALOG );
149
  }
150
151
  private void initActions() {
152
    final var stage = getStage();
153
    stage.setOnCloseRequest( event -> stage.hide() );
154
  }
155
156
  private Stage getStage() {
157
    return (Stage) getDialogPane().getScene().getWindow();
158
  }
159
160
  private static final class LogEntry {
161
    private final StringProperty mDate;
162
    private final StringProperty mMessage;
163
    private final StringProperty mTrace;
164
165
    /**
166
     * Constructs a new {@link LogEntry} for the current time, and having
167
     * no associated stack trace.
168
     *
169
     * @param message The error message.
170
     */
171
    public LogEntry( final String message ) {
172
      this( message, null );
173
    }
174
175
    /**
176
     * Constructs a new {@link LogEntry} for the current time, and using
177
     * the given error's message.
178
     *
179
     * @param error The stack trace, must not be {@code null}.
180
     */
181
    public LogEntry( final Throwable error ) {
182
      this( error.getMessage(), error );
183
    }
184
185
    /**
186
     * Constructs a new {@link LogEntry} with the current date and time.
187
     *
188
     * @param message The error message.
189
     * @param trace   The stack trace associated with the message, may be
190
     *                {@code null}.
191
     */
192
    public LogEntry( final String message, final Throwable trace ) {
193
      mDate = new SimpleStringProperty( toString( now() ) );
194
      mMessage = new SimpleStringProperty( message );
195
      mTrace = new SimpleStringProperty( toString( trace ) );
196
    }
197
198
    private StringProperty messageProperty() {
199
      return mMessage;
200
    }
201
202
    private StringProperty dateProperty() {
203
      return mDate;
204
    }
205
206
    private StringProperty traceProperty() {
207
      return mTrace;
208
    }
209
210
    private String toString( final LocalDateTime date ) {
211
      return date.format( ofPattern( "d MMM u HH:mm:ss" ) );
212
    }
213
214
    private String toString( final Throwable trace ) {
215
      final var sb = new StringBuilder( 256 );
216
217
      if( trace != null ) {
218
        sb.append( trace.getMessage().trim() ).append( NEWLINE );
219
        stream( trace.getStackTrace() )
220
          .takeWhile( LogView::filter )
221
          .limit( 10 )
222
          .collect( Collectors.toList() )
223
          .forEach( e -> sb.append( e.toString() ).append( NEWLINE ) );
224
      }
225
226
      return sb.toString();
227
    }
228
  }
229
230
  private static boolean filter( final StackTraceElement e ) {
231
    final var clazz = e.getClassName();
232
    return clazz.startsWith( MainApp.class.getPackageName() ) ||
233
      clazz.startsWith( "org.renjin" );
234
  }
235
236
  /**
237
   * Copies the contents of the selected rows into the clipboard; code is from
238
   * <a href="https://stackoverflow.com/a/48126059/59087">StackOverflow</a>.
239
   *
240
   * @param table The {@link TableView} having selected rows to copy.
241
   */
242
  public void copyToClipboard( final TableView<?> table ) {
243
    final var sb = new StringBuilder();
244
    final var rows = new TreeSet<Integer>();
245
    boolean firstRow = true;
246
247
    for( final var position : table.getSelectionModel().getSelectedCells() ) {
248
      rows.add( position.getRow() );
249
    }
250
251
    for( final var row : rows ) {
252
      if( !firstRow ) {
253
        sb.append( '\n' );
254
      }
255
256
      firstRow = false;
257
      boolean firstCol = true;
258
259
      for( final var column : table.getColumns() ) {
260
        if( !firstCol ) {
261
          sb.append( '\t' );
262
        }
263
264
        firstCol = false;
265
        final var data = column.getCellData( row );
266
        sb.append( data == null ? "" : data.toString() );
267
      }
268
    }
269
270
    final var contents = new ClipboardContent();
271
    contents.putString( sb.toString() );
272
    getSystemClipboard().setContent( contents );
273
  }
274
}
1275
M src/main/java/com/keenwrite/util/BoundedCache.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.util;
33
M src/main/java/com/keenwrite/util/CyclicIterator.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.util;
33
M src/main/java/com/keenwrite/util/FontLoader.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.util;
33
...
1313
1414
import static com.keenwrite.Constants.FONT_DIRECTORY;
15
import static com.keenwrite.StatusBarNotifier.clue;
15
import static com.keenwrite.StatusNotifier.clue;
1616
import static com.keenwrite.util.ProtocolScheme.valueFrom;
1717
import static com.keenwrite.util.ResourceWalker.GLOB_FONTS;
...
3838
   * </p>
3939
   */
40
  @SuppressWarnings("unchecked")
40
  @SuppressWarnings( "unchecked" )
4141
  public static void initFonts() {
42
    final var ge = getLocalGraphicsEnvironment();
43
4442
    try {
43
      final var ge = getLocalGraphicsEnvironment();
4544
      walk(
46
          FONT_DIRECTORY, GLOB_FONTS, path -> {
47
            final var uri = path.toUri();
48
            final var filename = path.toString();
45
        FONT_DIRECTORY, GLOB_FONTS, path -> {
46
          final var uri = path.toUri();
47
          final var filename = path.toString();
4948
50
            try( final var is = openFont( uri, filename ) ) {
51
              final var font = createFont( TRUETYPE_FONT, is );
52
              final var attributes =
53
                  (Map<TextAttribute, Integer>) font.getAttributes();
49
          try( final var is = openFont( uri, filename ) ) {
50
            final var font = createFont( TRUETYPE_FONT, is );
51
            final var attributes =
52
              (Map<TextAttribute, Integer>) font.getAttributes();
5453
55
              attributes.put( LIGATURES, LIGATURES_ON );
56
              attributes.put( KERNING, KERNING_ON );
57
              ge.registerFont( font.deriveFont( attributes ) );
58
            } catch( final Exception ex ) {
59
              clue( ex );
60
            }
54
            attributes.put( LIGATURES, LIGATURES_ON );
55
            attributes.put( KERNING, KERNING_ON );
56
            ge.registerFont( font.deriveFont( attributes ) );
57
          } catch( final Exception ex ) {
58
            clue( ex );
6159
          }
60
        }
6261
      );
6362
    } catch( final Exception ex ) {
...
7675
   */
7776
  private static InputStream openFont( final URI uri, final String filename )
78
      throws IOException {
77
    throws IOException {
7978
    return valueFrom( uri ).isJar()
80
        ? FontLoader.class.getResourceAsStream( filename )
81
        : new FileInputStream( filename );
79
      ? FontLoader.class.getResourceAsStream( filename )
80
      : new FileInputStream( filename );
8281
  }
8382
}
M src/main/java/com/keenwrite/util/GenericBuilder.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.util;
33
M src/main/java/com/keenwrite/util/ProtocolScheme.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.util;
33
M src/main/java/com/keenwrite/util/ResourceWalker.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.util;
33
44
import java.io.IOException;
5
import java.net.URI;
65
import java.net.URISyntaxException;
6
import java.nio.file.FileSystem;
77
import java.nio.file.Files;
88
import java.nio.file.Path;
99
import java.nio.file.Paths;
1010
import java.util.function.Consumer;
1111
12
import static com.keenwrite.util.ProtocolScheme.JAR;
13
import static com.keenwrite.util.ProtocolScheme.valueFrom;
1214
import static java.nio.file.FileSystems.getDefault;
1315
import static java.nio.file.FileSystems.newFileSystem;
...
3133
   */
3234
  public static void walk(
33
      final String directory, final String glob, final Consumer<Path> c )
34
      throws URISyntaxException, IOException {
35
    final String directory, final String glob, final Consumer<Path> c )
36
    throws URISyntaxException, IOException {
3537
    final var resource = ResourceWalker.class.getResource( directory );
3638
    final var matcher = getDefault().getPathMatcher( "glob:" + glob );
3739
3840
    if( resource != null ) {
3941
      final var uri = resource.toURI();
40
      final var jar = uri.getScheme().equals( "jar" );
41
      final var path = jar ? toFileSystem( uri, directory ) : Paths.get( uri );
42
      final Path path;
43
      FileSystem fs = null;
44
45
      if( valueFrom( uri ) == JAR ) {
46
        fs = newFileSystem( uri, emptyMap() );
47
        path = fs.getPath( directory );
48
      }
49
      else {
50
        path = Paths.get( uri );
51
      }
4252
4353
      try( final var walk = Files.walk( path, 10 ) ) {
4454
        for( final var it = walk.iterator(); it.hasNext(); ) {
4555
          final Path p = it.next();
4656
          if( matcher.matches( p ) ) {
4757
            c.accept( p );
4858
          }
4959
        }
60
      } finally {
61
        if( fs != null ) { fs.close(); }
5062
      }
51
    }
52
  }
53
54
  private static Path toFileSystem( final URI uri, final String directory )
55
      throws IOException {
56
    try( final var fs = newFileSystem( uri, emptyMap() ) ) {
57
      return fs.getPath( directory );
5863
    }
5964
  }
M src/main/resources/com/keenwrite/editor/markdown.css
44
  -fx-font-family: 'Noto Sans';
55
  -fx-font-size: 11pt;
6
  -fx-padding: 1em;
6
  -fx-padding: .25em;
77
}
88
M src/main/resources/com/keenwrite/messages.properties
3333
Main.status.export.success=Saved as {0}
3434
35
Main.status.error.parse={0} (near ${Main.status.text.offset} {1})
36
Main.status.error.def.blank=Move the caret to a word before inserting a definition
37
Main.status.error.def.empty=Create a definition before inserting a definition
38
Main.status.error.def.missing=No definition value found for ''{0}''
39
Main.status.error.r=Error with [{0}...]: {1}
40
Main.status.error.file.missing=Not found: {0}
41
42
Main.status.error.messages.recursion=Lookup depth exceeded, check for loops in ''{0}''
43
Main.status.error.messages.syntax=Missing ''}'' in ''{0}''
44
45
Main.status.error.undo=Cannot undo; beginning of undo history reached
46
Main.status.error.redo=Cannot redo; end of redo history reached
47
48
Main.status.image.request.init=Initializing HTTP request
49
Main.status.image.request.fetch=Requesting content type from {0}
50
Main.status.image.request.success=Detected content type ''{0}''
51
52
# ########################################################################
53
# Search Bar
54
# ########################################################################
55
56
Main.search.stop.tooltip=Close search bar
57
Main.search.stop.icon=CLOSE
58
Main.search.next.tooltip=Find next match
59
Main.search.next.icon=CHEVRON_DOWN
60
Main.search.prev.tooltip=Find previous match
61
Main.search.prev.icon=CHEVRON_UP
62
Main.search.find.tooltip=Search document for text
63
Main.search.find.icon=SEARCH
64
Main.search.match.none=No matches
65
Main.search.match.some={0} of {1} matches
66
67
# ########################################################################
68
# Workspace preferences
69
# ########################################################################
70
71
workspace.r=R
72
workspace.r.script=Startup Script
73
workspace.r.script.desc=Script runs prior to executing R statements within the document.
74
workspace.r.dir=Working Directory
75
workspace.r.dir.desc=Value assigned to {0}application.r.working.directory{1} and usable in the startup script.
76
workspace.r.dir.title=Directory
77
workspace.r.delimiter.began=Delimiter Prefix
78
workspace.r.delimiter.began.desc=Prefix of expression that wraps inserted definitions.
79
workspace.r.delimiter.began.title=Opening
80
workspace.r.delimiter.ended=Delimiter Suffix
81
workspace.r.delimiter.ended.desc=Suffix of expression that wraps inserted definitions.
82
workspace.r.delimiter.ended.title=Closing
83
84
workspace.images=Images
85
workspace.images.dir=Relative Directory
86
workspace.images.dir.desc=Path prepended to embedded images referenced using local file paths.
87
workspace.images.dir.title=Directory
88
workspace.images.order=Extensions
89
workspace.images.order.desc=Preferred order of image file types to embed, separated by spaces.
90
workspace.images.order.title=Extensions
91
92
workspace.definition=Definition
93
workspace.definition.path=File name
94
workspace.definition.path.desc=Absolute path to interpolated string definition.
95
workspace.definition.path.title=Path
96
workspace.definition.delimiter.began=Delimiter Prefix
97
workspace.definition.delimiter.began.desc=Indicates when a definition key is starting.
98
workspace.definition.delimiter.began.title=Opening
99
workspace.definition.delimiter.ended=Delimiter Suffix
100
workspace.definition.delimiter.ended.desc=Indicates when a definition key is ending.
101
workspace.definition.delimiter.ended.title=Closing
102
103
workspace.ui.font=Fonts
104
workspace.ui.font.editor.size=Editor Font Size
105
workspace.ui.font.editor.size.desc=Text editor font size.
106
workspace.ui.font.editor.size.title=Points
107
workspace.ui.font.preview.size=Preview Font Size
108
workspace.ui.font.preview.size.desc=Preview pane font size.
109
workspace.ui.font.preview.size.title=Points
110
workspace.ui.font.locale=Locale
111
workspace.ui.font.locale.desc=Character set for editing and previewing.
112
workspace.ui.font.locale.title=Language
113
114
# ########################################################################
115
# Definition Pane and its Tree View
116
# ########################################################################
117
118
Definition.menu.add.default=Undefined
119
120
# ########################################################################
121
# Definition Pane
122
# ########################################################################
123
124
Pane.definition.node.root.title=Definitions
125
126
# ########################################################################
127
# Failure messages with respect to YAML files.
128
# ########################################################################
129
130
yaml.error.open=Could not open YAML file (ensure non-empty file).
131
yaml.error.unresolvable=Too much indirection for: ''{0}'' = ''{1}''.
132
yaml.error.missing=Empty definition value for key ''{0}''.
133
yaml.error.tree.form=Unassigned definition near ''{0}''.
134
135
# ########################################################################
136
# Text Resource
137
# ########################################################################
138
139
TextResource.load.error.unsaved=The file ''{0}'' is unsaved or does not exist.
140
TextResource.load.error.permissions=The file ''{0}'' must be readable and writable.
141
142
# ########################################################################
143
# Text Resources
144
# ########################################################################
145
146
TextResource.saveFailed.message=Failed to save ''{0}''.\n\nReason: {1}
147
TextResource.saveFailed.title=Save
148
149
# ########################################################################
150
# File Open
151
# ########################################################################
152
153
Dialog.file.choose.open.title=Open File
154
Dialog.file.choose.save.title=Save File
155
Dialog.file.choose.export.title=Export File
156
157
Dialog.file.choose.filter.title.source=Source Files
158
Dialog.file.choose.filter.title.definition=Definition Files
159
Dialog.file.choose.filter.title.xml=XML Files
160
Dialog.file.choose.filter.title.all=All Files
161
162
# ########################################################################
163
# Browse File
164
# ########################################################################
165
166
BrowseFileButton.chooser.title=Browse for local file
167
BrowseFileButton.chooser.allFilesFilter=All Files
168
BrowseFileButton.tooltip=${BrowseFileButton.chooser.title}
169
170
# ########################################################################
171
# Alert Dialog
172
# ########################################################################
173
174
Alert.file.close.title=Close
175
Alert.file.close.text=Save changes to {0}?
176
177
# ########################################################################
178
# Image Dialog
179
# ########################################################################
180
181
Dialog.image.title=Image
182
Dialog.image.chooser.imagesFilter=Images
183
Dialog.image.previewLabel.text=Markdown Preview\:
184
Dialog.image.textLabel.text=Alternate Text\:
185
Dialog.image.titleLabel.text=Title (tooltip)\:
186
Dialog.image.urlLabel.text=Image URL\:
187
188
# ########################################################################
189
# Hyperlink Dialog
190
# ########################################################################
191
192
Dialog.link.title=Link
193
Dialog.link.previewLabel.text=Markdown Preview\:
194
Dialog.link.textLabel.text=Link Text\:
195
Dialog.link.titleLabel.text=Title (tooltip)\:
196
Dialog.link.urlLabel.text=Link URL\:
197
198
# ########################################################################
199
# About Dialog
200
# ########################################################################
201
202
Dialog.about.title=About {0}
203
Dialog.about.header={0}
204
Dialog.about.content=Copyright 2020 White Magic Software, Ltd.\n\nBased on Markdown Writer FX by Karl Tauber
205
206
# ########################################################################
207
# Application Actions
208
# ########################################################################
209
210
App.action.file.new.description=Create a new file
211
App.action.file.new.accelerator=Shortcut+N
212
App.action.file.new.icon=FILE_ALT
213
App.action.file.new.text=_New
214
215
App.action.file.open.description=Open a new file
216
App.action.file.open.accelerator=Shortcut+O
217
App.action.file.open.text=_Open...
218
App.action.file.open.icon=FOLDER_OPEN_ALT
219
220
App.action.file.close.description=Close the current document
221
App.action.file.close.accelerator=Shortcut+W
222
App.action.file.close.text=_Close
223
224
App.action.file.close_all.description=Close all open documents
225
App.action.file.close_all.accelerator=Ctrl+F4
226
App.action.file.close_all.text=Close All
227
228
App.action.file.save.description=Save the document
229
App.action.file.save.accelerator=Shortcut+S
230
App.action.file.save.text=_Save
231
App.action.file.save.icon=FLOPPY_ALT
232
233
App.action.file.save_as.description=Rename the current document
234
App.action.file.save_as.text=Save _As
235
236
App.action.file.save_all.description=Save all open documents
237
App.action.file.save_all.accelerator=Shortcut+Shift+S
238
App.action.file.save_all.text=Save A_ll
239
240
App.action.file.export.html_svg.description=Export the current document as HTML + SVG
241
App.action.file.export.text=_Export As
242
App.action.file.export.html_svg.text=HTML and S_VG
243
244
App.action.file.export.html_tex.description=Export the current document as HTML + TeX
245
App.action.file.export.html_tex.text=HTML and _TeX
246
247
App.action.file.export.markdown.description=Export the current document as Markdown
248
App.action.file.export.markdown.text=Markdown
249
250
App.action.file.exit.description=Quit the application
251
App.action.file.exit.text=E_xit
252
253
254
App.action.edit.undo.description=Undo the previous edit
255
App.action.edit.undo.accelerator=Shortcut+Z
256
App.action.edit.undo.text=_Undo
257
App.action.edit.undo.icon=UNDO
258
259
App.action.edit.redo.description=Redo the previous edit
260
App.action.edit.redo.accelerator=Shortcut+Y
261
App.action.edit.redo.text=_Redo
262
App.action.edit.redo.icon=REPEAT
263
264
App.action.edit.cut.description=Delete the selected text or line
265
App.action.edit.cut.accelerator=Shortcut+X
266
App.action.edit.cut.text=Cu_t
267
App.action.edit.cut.icon=CUT
268
269
App.action.edit.copy.description=Copy the selected text
270
App.action.edit.copy.accelerator=Shortcut+C
271
App.action.edit.copy.text=_Copy
272
App.action.edit.copy.icon=COPY
273
274
App.action.edit.paste.description=Paste from the clipboard
275
App.action.edit.paste.accelerator=Shortcut+V
276
App.action.edit.paste.text=_Paste
277
App.action.edit.paste.icon=PASTE
278
279
App.action.edit.select_all.description=Highlight the current document text
280
App.action.edit.select_all.accelerator=Shortcut+A
281
App.action.edit.select_all.text=Select _All
282
283
App.action.edit.find.description=Search for text in the document
284
App.action.edit.find.accelerator=Shortcut+F
285
App.action.edit.find.text=_Find
286
App.action.edit.find.icon=SEARCH
287
288
App.action.edit.find_next.description=Find next occurrence
289
App.action.edit.find_next.accelerator=F3
290
App.action.edit.find_next.text=Find _Next
291
292
App.action.edit.find_prev.description=Find previous occurrence
293
App.action.edit.find_prev.accelerator=Shift+F3
294
App.action.edit.find_prev.text=Find _Prev
295
296
App.action.edit.preferences.description=Edit user preferences
297
App.action.edit.preferences.accelerator=Ctrl+Alt+S
298
App.action.edit.preferences.text=_Preferences
299
300
301
App.action.format.bold.description=Insert strong text
302
App.action.format.bold.accelerator=Shortcut+B
303
App.action.format.bold.text=_Bold
304
App.action.format.bold.icon=BOLD
305
306
App.action.format.italic.description=Insert text emphasis
307
App.action.format.italic.accelerator=Shortcut+I
308
App.action.format.italic.text=_Italic
309
App.action.format.italic.icon=ITALIC
310
311
App.action.format.superscript.description=Insert superscript text
312
App.action.format.superscript.accelerator=Shortcut+[
313
App.action.format.superscript.text=Su_perscript
314
App.action.format.superscript.icon=SUPERSCRIPT
315
316
App.action.format.subscript.description=Insert subscript text
317
App.action.format.subscript.accelerator=Shortcut+]
318
App.action.format.subscript.text=Su_bscript
319
App.action.format.subscript.icon=SUBSCRIPT
320
321
App.action.format.strikethrough.description=Insert struck text
322
App.action.format.strikethrough.accelerator=Shortcut+T
323
App.action.format.strikethrough.text=Stri_kethrough
324
App.action.format.strikethrough.icon=STRIKETHROUGH
325
326
327
App.action.insert.blockquote.description=Insert blockquote
328
App.action.insert.blockquote.accelerator=Ctrl+Q
329
App.action.insert.blockquote.text=_Blockquote
330
App.action.insert.blockquote.icon=QUOTE_LEFT
331
332
App.action.insert.code.description=Insert inline code
333
App.action.insert.code.accelerator=Shortcut+K
334
App.action.insert.code.text=Inline _Code
335
App.action.insert.code.icon=CODE
336
337
App.action.insert.fenced_code_block.description=Insert code block
338
App.action.insert.fenced_code_block.accelerator=Shortcut+Shift+K
339
App.action.insert.fenced_code_block.text=_Fenced Code Block
340
App.action.insert.fenced_code_block.prompt.text=Enter code here
341
App.action.insert.fenced_code_block.icon=FILE_CODE_ALT
342
343
App.action.insert.link.description=Insert hyperlink
344
App.action.insert.link.accelerator=Shortcut+L
345
App.action.insert.link.text=_Link...
346
App.action.insert.link.icon=LINK
347
348
App.action.insert.image.description=Insert image
349
App.action.insert.image.accelerator=Shortcut+G
350
App.action.insert.image.text=_Image...
351
App.action.insert.image.icon=PICTURE_ALT
352
353
App.action.insert.heading.description=Insert heading level
354
App.action.insert.heading.accelerator=Shortcut+
355
App.action.insert.heading.icon=HEADER
356
357
App.action.insert.heading_1.description=${App.action.insert.heading.description} 1
358
App.action.insert.heading_1.accelerator=${App.action.insert.heading.accelerator}1
359
App.action.insert.heading_1.text=Heading _1
360
App.action.insert.heading_1.icon=${App.action.insert.heading.icon}
361
362
App.action.insert.heading_2.description=${App.action.insert.heading.description} 2
363
App.action.insert.heading_2.accelerator=${App.action.insert.heading.accelerator}2
364
App.action.insert.heading_2.text=Heading _2
365
App.action.insert.heading_2.icon=${App.action.insert.heading.icon}
366
367
App.action.insert.heading_3.description=${App.action.insert.heading.description} 3
368
App.action.insert.heading_3.accelerator=${App.action.insert.heading.accelerator}3
369
App.action.insert.heading_3.text=Heading _3
370
App.action.insert.heading_3.icon=${App.action.insert.heading.icon}
371
372
App.action.insert.unordered_list.description=Insert bulleted list
373
App.action.insert.unordered_list.accelerator=Shortcut+U
374
App.action.insert.unordered_list.text=_Unordered List
375
App.action.insert.unordered_list.icon=LIST_UL
376
377
App.action.insert.ordered_list.description=Insert enumerated list
378
App.action.insert.ordered_list.accelerator=Shortcut+Shift+O
379
App.action.insert.ordered_list.text=_Ordered List
380
App.action.insert.ordered_list.icon=LIST_OL
381
382
App.action.insert.horizontal_rule.description=Insert horizontal rule
383
App.action.insert.horizontal_rule.accelerator=Shortcut+H
384
App.action.insert.horizontal_rule.text=_Horizontal Rule
385
App.action.insert.horizontal_rule.icon=LIST_OL
386
387
388
App.action.definition.create.description=Create a new variable definition
389
App.action.definition.create.text=_Create
390
App.action.definition.create.icon=TREE
391
App.action.definition.create.tooltip=Add new item (Insert)
392
393
App.action.definition.rename.description=Rename the selected variable definition
394
App.action.definition.rename.text=_Rename
395
App.action.definition.rename.icon=EDIT
396
App.action.definition.rename.tooltip=Rename selected item (F2)
397
398
App.action.definition.delete.description=Delete the selected variable definitions
399
App.action.definition.delete.text=_Delete
400
App.action.definition.delete.icon=TRASH
401
App.action.definition.delete.tooltip=Delete selected items (Delete)
402
403
App.action.definition.insert.description=Insert a definition
404
App.action.definition.insert.accelerator=Ctrl+Space
405
App.action.definition.insert.text=_Insert
406
App.action.definition.insert.icon=STAR
407
408
409
App.action.view.refresh.description=Clear all caches
410
App.action.view.refresh.accelerator=F5
411
App.action.view.refresh.text=Refresh
412
413
App.action.view.preview.description=Open document preview
414
App.action.view.preview.accelerator=F7
415
App.action.view.preview.text=Preview
416
417
App.action.view.outline.description=Open document outline
418
App.action.view.outline.accelerator=F8
419
App.action.view.outline.text=Outline
420
35
Main.status.error.bootstrap.eval=Note: Bootstrap definition of ''{0}'' not found
36
37
Main.status.error.parse={0} (near ${Main.status.text.offset} {1})
38
Main.status.error.def.blank=Move the caret to a word before inserting a definition
39
Main.status.error.def.empty=Create a definition before inserting a definition
40
Main.status.error.def.missing=No definition value found for ''{0}''
41
Main.status.error.r=Error with [{0}...]: {1}
42
Main.status.error.file.missing=Not found: {0}
43
44
Main.status.error.messages.recursion=Lookup depth exceeded, check for loops in ''{0}''
45
Main.status.error.messages.syntax=Missing ''}'' in ''{0}''
46
47
Main.status.error.undo=Cannot undo; beginning of undo history reached
48
Main.status.error.redo=Cannot redo; end of redo history reached
49
50
Main.status.image.request.init=Initializing HTTP request
51
Main.status.image.request.fetch=Requesting content type from {0}
52
Main.status.image.request.success=Detected content type ''{0}''
53
54
Main.status.font.search.missing=No font name starting with ''{0}'' was found
55
56
# ########################################################################
57
# Search Bar
58
# ########################################################################
59
60
Main.search.stop.tooltip=Close search bar
61
Main.search.stop.icon=CLOSE
62
Main.search.next.tooltip=Find next match
63
Main.search.next.icon=CHEVRON_DOWN
64
Main.search.prev.tooltip=Find previous match
65
Main.search.prev.icon=CHEVRON_UP
66
Main.search.find.tooltip=Search document for text
67
Main.search.find.icon=SEARCH
68
Main.search.match.none=No matches
69
Main.search.match.some={0} of {1} matches
70
71
# ########################################################################
72
# Workspace preferences
73
# ########################################################################
74
75
workspace.r=R
76
workspace.r.script=Startup Script
77
workspace.r.script.desc=Script runs prior to executing R statements within the document.
78
workspace.r.dir=Working Directory
79
workspace.r.dir.desc=Value assigned to {0}application.r.working.directory{1} and usable in the startup script.
80
workspace.r.dir.title=Directory
81
workspace.r.delimiter.began=Delimiter Prefix
82
workspace.r.delimiter.began.desc=Prefix of expression that wraps inserted definitions.
83
workspace.r.delimiter.began.title=Opening
84
workspace.r.delimiter.ended=Delimiter Suffix
85
workspace.r.delimiter.ended.desc=Suffix of expression that wraps inserted definitions.
86
workspace.r.delimiter.ended.title=Closing
87
88
workspace.images=Images
89
workspace.images.dir=Relative Directory
90
workspace.images.dir.desc=Path prepended to embedded images referenced using local file paths.
91
workspace.images.dir.title=Directory
92
workspace.images.order=Extensions
93
workspace.images.order.desc=Preferred order of image file types to embed, separated by spaces.
94
workspace.images.order.title=Extensions
95
96
workspace.definition=Definition
97
workspace.definition.path=File name
98
workspace.definition.path.desc=Absolute path to interpolated string definition.
99
workspace.definition.path.title=Path
100
workspace.definition.delimiter.began=Delimiter Prefix
101
workspace.definition.delimiter.began.desc=Indicates when a definition key is starting.
102
workspace.definition.delimiter.began.title=Opening
103
workspace.definition.delimiter.ended=Delimiter Suffix
104
workspace.definition.delimiter.ended.desc=Indicates when a definition key is ending.
105
workspace.definition.delimiter.ended.title=Closing
106
107
workspace.ui.font=Fonts
108
workspace.ui.font.editor=Editor Font
109
workspace.ui.font.editor.name=Name
110
workspace.ui.font.editor.name.desc=Text editor font name (sans-serif font recommended).
111
workspace.ui.font.editor.name.title=Family
112
workspace.ui.font.editor.size=Size
113
workspace.ui.font.editor.size.desc=Font size.
114
workspace.ui.font.editor.size.title=Points
115
workspace.ui.font.preview=Preview Font
116
workspace.ui.font.preview.name=Name
117
workspace.ui.font.preview.name.desc=Preview pane font name (must support ligatures, serif font recommended).
118
workspace.ui.font.preview.name.title=Family
119
workspace.ui.font.preview.size=Size
120
workspace.ui.font.preview.size.desc=Font size.
121
workspace.ui.font.preview.size.title=Points
122
workspace.ui.font.preview.mono.name=Name
123
workspace.ui.font.preview.mono.name.desc=Monospace font name.
124
workspace.ui.font.preview.mono.name.title=Family
125
workspace.ui.font.preview.mono.size=Size
126
workspace.ui.font.preview.mono.size.desc=Monospace font size.
127
workspace.ui.font.preview.mono.size.title=Points
128
129
workspace.language=Language
130
workspace.language.locale=Internationalization
131
workspace.language.locale.desc=Language for application and HTML export.
132
workspace.language.locale.title=Locale
133
134
# ########################################################################
135
# Definition Pane and its Tree View
136
# ########################################################################
137
138
Definition.menu.add.default=Undefined
139
140
# ########################################################################
141
# Definition Pane
142
# ########################################################################
143
144
Pane.definition.node.root.title=Definitions
145
146
# ########################################################################
147
# Failure messages with respect to YAML files.
148
# ########################################################################
149
150
yaml.error.open=Could not open YAML file (ensure non-empty file).
151
yaml.error.unresolvable=Too much indirection for: ''{0}'' = ''{1}''.
152
yaml.error.missing=Empty definition value for key ''{0}''.
153
yaml.error.tree.form=Unassigned definition near ''{0}''.
154
155
# ########################################################################
156
# Text Resource
157
# ########################################################################
158
159
TextResource.load.error.unsaved=The file ''{0}'' is unsaved or does not exist.
160
TextResource.load.error.permissions=The file ''{0}'' must be readable and writable.
161
162
# ########################################################################
163
# Text Resources
164
# ########################################################################
165
166
TextResource.saveFailed.message=Failed to save ''{0}''.\n\nReason: {1}
167
TextResource.saveFailed.title=Save
168
169
# ########################################################################
170
# File Open
171
# ########################################################################
172
173
Dialog.file.choose.open.title=Open File
174
Dialog.file.choose.save.title=Save File
175
Dialog.file.choose.export.title=Export File
176
177
Dialog.file.choose.filter.title.source=Source Files
178
Dialog.file.choose.filter.title.definition=Definition Files
179
Dialog.file.choose.filter.title.xml=XML Files
180
Dialog.file.choose.filter.title.all=All Files
181
182
# ########################################################################
183
# Browse File
184
# ########################################################################
185
186
BrowseFileButton.chooser.title=Browse for local file
187
BrowseFileButton.chooser.allFilesFilter=All Files
188
BrowseFileButton.tooltip=${BrowseFileButton.chooser.title}
189
190
# ########################################################################
191
# Alert Dialog
192
# ########################################################################
193
194
Alert.file.close.title=Close
195
Alert.file.close.text=Save changes to {0}?
196
197
# ########################################################################
198
# Image Dialog
199
# ########################################################################
200
201
Dialog.image.title=Image
202
Dialog.image.chooser.imagesFilter=Images
203
Dialog.image.previewLabel.text=Markdown Preview\:
204
Dialog.image.textLabel.text=Alternate Text\:
205
Dialog.image.titleLabel.text=Title (tooltip)\:
206
Dialog.image.urlLabel.text=Image URL\:
207
208
# ########################################################################
209
# Hyperlink Dialog
210
# ########################################################################
211
212
Dialog.link.title=Link
213
Dialog.link.previewLabel.text=Markdown Preview\:
214
Dialog.link.textLabel.text=Link Text\:
215
Dialog.link.titleLabel.text=Title (tooltip)\:
216
Dialog.link.urlLabel.text=Link URL\:
217
218
# ########################################################################
219
# About Dialog
220
# ########################################################################
221
222
Dialog.about.title=About {0}
223
Dialog.about.header={0}
224
Dialog.about.content=Copyright 2020 White Magic Software, Ltd.\n\nBased on Markdown Writer FX by Karl Tauber
225
226
# ########################################################################
227
# Application Actions
228
# ########################################################################
229
230
App.action.file.new.description=Create a new file
231
App.action.file.new.accelerator=Shortcut+N
232
App.action.file.new.icon=FILE_ALT
233
App.action.file.new.text=_New
234
235
App.action.file.open.description=Open a new file
236
App.action.file.open.accelerator=Shortcut+O
237
App.action.file.open.text=_Open...
238
App.action.file.open.icon=FOLDER_OPEN_ALT
239
240
App.action.file.close.description=Close the current document
241
App.action.file.close.accelerator=Shortcut+W
242
App.action.file.close.text=_Close
243
244
App.action.file.close_all.description=Close all open documents
245
App.action.file.close_all.accelerator=Ctrl+F4
246
App.action.file.close_all.text=Close All
247
248
App.action.file.save.description=Save the document
249
App.action.file.save.accelerator=Shortcut+S
250
App.action.file.save.text=_Save
251
App.action.file.save.icon=FLOPPY_ALT
252
253
App.action.file.save_as.description=Rename the current document
254
App.action.file.save_as.text=Save _As
255
256
App.action.file.save_all.description=Save all open documents
257
App.action.file.save_all.accelerator=Shortcut+Shift+S
258
App.action.file.save_all.text=Save A_ll
259
260
App.action.file.export.html_svg.description=Export the current document as HTML + SVG
261
App.action.file.export.text=_Export As
262
App.action.file.export.html_svg.text=HTML and S_VG
263
264
App.action.file.export.html_tex.description=Export the current document as HTML + TeX
265
App.action.file.export.html_tex.text=HTML and _TeX
266
267
App.action.file.export.markdown.description=Export the current document as Markdown
268
App.action.file.export.markdown.text=Markdown
269
270
App.action.file.exit.description=Quit the application
271
App.action.file.exit.text=E_xit
272
273
274
App.action.edit.undo.description=Undo the previous edit
275
App.action.edit.undo.accelerator=Shortcut+Z
276
App.action.edit.undo.text=_Undo
277
App.action.edit.undo.icon=UNDO
278
279
App.action.edit.redo.description=Redo the previous edit
280
App.action.edit.redo.accelerator=Shortcut+Y
281
App.action.edit.redo.text=_Redo
282
App.action.edit.redo.icon=REPEAT
283
284
App.action.edit.cut.description=Delete the selected text or line
285
App.action.edit.cut.accelerator=Shortcut+X
286
App.action.edit.cut.text=Cu_t
287
App.action.edit.cut.icon=CUT
288
289
App.action.edit.copy.description=Copy the selected text
290
App.action.edit.copy.accelerator=Shortcut+C
291
App.action.edit.copy.text=_Copy
292
App.action.edit.copy.icon=COPY
293
294
App.action.edit.paste.description=Paste from the clipboard
295
App.action.edit.paste.accelerator=Shortcut+V
296
App.action.edit.paste.text=_Paste
297
App.action.edit.paste.icon=PASTE
298
299
App.action.edit.select_all.description=Highlight the current document text
300
App.action.edit.select_all.accelerator=Shortcut+A
301
App.action.edit.select_all.text=Select _All
302
303
App.action.edit.find.description=Search for text in the document
304
App.action.edit.find.accelerator=Shortcut+F
305
App.action.edit.find.text=_Find
306
App.action.edit.find.icon=SEARCH
307
308
App.action.edit.find_next.description=Find next occurrence
309
App.action.edit.find_next.accelerator=F3
310
App.action.edit.find_next.text=Find _Next
311
312
App.action.edit.find_prev.description=Find previous occurrence
313
App.action.edit.find_prev.accelerator=Shift+F3
314
App.action.edit.find_prev.text=Find _Prev
315
316
App.action.edit.preferences.description=Edit user preferences
317
App.action.edit.preferences.accelerator=Ctrl+Alt+S
318
App.action.edit.preferences.text=_Preferences
319
320
321
App.action.format.bold.description=Insert strong text
322
App.action.format.bold.accelerator=Shortcut+B
323
App.action.format.bold.text=_Bold
324
App.action.format.bold.icon=BOLD
325
326
App.action.format.italic.description=Insert text emphasis
327
App.action.format.italic.accelerator=Shortcut+I
328
App.action.format.italic.text=_Italic
329
App.action.format.italic.icon=ITALIC
330
331
App.action.format.superscript.description=Insert superscript text
332
App.action.format.superscript.accelerator=Shortcut+[
333
App.action.format.superscript.text=Su_perscript
334
App.action.format.superscript.icon=SUPERSCRIPT
335
336
App.action.format.subscript.description=Insert subscript text
337
App.action.format.subscript.accelerator=Shortcut+]
338
App.action.format.subscript.text=Su_bscript
339
App.action.format.subscript.icon=SUBSCRIPT
340
341
App.action.format.strikethrough.description=Insert struck text
342
App.action.format.strikethrough.accelerator=Shortcut+T
343
App.action.format.strikethrough.text=Stri_kethrough
344
App.action.format.strikethrough.icon=STRIKETHROUGH
345
346
347
App.action.insert.blockquote.description=Insert blockquote
348
App.action.insert.blockquote.accelerator=Ctrl+Q
349
App.action.insert.blockquote.text=_Blockquote
350
App.action.insert.blockquote.icon=QUOTE_LEFT
351
352
App.action.insert.code.description=Insert inline code
353
App.action.insert.code.accelerator=Shortcut+K
354
App.action.insert.code.text=Inline _Code
355
App.action.insert.code.icon=CODE
356
357
App.action.insert.fenced_code_block.description=Insert code block
358
App.action.insert.fenced_code_block.accelerator=Shortcut+Shift+K
359
App.action.insert.fenced_code_block.text=_Fenced Code Block
360
App.action.insert.fenced_code_block.prompt.text=Enter code here
361
App.action.insert.fenced_code_block.icon=FILE_CODE_ALT
362
363
App.action.insert.link.description=Insert hyperlink
364
App.action.insert.link.accelerator=Shortcut+L
365
App.action.insert.link.text=_Link...
366
App.action.insert.link.icon=LINK
367
368
App.action.insert.image.description=Insert image
369
App.action.insert.image.accelerator=Shortcut+G
370
App.action.insert.image.text=_Image...
371
App.action.insert.image.icon=PICTURE_ALT
372
373
App.action.insert.heading.description=Insert heading level
374
App.action.insert.heading.accelerator=Shortcut+
375
App.action.insert.heading.icon=HEADER
376
377
App.action.insert.heading_1.description=${App.action.insert.heading.description} 1
378
App.action.insert.heading_1.accelerator=${App.action.insert.heading.accelerator}1
379
App.action.insert.heading_1.text=Heading _1
380
App.action.insert.heading_1.icon=${App.action.insert.heading.icon}
381
382
App.action.insert.heading_2.description=${App.action.insert.heading.description} 2
383
App.action.insert.heading_2.accelerator=${App.action.insert.heading.accelerator}2
384
App.action.insert.heading_2.text=Heading _2
385
App.action.insert.heading_2.icon=${App.action.insert.heading.icon}
386
387
App.action.insert.heading_3.description=${App.action.insert.heading.description} 3
388
App.action.insert.heading_3.accelerator=${App.action.insert.heading.accelerator}3
389
App.action.insert.heading_3.text=Heading _3
390
App.action.insert.heading_3.icon=${App.action.insert.heading.icon}
391
392
App.action.insert.unordered_list.description=Insert bulleted list
393
App.action.insert.unordered_list.accelerator=Shortcut+U
394
App.action.insert.unordered_list.text=_Unordered List
395
App.action.insert.unordered_list.icon=LIST_UL
396
397
App.action.insert.ordered_list.description=Insert enumerated list
398
App.action.insert.ordered_list.accelerator=Shortcut+Shift+O
399
App.action.insert.ordered_list.text=_Ordered List
400
App.action.insert.ordered_list.icon=LIST_OL
401
402
App.action.insert.horizontal_rule.description=Insert horizontal rule
403
App.action.insert.horizontal_rule.accelerator=Shortcut+H
404
App.action.insert.horizontal_rule.text=_Horizontal Rule
405
App.action.insert.horizontal_rule.icon=LIST_OL
406
407
408
App.action.definition.create.description=Create a new variable definition
409
App.action.definition.create.text=_Create
410
App.action.definition.create.icon=TREE
411
App.action.definition.create.tooltip=Add new item (Insert)
412
413
App.action.definition.rename.description=Rename the selected variable definition
414
App.action.definition.rename.text=_Rename
415
App.action.definition.rename.icon=EDIT
416
App.action.definition.rename.tooltip=Rename selected item (F2)
417
418
App.action.definition.delete.description=Delete the selected variable definitions
419
App.action.definition.delete.text=_Delete
420
App.action.definition.delete.icon=TRASH
421
App.action.definition.delete.tooltip=Delete selected items (Delete)
422
423
App.action.definition.insert.description=Insert a definition
424
App.action.definition.insert.accelerator=Ctrl+Space
425
App.action.definition.insert.text=_Insert
426
App.action.definition.insert.icon=STAR
427
428
429
App.action.view.refresh.description=Clear all caches
430
App.action.view.refresh.accelerator=F5
431
App.action.view.refresh.text=Refresh
432
433
App.action.view.issues.description=Open document issues
434
App.action.view.issues.accelerator=F6
435
App.action.view.issues.text=Issues
436
437
App.action.view.preview.description=Open document preview
438
App.action.view.preview.accelerator=F7
439
App.action.view.preview.text=Preview
440
441
App.action.view.menubar.description=Toggle menu bar
442
App.action.view.menubar.accelerator=Ctrl+F7
443
App.action.view.menubar.text=Menu bar
444
445
App.action.view.toolbar.description=Toggle tool bar
446
App.action.view.toolbar.accelerator=Ctrl+Shift+F7
447
App.action.view.toolbar.text=Tool bar
448
449
App.action.view.statusbar.description=Toggle status bar
450
App.action.view.statusbar.accelerator=Ctrl+Shift+Alt+F7
451
App.action.view.statusbar.text=Status bar
452
453
App.action.view.outline.description=Open document outline
454
App.action.view.outline.accelerator=F8
455
App.action.view.outline.text=Outline
421456
422457
App.action.view.files.description=Open file system browser
D src/main/resources/fonts/noto-sans-cjk/NotoSansCJKhk-Bold.otf
Binary file
D src/main/resources/fonts/noto-sans-cjk/NotoSansCJKhk-Regular.otf
Binary file
D src/main/resources/fonts/noto-sans-cjk/NotoSansCJKjp-Bold.otf
Binary file
D src/main/resources/fonts/noto-sans-cjk/NotoSansCJKjp-Regular.otf
Binary file
D src/main/resources/fonts/noto-sans-cjk/NotoSansCJKkr-Bold.otf
Binary file
D src/main/resources/fonts/noto-sans-cjk/NotoSansCJKkr-Regular.otf
Binary file
D src/main/resources/fonts/noto-sans-cjk/NotoSansCJKsc-Bold.otf
Binary file
D src/main/resources/fonts/noto-sans-cjk/NotoSansCJKsc-Regular.otf
Binary file
D src/main/resources/fonts/noto-sans-cjk/NotoSansCJKtc-Bold.otf
Binary file
D src/main/resources/fonts/noto-sans-cjk/NotoSansCJKtc-Regular.otf
Binary file
D src/main/resources/fonts/noto-sans-mono-cjk/NotoSansMonoCJKhk-Bold.otf
Binary file
D src/main/resources/fonts/noto-sans-mono-cjk/NotoSansMonoCJKhk-Regular.otf
Binary file
D src/main/resources/fonts/noto-sans-mono-cjk/NotoSansMonoCJKjp-Bold.otf
Binary file
D src/main/resources/fonts/noto-sans-mono-cjk/NotoSansMonoCJKjp-Regular.otf
Binary file
D src/main/resources/fonts/noto-sans-mono-cjk/NotoSansMonoCJKkr-Bold.otf
Binary file
D src/main/resources/fonts/noto-sans-mono-cjk/NotoSansMonoCJKkr-Regular.otf
Binary file
D src/main/resources/fonts/noto-sans-mono-cjk/NotoSansMonoCJKsc-Bold.otf
Binary file
D src/main/resources/fonts/noto-sans-mono-cjk/NotoSansMonoCJKsc-Regular.otf
Binary file
D src/main/resources/fonts/noto-sans-mono-cjk/NotoSansMonoCJKtc-Bold.otf
Binary file
D src/main/resources/fonts/noto-sans-mono-cjk/NotoSansMonoCJKtc-Regular.otf
Binary file
D src/main/resources/fonts/noto-serif-cjk/NotoSerifCJKjp-Bold.otf
Binary file
D src/main/resources/fonts/noto-serif-cjk/NotoSerifCJKjp-Regular.otf
Binary file
D src/main/resources/fonts/noto-serif-cjk/NotoSerifCJKkr-Bold.otf
Binary file
D src/main/resources/fonts/noto-serif-cjk/NotoSerifCJKkr-Regular.otf
Binary file
D src/main/resources/fonts/noto-serif-cjk/NotoSerifCJKsc-Bold.otf
Binary file
D src/main/resources/fonts/noto-serif-cjk/NotoSerifCJKsc-Regular.otf
Binary file
D src/main/resources/fonts/noto-serif-cjk/NotoSerifCJKtc-Bold.otf
Binary file
D src/main/resources/fonts/noto-serif-cjk/NotoSerifCJKtc-Regular.otf
Binary file
M src/test/java/com/keenwrite/definition/TreeViewTest.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.definition;
33
M src/test/java/com/keenwrite/io/MediaTypeTest.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.io;
33
M src/test/java/com/keenwrite/processors/markdown/ImageLinkExtensionTest.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.processors.markdown;
33
44
import com.keenwrite.preferences.Workspace;
5
import com.keenwrite.processors.markdown.extensions.ImageLinkExtension;
56
import com.vladsch.flexmark.html.HtmlRenderer;
67
import com.vladsch.flexmark.parser.Parser;
M src/test/java/com/keenwrite/r/PluralizeTest.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.r;
33
M src/test/java/com/keenwrite/tex/TeXRasterization.java
11
/*
2
 * Copyright 2020 White Magic Software, Ltd.
2
 * Copyright 2020-2021 White Magic Software, Ltd.
33
 *
44
 * All rights reserved.
M src/test/java/com/keenwrite/util/CyclicIteratorTest.java
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.util;
33