Dave Jarvis' Repositories

git clone https://repo.autonoma.ca/repo/keenwrite.git
M README.md
1
![Total Downloads](https://img.shields.io/github/downloads/DaveJarvis/keenwrite/total?color=blue&label=Total%20Downloads&style=flat) ![Release Downloads](https://img.shields.io/github/downloads/DaveJarvis/keenwrite/latest/total?color=purple&label=Release%20Downloads&style=flat) ![Release Date](https://img.shields.io/github/release-date/DaveJarvis/keenwrite?color=red&style=flat&label=Release%20Date) ![Release Version](https://img.shields.io/github/v/release/DaveJarvis/keenwrite?style=flat&label=Release)
2
13
# ![Logo](docs/images/app-title.png)
24
...
4850
* Real-time spell check
4951
* Real-time rendering of math using TeX notation
50
* Diagrams: Mermaid, GraphViz, UML, sequence, timing, and [many more](https://kroki.io/)!
52
* Real-time document statistics (with CJK word separation)
53
* Diagrams: Mermaid, GraphViz, UML, sequence, timing, and more
5154
* Dark, custom, and responsive themes
5255
* Interactive document outline
...
6770
## Screenshots
6871
69
Diagram that includes variables:
72
Diagrams that include variables:
7073
7174
![GraphViz diagram screenshot](docs/images/screenshots/01.png)
75
76
![Family tree diagram screenshot](docs/images/screenshots/05.png)
7277
7378
Poem with locale settings:
M build.gradle
3636
3737
dependencies {
38
  def v_junit = '5.5.1'
38
  def v_junit = '5.5.2'
3939
  def v_flexmark = '0.62.2'
40
  def v_jackson = '2.12.0'
41
  def v_batik = '1.13'
40
  def v_jackson = '2.12.1'
41
  def v_batik = '1.14'
4242
4343
  // JavaFX
...
7373
  // XML and XSL
7474
  implementation 'com.ximpleware:vtd-xml:2.13.4'
75
  implementation 'net.sf.saxon:Saxon-HE:10.1'
76
  implementation 'xalan:xalan:2.7.2'
75
  implementation 'net.sf.saxon:Saxon-HE:10.3'
76
  //implementation 'xalan:xalan:2.7.2'
7777
7878
  // HTML parsing and rendering
M docs/README.md
33
See the following documents for more information:
44
5
* [i18n.md](i18n.md) -- Using internationalization features
56
* [variables.md](variables.md) -- Variable definitions and interpolation
67
* [r.md](r.md) -- Call R functions within R Markdown documents
78
* [svg.md](svg.md) -- Fix known issues with displaying SVG files
89
* [themes.md](themes.md) -- Describes how to add and customize themes
9
* [texample.Rmd](texample.Rmd) -- Numerous examples of formulas
1010
* [credits.md](credits.md) -- Thanks to authors of contributing projects
11
* [samples](samples) -- Contains example documents
1112
1213
D docs/i18n/korean.md
1
*Song of the Yellow Bird*:
2
3
	翩翩黃鳥,
4
	雌雄相依。
5
	念我之獨,
6
	誰其與歸?
7
8
English translation:
9
    	
10
	Orioles fly smoothly
11
	Female and male cuddle close together
12
	Thinking of my loneliness
13
	Whom shall I go with?
14
15
Fonts:
16
17
* Regular: 활판 인쇄술
18
* Bold: **활판 인쇄술**
19
* Monospace: `활판 인쇄술`
20
* Monospace bold: **`활판 인쇄술`**
21
* Math: $E=mc^2$
22
231
A docs/i18n.md
1
# Internationalization
2
3
The application supports internationalization (I18N). There are multiple
4
components to editing and previewing internationalized text documents.
5
These include:
6
7
* Fonts
8
* Language
9
10
Both fonts and language must be set for non-Latin-based text.
11
12
# Fonts
13
14
The text editors and preview panel have independent font settings. For
15
all Chinese, Japanese, and Korean (CJK) fonts, you may have to type in
16
the font family name directly.
17
18
For example, CJK font families for the editor have the following names:
19
20
* **Noto Sans CJK KR** --- Korean font
21
* **Noto Sans CJK JP** --- Japanese font
22
* **Noto Sans CJK HN** --- Chinese font
23
* **Noto Sans CJK SC** --- Simplified Chinese font
24
25
While CJK font familes for the preview have the following names:
26
27
* **Noto Serif CJK KR** --- Korean font
28
* **Noto Serif CJK JP** --- Japanese font
29
* **Noto Serif CJK HN** --- Chinese font
30
* **Noto Serif CJK SC** --- Simplified Chinese font
31
32
## Editor
33
34
Complete the following steps to change the editor font:
35
36
1. Click **Edit → Preferences**.
37
1. Click **Fonts**.
38
1. Click **Change** under **Editor Font**.
39
1. Find the font name by typing or scrolling.
40
1. Click the desired font family.
41
1. Click **OK**.
42
1. Click **Apply**.
43
44
The text editor font is changed.
45
46
Note the following:
47
48
* The font must be installed in the system for this to work.
49
* You may have to edit the font name if it cannot be selected from the list.
50
51
## Preview
52
53
The preview font defines has font styles for regular and monospace text.
54
55
### Regular
56
57
Complete the following steps to change the regular preview font:
58
59
1. Click **Edit → Preferences**.
60
1. Click **Fonts**.
61
1. Click **Change** under **Preview Font** for the **Preview pane font name**.
62
1. Find the font name by typing or scrolling.
63
1. Click the desired font family.
64
1. Click **OK**.
65
1. Click **Apply**.
66
67
The regular preview font is changed.
68
69
### Monospace
70
71
Complete the following steps to change the monospace preview font:
72
73
1. Click **Edit → Preferences**.
74
1. Click **Fonts**.
75
1. Click **Change** under **Preview Font** for the **Monospace font name**.
76
1. Find the font name by typing or scrolling.
77
1. Click the desired font family.
78
1. Click **OK**.
79
1. Click **Apply**.
80
81
The regular monospace font is changed.
82
83
# Language
84
85
Language settings control the locale that the application uses. When using
86
a CJK font, for example, the application must also be instructed to use
87
a particular locale. Change the locale as follows:
88
89
1. Click **Edit → Preferences**.
90
1. Click **Language**.
91
1. Select a value for **Locale**.
92
1. Click **Apply**.
93
94
The language is set.
95
196
A docs/images/screenshots/05.png
Binary file
D docs/math.yaml
1
---
2
formula:
3
  sqrt:
4
    value: "420"
5
  quadratic:
6
    a: "25"
7
    b: "84.906"
8
    c: "20"
91
D docs/quadratic.Rmd
1
![Logo](images/app-title)
2
3
Given the quadratic formula:
4
5
$x = \frac{-b \pm \sqrt{b^2 -4ac}}{2a}$
6
7
Formatted in an R Markdown document as follows:
8
9
    $x = \frac{-b \pm \sqrt{b^2 -4ac}}{2a}$
10
11
We can substitute the following values:
12
13
$a = `r# x(v$formula$quadratic$a)`, b = `r# x(v$formula$quadratic$b)`, c = `r# x(v$formula$quadratic$c)`$
14
15
`r# -x(v$formula$quadratic$b) + sqrt( v$formula$quadratic$b^2  - 4 * v$formula$quadratic$a * v$formula$quadratic$c )`
16
17
To arrive at two solutions:
18
19
$x = \frac{-b + \sqrt{b^2 -4ac}}{2a} = `r# (-x(v$formula$quadratic$b) + sqrt( x(v$formula$quadratic$b)^2  - 4 * x(v$formula$quadratic$a) * x(v$formula$quadratic$c) )) / (2 * x(v$formula$quadratic$a))`$
20
21
$x = \frac{-b - \sqrt{b^2 -4ac}}{2a} = `r# (-x(v$formula$quadratic$b) - sqrt( x(v$formula$quadratic$b)^2  - 4 * x(v$formula$quadratic$a) * x(v$formula$quadratic$c) )) / (2 * x(v$formula$quadratic$a))`$
22
23
Changing the variable values is reflected in the output immediately.
241
A docs/samples/korean.md
1
*Song of the Yellow Bird*:
2
3
	翩翩黃鳥,
4
	雌雄相依。
5
	念我之獨,
6
	誰其與歸?
7
8
English translation:
9
    	
10
	Orioles fly smoothly
11
	Female and male cuddle close together
12
	Thinking of my loneliness
13
	Whom shall I go with?
14
15
Fonts:
16
17
* Regular: 활판 인쇄술
18
* Bold: **활판 인쇄술**
19
* Monospace: `활판 인쇄술`
20
* Monospace bold: **`활판 인쇄술`**
21
* Math: $E=mc^2$
22
123
A docs/samples/math.yaml
1
---
2
formula:
3
  sqrt:
4
    value: "420"
5
  quadratic:
6
    a: "25"
7
    b: "84.906"
8
    c: "20"
19
A docs/samples/quadratic.Rmd
1
![Logo](../images/app-title)
2
3
Given the quadratic formula:
4
5
$x = \frac{-b \pm \sqrt{b^2 -4ac}}{2a}$
6
7
Formatted in an R Markdown document as follows:
8
9
    $x = \frac{-b \pm \sqrt{b^2 -4ac}}{2a}$
10
11
We can substitute the following values:
12
13
$a = `r# x(v$formula$quadratic$a)`, b = `r# x(v$formula$quadratic$b)`, c = `r# x(v$formula$quadratic$c)`$
14
15
`r# -x(v$formula$quadratic$b) + sqrt( v$formula$quadratic$b^2  - 4 * v$formula$quadratic$a * v$formula$quadratic$c )`
16
17
To arrive at two solutions:
18
19
$x = \frac{-b + \sqrt{b^2 -4ac}}{2a} = `r# (-x(v$formula$quadratic$b) + sqrt( x(v$formula$quadratic$b)^2  - 4 * x(v$formula$quadratic$a) * x(v$formula$quadratic$c) )) / (2 * x(v$formula$quadratic$a))`$
20
21
$x = \frac{-b - \sqrt{b^2 -4ac}}{2a} = `r# (-x(v$formula$quadratic$b) - sqrt( x(v$formula$quadratic$b)^2  - 4 * x(v$formula$quadratic$a) * x(v$formula$quadratic$c) )) / (2 * x(v$formula$quadratic$a))`$
22
23
Changing the variable values is reflected in the output immediately.
124
A docs/samples/tex.Rmd
1
# ![Logo](../images/app-title.png)
2
3
# Real-time equation rendering
4
5
Interpolated variables within R calculations, formatted as an equation:
6
7
$\sqrt{`r#x( v$formula$sqrt$value)`} = \pm `r# round(sqrt(x( v$formula$sqrt$value )),5)`$
8
9
# Maxwell's equations
10
11
$rot \vec{E} = \frac{1}{c} \frac{\partial{\vec{B}}}{\partial t}, div \vec{B} = 0$
12
13
$rot \vec{B} = \frac{1}{c} \frac{\partial{\vec{E}}}{\partial t} + \frac{4\pi}{c} \vec{j}, div \vec{E} = 4 \pi \rho_{\varepsilon}$
14
15
# Time-dependent Schrödinger equation
16
17
$- \frac{{\hbar ^2 }}{{2m}}\frac{{\partial ^2 \psi (x,t)}}{{\partial x^2 }} + U(x)\psi (x,t) = i\hbar \frac{{\partial \psi (x,t)}}{{\partial t}}$
18
19
# Discrete-time Fourier transforms
20
21
Unit step function: $u(n) \Leftrightarrow \frac{1}{1-e^{-jw}} + \sum_{k=-\infty}^{\infty} \pi \delta (\omega + 2\pi k)$
22
23
Shifted delta: $\delta (n - n_o ) \Leftrightarrow e^{ - j\omega n_o }$
24
25
# Faraday's Law
26
27
$\oint_C {E \cdot d\ell  =  - \frac{d}{{dt}}} \int_S {B_n dA}$
28
29
# Infinite series
30
31
$sin(x) = \sum_{n = 1}^{\infty}  {\frac{{( { - 1})^{n - 1} x^{2n - 1} }}{{( {2n - 1})!}}}$
32
33
# Magnetic flux
34
35
$\phi _m  = \int_S {N{{B}} \cdot {{\hat n}}dA = } \int_S {NB_n dA}$
36
37
# Driven oscillation amplitude
38
39
$A = \frac{{F_0 }}{{\sqrt {m^2 ( {\omega _0^2  - \omega ^2 } )^2  + b^2 \omega ^2 } }}$
40
41
# Optics
42
43
$\phi  = \frac{{2\pi }}{\lambda }a sin(\theta)$
144
D docs/texample.Rmd
1
# ![Logo](images/app-title.png)
2
3
# Real-time equation rendering
4
5
Interpolated variables within R calculations, formatted as an equation:
6
7
$\sqrt{`r#x( v$formula$sqrt$value)`} = \pm `r# round(sqrt(x( v$formula$sqrt$value )),5)`$
8
9
# Maxwell's equations
10
11
$rot \vec{E} = \frac{1}{c} \frac{\partial{\vec{B}}}{\partial t}, div \vec{B} = 0$
12
13
$rot \vec{B} = \frac{1}{c} \frac{\partial{\vec{E}}}{\partial t} + \frac{4\pi}{c} \vec{j}, div \vec{E} = 4 \pi \rho_{\varepsilon}$
14
15
# Time-dependent Schrödinger equation
16
17
$- \frac{{\hbar ^2 }}{{2m}}\frac{{\partial ^2 \psi (x,t)}}{{\partial x^2 }} + U(x)\psi (x,t) = i\hbar \frac{{\partial \psi (x,t)}}{{\partial t}}$
18
19
# Discrete-time Fourier transforms
20
21
Unit step function: $u(n) \Leftrightarrow \frac{1}{1-e^{-jw}} + \sum_{k=-\infty}^{\infty} \pi \delta (\omega + 2\pi k)$
22
23
Shifted delta: $\delta (n - n_o ) \Leftrightarrow e^{ - j\omega n_o }$
24
25
# Faraday's Law
26
27
$\oint_C {E \cdot d\ell  =  - \frac{d}{{dt}}} \int_S {B_n dA}$
28
29
# Infinite series
30
31
$sin(x) = \sum_{n = 1}^{\infty}  {\frac{{( { - 1})^{n - 1} x^{2n - 1} }}{{( {2n - 1})!}}}$
32
33
# Magnetic flux
34
35
$\phi _m  = \int_S {N{{B}} \cdot {{\hat n}}dA = } \int_S {NB_n dA}$
36
37
# Driven oscillation amplitude
38
39
$A = \frac{{F_0 }}{{\sqrt {m^2 ( {\omega _0^2  - \omega ^2 } )^2  + b^2 \omega ^2 } }}$
40
41
# Optics
42
43
$\phi  = \frac{{2\pi }}{\lambda }a sin(\theta)$
441
M font-names
11
#!/usr/bin/env bash
22
3
# Writes the name for all OTF files found in the current directory or lower
3
# Outputs font names for all font files.
44
55
find src/main/resources/fonts -type f \( -name "*otf" -o -name "*ttf" \) -exec \
M libs/jmathtex.jar
Binary file
D libs/jsymspell/jsymspell-core-1.0-SNAPSHOT-javadoc.jar
Binary file
D libs/jsymspell/jsymspell-core-1.0-SNAPSHOT-sources.jar
Binary file
D libs/jsymspell/jsymspell-core-1.0-SNAPSHOT.jar
Binary file
A libs/jsymspell/jsymspell-core-1.0.jar
Binary file
A libs/tokenize.jar
Binary file
M src/main/java/com/keenwrite/Constants.java
223223
  public static final String ICON_SIZE_DEFAULT = "1.2em";
224224
225
  /**
226
   * Default server name for rendering diagrams.
227
   * <p>
228
   * TODO: Make this a preference so that local installs are possible.
229
   */
225230
  public static final String DIAGRAM_SERVER_NAME = "kroki.io";
231
232
  /**
233
   * Application action messages properties prefix.
234
   */
235
  public static final String ACTION_PREFIX = "Action.";
226236
227237
  /**
228238
   * Prevent instantiation.
229239
   */
230240
  private Constants() {
241
  }
242
243
  /**
244
   * Converts from points to pixels because FlyingSaucer cannot handle points
245
   * properly. This is used to convert font sizes.
246
   *
247
   * @param points The points to convert to pixels.
248
   * @return The given number of points in equivalent pixels.
249
   */
250
  public static int toPixels( final double points ) {
251
    return (int) (points * (1 + 1 / 3f));
231252
  }
232253
M src/main/java/com/keenwrite/MainPane.java
1414
import com.keenwrite.events.TextEditorFocusEvent;
1515
import com.keenwrite.io.MediaType;
16
import com.keenwrite.outline.DocumentOutline;
17
import com.keenwrite.preferences.Key;
18
import com.keenwrite.preferences.Workspace;
19
import com.keenwrite.preview.HtmlPanel;
20
import com.keenwrite.preview.HtmlPreview;
21
import com.keenwrite.processors.Processor;
22
import com.keenwrite.processors.ProcessorContext;
23
import com.keenwrite.processors.ProcessorFactory;
24
import com.keenwrite.processors.markdown.extensions.CaretExtension;
25
import com.keenwrite.service.events.Notifier;
26
import com.keenwrite.sigils.RSigilOperator;
27
import com.keenwrite.sigils.SigilOperator;
28
import com.keenwrite.sigils.Tokens;
29
import com.keenwrite.sigils.YamlSigilOperator;
30
import com.panemu.tiwulfx.control.dock.DetachableTab;
31
import com.panemu.tiwulfx.control.dock.DetachableTabPane;
32
import javafx.application.Platform;
33
import javafx.beans.property.*;
34
import javafx.collections.ListChangeListener;
35
import javafx.event.ActionEvent;
36
import javafx.event.Event;
37
import javafx.event.EventHandler;
38
import javafx.scene.Node;
39
import javafx.scene.Scene;
40
import javafx.scene.control.SplitPane;
41
import javafx.scene.control.Tab;
42
import javafx.scene.control.TabPane;
43
import javafx.scene.control.Tooltip;
44
import javafx.scene.control.TreeItem.TreeModificationEvent;
45
import javafx.scene.input.KeyEvent;
46
import javafx.stage.Stage;
47
import javafx.stage.Window;
48
import org.greenrobot.eventbus.Subscribe;
49
50
import java.io.File;
51
import java.io.FileNotFoundException;
52
import java.nio.file.Path;
53
import java.util.*;
54
import java.util.concurrent.atomic.AtomicBoolean;
55
import java.util.function.Function;
56
import java.util.stream.Collectors;
57
58
import static com.keenwrite.Constants.*;
59
import static com.keenwrite.ExportFormat.NONE;
60
import static com.keenwrite.Messages.get;
61
import static com.keenwrite.events.Bus.register;
62
import static com.keenwrite.events.StatusEvent.clue;
63
import static com.keenwrite.io.MediaType.*;
64
import static com.keenwrite.preferences.WorkspaceKeys.*;
65
import static com.keenwrite.processors.IdentityProcessor.IDENTITY;
66
import static com.keenwrite.processors.ProcessorFactory.createProcessors;
67
import static java.util.stream.Collectors.groupingBy;
68
import static javafx.application.Platform.runLater;
69
import static javafx.scene.control.ButtonType.NO;
70
import static javafx.scene.control.ButtonType.YES;
71
import static javafx.scene.control.TabPane.TabClosingPolicy.ALL_TABS;
72
import static javafx.scene.input.KeyCode.SPACE;
73
import static javafx.scene.input.KeyCombination.CONTROL_DOWN;
74
import static javafx.util.Duration.millis;
75
import static javax.swing.SwingUtilities.invokeLater;
76
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
77
78
/**
79
 * Responsible for wiring together the main application components for a
80
 * particular workspace (project). These include the definition views,
81
 * text editors, and preview pane along with any corresponding controllers.
82
 */
83
public final class MainPane extends SplitPane {
84
  private static final Notifier sNotifier = Services.load( Notifier.class );
85
86
  /**
87
   * Used when opening files to determine how each file should be binned and
88
   * therefore what tab pane to be opened within.
89
   */
90
  private static final Set<MediaType> PLAIN_TEXT_FORMAT = Set.of(
91
    TEXT_MARKDOWN, TEXT_R_MARKDOWN, TEXT_R_XML, UNDEFINED
92
  );
93
94
  /**
95
   * Prevents re-instantiation of processing classes.
96
   */
97
  private final Map<TextResource, Processor<String>> mProcessors =
98
    new HashMap<>();
99
100
  private final Workspace mWorkspace;
101
102
  /**
103
   * Groups similar file type tabs together.
104
   */
105
  private final Map<MediaType, TabPane> mTabPanes = new HashMap<>();
106
107
  /**
108
   * Stores definition names and values.
109
   */
110
  private final Map<String, String> mResolvedMap =
111
    new HashMap<>( MAP_SIZE_DEFAULT );
112
113
  /**
114
   * Renders the actively selected plain text editor tab.
115
   */
116
  private final HtmlPreview mPreview;
117
118
  /**
119
   * Provides an interactive document outline.
120
   */
121
  private final DocumentOutline mOutline = new DocumentOutline();
122
123
  /**
124
   * Changing the active editor fires the value changed event. This allows
125
   * refreshes to happen when external definitions are modified and need to
126
   * trigger the processing chain.
127
   */
128
  private final ObjectProperty<TextEditor> mActiveTextEditor =
129
    createActiveTextEditor();
130
131
  /**
132
   * Changing the active definition editor fires the value changed event. This
133
   * allows refreshes to happen when external definitions are modified and need
134
   * to trigger the processing chain.
135
   */
136
  private final ObjectProperty<TextDefinition> mActiveDefinitionEditor =
137
    createActiveDefinitionEditor( mActiveTextEditor );
138
139
  /**
140
   * Tracks the number of detached tab panels opened into their own windows,
141
   * which allows unique identification of subordinate windows by their title.
142
   * It is doubtful more than 128 windows, much less 256, will be created.
143
   */
144
  private byte mWindowCount;
145
146
  /**
147
   * Called when the definition data is changed.
148
   */
149
  private final EventHandler<TreeModificationEvent<Event>> mTreeHandler =
150
    event -> {
151
      final var editor = mActiveDefinitionEditor.get();
152
153
      resolve( editor );
154
      process( getActiveTextEditor() );
155
      save( editor );
156
    };
157
158
  /**
159
   * Adds all content panels to the main user interface. This will load the
160
   * configuration settings from the workspace to reproduce the settings from
161
   * a previous session.
162
   */
163
  public MainPane( final Workspace workspace ) {
164
    mWorkspace = workspace;
165
    mPreview = new HtmlPreview( workspace );
166
167
    open( bin( getRecentFiles() ) );
168
    viewPreview();
169
    setDividerPositions( calculateDividerPositions() );
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().setOnCloseRequest( ( event ) -> {
175
        // Order matters here. We want to close all the tabs to ensure each
176
        // is saved, but after they are closed, the workspace should still
177
        // retain the list of files that were open. If this line came after
178
        // closing, then restarting the application would list no files.
179
        mWorkspace.save();
180
181
        if( closeAll() ) {
182
          Platform.exit();
183
          System.exit( 0 );
184
        }
185
        else {
186
          event.consume();
187
        }
188
      } )
189
    );
190
191
    register( this );
192
  }
193
194
  @Subscribe
195
  public void handle( final TextEditorFocusEvent event ) {
196
    mActiveTextEditor.set( event.get() );
197
  }
198
199
  @Subscribe
200
  public void handle( final TextDefinitionFocusEvent event ) {
201
    mActiveDefinitionEditor.set( event.get() );
202
  }
203
204
  /**
205
   * Typically called when a file name is clicked in the {@link HtmlPanel}.
206
   *
207
   * @param event The event to process, must contain a valid file reference.
208
   */
209
  @Subscribe
210
  public void handle( final FileOpenEvent event ) {
211
    final File eventFile;
212
    final var eventUri = event.getUri();
213
214
    if( eventUri.isAbsolute() ) {
215
      eventFile = new File( eventUri.getPath() );
216
    }
217
    else {
218
      final var activeFile = getActiveTextEditor().getFile();
219
      final var parent = activeFile.getParentFile();
220
221
      if( parent == null ) {
222
        clue( new FileNotFoundException( eventUri.getPath() ) );
223
        return;
224
      }
225
      else {
226
        final var parentPath = parent.getAbsolutePath();
227
        eventFile = Path.of( parentPath, eventUri.getPath() ).toFile();
228
      }
229
    }
230
231
    runLater( () -> open( eventFile ) );
232
  }
233
234
  @Subscribe
235
  public void handle( final CaretNavigationEvent event ) {
236
    runLater( () -> {
237
      final var textArea = getActiveTextEditor().getTextArea();
238
      textArea.moveTo( event.getOffset() );
239
      textArea.requestFollowCaret();
240
      textArea.requestFocus();
241
    } );
242
  }
243
244
  /**
245
   * TODO: Load divider positions from exported settings, see bin() comment.
246
   */
247
  private double[] calculateDividerPositions() {
248
    final var ratio = 100f / getItems().size() / 100;
249
    final var positions = getDividerPositions();
250
251
    for( int i = 0; i < positions.length; i++ ) {
252
      positions[ i ] = ratio * i;
253
    }
254
255
    return positions;
256
  }
257
258
  /**
259
   * Opens all the files into the application, provided the paths are unique.
260
   * This may only be called for any type of files that a user can edit
261
   * (i.e., update and persist), such as definitions and text files.
262
   *
263
   * @param files The list of files to open.
264
   */
265
  public void open( final List<File> files ) {
266
    files.forEach( this::open );
267
  }
268
269
  /**
270
   * This opens the given file. Since the preview pane is not a file that
271
   * can be opened, it is safe to add a listener to the detachable pane.
272
   *
273
   * @param file The file to open.
274
   */
275
  private void open( final File file ) {
276
    final var tab = createTab( file );
277
    final var node = tab.getContent();
278
    final var mediaType = MediaType.valueFrom( file );
279
    final var tabPane = obtainTabPane( mediaType );
280
281
    tab.setTooltip( createTooltip( file ) );
282
    tabPane.setFocusTraversable( false );
283
    tabPane.setTabClosingPolicy( ALL_TABS );
284
    tabPane.getTabs().add( tab );
285
286
    // Attach the tab scene factory for new tab panes.
287
    if( !getItems().contains( tabPane ) ) {
288
      addTabPane(
289
        node instanceof TextDefinition ? 0 : getItems().size(), tabPane
290
      );
291
    }
292
293
    getRecentFiles().add( file.getAbsolutePath() );
294
  }
295
296
  /**
297
   * Opens a new text editor document using the default document file name.
298
   */
299
  public void newTextEditor() {
300
    open( DOCUMENT_DEFAULT );
301
  }
302
303
  /**
304
   * Opens a new definition editor document using the default definition
305
   * file name.
306
   */
307
  public void newDefinitionEditor() {
308
    open( DEFINITION_DEFAULT );
309
  }
310
311
  /**
312
   * Iterates over all tab panes to find all {@link TextEditor}s and request
313
   * that they save themselves.
314
   */
315
  public void saveAll() {
316
    mTabPanes.forEach(
317
      ( mt, tp ) -> tp.getTabs().forEach( ( tab ) -> {
318
        final var node = tab.getContent();
319
        if( node instanceof TextEditor ) {
320
          save( ((TextEditor) node) );
321
        }
322
      } )
323
    );
324
  }
325
326
  /**
327
   * Requests that the active {@link TextEditor} saves itself. Don't bother
328
   * checking if modified first because if the user swaps external media from
329
   * an external source (e.g., USB thumb drive), save should not second-guess
330
   * the user: save always re-saves. Also, it's less code.
331
   */
332
  public void save() {
333
    save( getActiveTextEditor() );
334
  }
335
336
  /**
337
   * Saves the active {@link TextEditor} under a new name.
338
   *
339
   * @param file The new active editor {@link File} reference.
340
   */
341
  public void saveAs( final File file ) {
342
    assert file != null;
343
    final var editor = getActiveTextEditor();
344
    final var tab = getTab( editor );
345
346
    editor.rename( file );
347
    tab.ifPresent( t -> {
348
      t.setText( editor.getFilename() );
349
      t.setTooltip( createTooltip( file ) );
350
    } );
351
352
    save();
353
  }
354
355
  /**
356
   * Saves the given {@link TextResource} to a file. This is typically used
357
   * to save either an instance of {@link TextEditor} or {@link TextDefinition}.
358
   *
359
   * @param resource The resource to export.
360
   */
361
  private void save( final TextResource resource ) {
362
    try {
363
      resource.save();
364
    } catch( final Exception ex ) {
365
      clue( ex );
366
      sNotifier.alert(
367
        getWindow(), resource.getPath(), "TextResource.saveFailed", ex
368
      );
369
    }
370
  }
371
372
  /**
373
   * Closes all open {@link TextEditor}s; all {@link TextDefinition}s stay open.
374
   *
375
   * @return {@code true} when all editors, modified or otherwise, were
376
   * permitted to close; {@code false} when one or more editors were modified
377
   * and the user requested no closing.
378
   */
379
  public boolean closeAll() {
380
    var closable = true;
381
382
    for( final var entry : mTabPanes.entrySet() ) {
383
      final var tabPane = entry.getValue();
384
      final var tabIterator = tabPane.getTabs().iterator();
385
386
      while( tabIterator.hasNext() ) {
387
        final var tab = tabIterator.next();
388
        final var resource = tab.getContent();
389
390
        // The definition panes auto-save, so being specific here prevents
391
        // closing the definitions in the situation where the user wants to
392
        // continue editing (i.e., possibly save unsaved work).
393
        if( !(resource instanceof TextEditor) ) {
394
          continue;
395
        }
396
397
        if( canClose( (TextEditor) resource ) ) {
398
          tabIterator.remove();
399
          close( tab );
400
        }
401
        else {
402
          closable = false;
403
        }
404
      }
405
    }
406
407
    return closable;
408
  }
409
410
  /**
411
   * Calls the tab's {@link Tab#getOnClosed()} handler to carry out a close
412
   * event.
413
   *
414
   * @param tab The {@link Tab} that was closed.
415
   */
416
  private void close( final Tab tab ) {
417
    final var handler = tab.getOnClosed();
418
419
    if( handler != null ) {
420
      handler.handle( new ActionEvent() );
421
    }
422
  }
423
424
  /**
425
   * Closes the active tab; delegates to {@link #canClose(TextResource)}.
426
   */
427
  public void close() {
428
    final var editor = getActiveTextEditor();
429
    if( canClose( editor ) ) {
430
      close( editor );
431
    }
432
  }
433
434
  /**
435
   * Closes the given {@link TextResource}. This must not be called from within
436
   * a loop that iterates over the tab panes using {@code forEach}, lest a
437
   * concurrent modification exception be thrown.
438
   *
439
   * @param resource The {@link TextResource} to close, without confirming with
440
   *                 the user.
441
   */
442
  private void close( final TextResource resource ) {
443
    getTab( resource ).ifPresent(
444
      ( tab ) -> {
445
        tab.getTabPane().getTabs().remove( tab );
446
        close( tab );
447
      }
448
    );
449
  }
450
451
  /**
452
   * Answers whether the given {@link TextResource} may be closed.
453
   *
454
   * @param editor The {@link TextResource} to try closing.
455
   * @return {@code true} when the editor may be closed; {@code false} when
456
   * the user has requested to keep the editor open.
457
   */
458
  private boolean canClose( final TextResource editor ) {
459
    final var editorTab = getTab( editor );
460
    final var canClose = new AtomicBoolean( true );
461
462
    if( editor.isModified() ) {
463
      final var filename = new StringBuilder();
464
      editorTab.ifPresent( ( tab ) -> filename.append( tab.getText() ) );
465
466
      final var message = sNotifier.createNotification(
467
        Messages.get( "Alert.file.close.title" ),
468
        Messages.get( "Alert.file.close.text" ),
469
        filename.toString()
470
      );
471
472
      final var dialog = sNotifier.createConfirmation( getWindow(), message );
473
474
      dialog.showAndWait().ifPresent(
475
        save -> canClose.set( save == YES ? editor.save() : save == NO )
476
      );
477
    }
478
479
    return canClose.get();
480
  }
481
482
  private ObjectProperty<TextEditor> createActiveTextEditor() {
483
    final var editor = new SimpleObjectProperty<TextEditor>();
484
485
    editor.addListener( ( c, o, n ) -> {
486
      if( n != null ) {
487
        mPreview.setBaseUri( n.getPath() );
488
        process( n );
489
      }
490
    } );
491
492
    return editor;
493
  }
494
495
  /**
496
   * Adds the HTML preview tab to its own, singular tab pane.
497
   */
498
  public void viewPreview() {
499
    viewTab( mPreview, TEXT_HTML, get( "Pane.preview.title" ) );
500
  }
501
502
  /**
503
   * Adds the document outline tab to its own, singular tab pane.
504
   */
505
  public void viewOutline() {
506
    viewTab( mOutline, APP_DOCUMENT_OUTLINE, get( "Pane.outline.title" ) );
507
  }
508
509
  private void viewTab(
510
    final Node node, final MediaType mediaType, final String name ) {
511
    final var tabPane = obtainTabPane( mediaType );
512
513
    for( final var tab : tabPane.getTabs() ) {
514
      if( tab.getContent() == node ) {
515
        return;
516
      }
517
    }
518
519
    tabPane.getTabs().add( createTab( name, node ) );
16
import com.keenwrite.preferences.Key;
17
import com.keenwrite.preferences.Workspace;
18
import com.keenwrite.preview.HtmlPanel;
19
import com.keenwrite.preview.HtmlPreview;
20
import com.keenwrite.processors.Processor;
21
import com.keenwrite.processors.ProcessorContext;
22
import com.keenwrite.processors.ProcessorFactory;
23
import com.keenwrite.processors.markdown.extensions.CaretExtension;
24
import com.keenwrite.service.events.Notifier;
25
import com.keenwrite.sigils.RSigilOperator;
26
import com.keenwrite.sigils.SigilOperator;
27
import com.keenwrite.sigils.Tokens;
28
import com.keenwrite.sigils.YamlSigilOperator;
29
import com.keenwrite.ui.heuristics.DocumentStatistics;
30
import com.keenwrite.ui.outline.DocumentOutline;
31
import com.panemu.tiwulfx.control.dock.DetachableTab;
32
import com.panemu.tiwulfx.control.dock.DetachableTabPane;
33
import javafx.application.Platform;
34
import javafx.beans.property.*;
35
import javafx.collections.ListChangeListener;
36
import javafx.event.ActionEvent;
37
import javafx.event.Event;
38
import javafx.event.EventHandler;
39
import javafx.scene.Node;
40
import javafx.scene.Scene;
41
import javafx.scene.control.SplitPane;
42
import javafx.scene.control.Tab;
43
import javafx.scene.control.TabPane;
44
import javafx.scene.control.Tooltip;
45
import javafx.scene.control.TreeItem.TreeModificationEvent;
46
import javafx.scene.input.KeyEvent;
47
import javafx.stage.Stage;
48
import javafx.stage.Window;
49
import org.greenrobot.eventbus.Subscribe;
50
51
import java.io.File;
52
import java.io.FileNotFoundException;
53
import java.nio.file.Path;
54
import java.util.*;
55
import java.util.concurrent.atomic.AtomicBoolean;
56
import java.util.function.Function;
57
import java.util.stream.Collectors;
58
59
import static com.keenwrite.Constants.*;
60
import static com.keenwrite.ExportFormat.NONE;
61
import static com.keenwrite.Messages.get;
62
import static com.keenwrite.events.Bus.register;
63
import static com.keenwrite.events.StatusEvent.clue;
64
import static com.keenwrite.io.MediaType.*;
65
import static com.keenwrite.preferences.WorkspaceKeys.*;
66
import static com.keenwrite.processors.IdentityProcessor.IDENTITY;
67
import static com.keenwrite.processors.ProcessorFactory.createProcessors;
68
import static java.util.stream.Collectors.groupingBy;
69
import static javafx.application.Platform.runLater;
70
import static javafx.scene.control.ButtonType.NO;
71
import static javafx.scene.control.ButtonType.YES;
72
import static javafx.scene.control.TabPane.TabClosingPolicy.ALL_TABS;
73
import static javafx.scene.input.KeyCode.SPACE;
74
import static javafx.scene.input.KeyCombination.CONTROL_DOWN;
75
import static javafx.util.Duration.millis;
76
import static javax.swing.SwingUtilities.invokeLater;
77
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
78
79
/**
80
 * Responsible for wiring together the main application components for a
81
 * particular workspace (project). These include the definition views,
82
 * text editors, and preview pane along with any corresponding controllers.
83
 */
84
public final class MainPane extends SplitPane {
85
  private static final Notifier sNotifier = Services.load( Notifier.class );
86
87
  /**
88
   * Used when opening files to determine how each file should be binned and
89
   * therefore what tab pane to be opened within.
90
   */
91
  private static final Set<MediaType> PLAIN_TEXT_FORMAT = Set.of(
92
    TEXT_MARKDOWN, TEXT_R_MARKDOWN, TEXT_R_XML, UNDEFINED
93
  );
94
95
  /**
96
   * Prevents re-instantiation of processing classes.
97
   */
98
  private final Map<TextResource, Processor<String>> mProcessors =
99
    new HashMap<>();
100
101
  private final Workspace mWorkspace;
102
103
  /**
104
   * Groups similar file type tabs together.
105
   */
106
  private final Map<MediaType, TabPane> mTabPanes = new HashMap<>();
107
108
  /**
109
   * Stores definition names and values.
110
   */
111
  private final Map<String, String> mResolvedMap =
112
    new HashMap<>( MAP_SIZE_DEFAULT );
113
114
  /**
115
   * Renders the actively selected plain text editor tab.
116
   */
117
  private final HtmlPreview mPreview;
118
119
  /**
120
   * Provides an interactive document outline.
121
   */
122
  private final DocumentOutline mOutline = new DocumentOutline();
123
124
  /**
125
   * Changing the active editor fires the value changed event. This allows
126
   * refreshes to happen when external definitions are modified and need to
127
   * trigger the processing chain.
128
   */
129
  private final ObjectProperty<TextEditor> mActiveTextEditor =
130
    createActiveTextEditor();
131
132
  /**
133
   * Changing the active definition editor fires the value changed event. This
134
   * allows refreshes to happen when external definitions are modified and need
135
   * to trigger the processing chain.
136
   */
137
  private final ObjectProperty<TextDefinition> mActiveDefinitionEditor =
138
    createActiveDefinitionEditor( mActiveTextEditor );
139
140
  /**
141
   * Tracks the number of detached tab panels opened into their own windows,
142
   * which allows unique identification of subordinate windows by their title.
143
   * It is doubtful more than 128 windows, much less 256, will be created.
144
   */
145
  private byte mWindowCount;
146
147
  /**
148
   * Called when the definition data is changed.
149
   */
150
  private final EventHandler<TreeModificationEvent<Event>> mTreeHandler =
151
    event -> {
152
      final var editor = mActiveDefinitionEditor.get();
153
154
      resolve( editor );
155
      process( getActiveTextEditor() );
156
      save( editor );
157
    };
158
159
  private final DocumentStatistics mStatistics;
160
161
  /**
162
   * Adds all content panels to the main user interface. This will load the
163
   * configuration settings from the workspace to reproduce the settings from
164
   * a previous session.
165
   */
166
  public MainPane( final Workspace workspace ) {
167
    mWorkspace = workspace;
168
    mPreview = new HtmlPreview( workspace );
169
    mStatistics = new DocumentStatistics( workspace );
170
171
    open( bin( getRecentFiles() ) );
172
    viewPreview();
173
    setDividerPositions( calculateDividerPositions() );
174
175
    // Once the main scene's window regains focus, update the active definition
176
    // editor to the currently selected tab.
177
    runLater(
178
      () -> getWindow().setOnCloseRequest( ( event ) -> {
179
        // Order matters here. We want to close all the tabs to ensure each
180
        // is saved, but after they are closed, the workspace should still
181
        // retain the list of files that were open. If this line came after
182
        // closing, then restarting the application would list no files.
183
        mWorkspace.save();
184
185
        if( closeAll() ) {
186
          Platform.exit();
187
          System.exit( 0 );
188
        }
189
        else {
190
          event.consume();
191
        }
192
      } )
193
    );
194
195
    register( this );
196
  }
197
198
  @Subscribe
199
  public void handle( final TextEditorFocusEvent event ) {
200
    mActiveTextEditor.set( event.get() );
201
  }
202
203
  @Subscribe
204
  public void handle( final TextDefinitionFocusEvent event ) {
205
    mActiveDefinitionEditor.set( event.get() );
206
  }
207
208
  /**
209
   * Typically called when a file name is clicked in the {@link HtmlPanel}.
210
   *
211
   * @param event The event to process, must contain a valid file reference.
212
   */
213
  @Subscribe
214
  public void handle( final FileOpenEvent event ) {
215
    final File eventFile;
216
    final var eventUri = event.getUri();
217
218
    if( eventUri.isAbsolute() ) {
219
      eventFile = new File( eventUri.getPath() );
220
    }
221
    else {
222
      final var activeFile = getActiveTextEditor().getFile();
223
      final var parent = activeFile.getParentFile();
224
225
      if( parent == null ) {
226
        clue( new FileNotFoundException( eventUri.getPath() ) );
227
        return;
228
      }
229
      else {
230
        final var parentPath = parent.getAbsolutePath();
231
        eventFile = Path.of( parentPath, eventUri.getPath() ).toFile();
232
      }
233
    }
234
235
    runLater( () -> open( eventFile ) );
236
  }
237
238
  @Subscribe
239
  public void handle( final CaretNavigationEvent event ) {
240
    runLater( () -> {
241
      final var textArea = getActiveTextEditor().getTextArea();
242
      textArea.moveTo( event.getOffset() );
243
      textArea.requestFollowCaret();
244
      textArea.requestFocus();
245
    } );
246
  }
247
248
  /**
249
   * TODO: Load divider positions from exported settings, see bin() comment.
250
   */
251
  private double[] calculateDividerPositions() {
252
    final var ratio = 100f / getItems().size() / 100;
253
    final var positions = getDividerPositions();
254
255
    for( int i = 0; i < positions.length; i++ ) {
256
      positions[ i ] = ratio * i;
257
    }
258
259
    return positions;
260
  }
261
262
  /**
263
   * Opens all the files into the application, provided the paths are unique.
264
   * This may only be called for any type of files that a user can edit
265
   * (i.e., update and persist), such as definitions and text files.
266
   *
267
   * @param files The list of files to open.
268
   */
269
  public void open( final List<File> files ) {
270
    files.forEach( this::open );
271
  }
272
273
  /**
274
   * This opens the given file. Since the preview pane is not a file that
275
   * can be opened, it is safe to add a listener to the detachable pane.
276
   *
277
   * @param file The file to open.
278
   */
279
  private void open( final File file ) {
280
    final var tab = createTab( file );
281
    final var node = tab.getContent();
282
    final var mediaType = MediaType.valueFrom( file );
283
    final var tabPane = obtainTabPane( mediaType );
284
285
    tab.setTooltip( createTooltip( file ) );
286
    tabPane.setFocusTraversable( false );
287
    tabPane.setTabClosingPolicy( ALL_TABS );
288
    tabPane.getTabs().add( tab );
289
290
    // Attach the tab scene factory for new tab panes.
291
    if( !getItems().contains( tabPane ) ) {
292
      addTabPane(
293
        node instanceof TextDefinition ? 0 : getItems().size(), tabPane
294
      );
295
    }
296
297
    getRecentFiles().add( file.getAbsolutePath() );
298
  }
299
300
  /**
301
   * Opens a new text editor document using the default document file name.
302
   */
303
  public void newTextEditor() {
304
    open( DOCUMENT_DEFAULT );
305
  }
306
307
  /**
308
   * Opens a new definition editor document using the default definition
309
   * file name.
310
   */
311
  public void newDefinitionEditor() {
312
    open( DEFINITION_DEFAULT );
313
  }
314
315
  /**
316
   * Iterates over all tab panes to find all {@link TextEditor}s and request
317
   * that they save themselves.
318
   */
319
  public void saveAll() {
320
    mTabPanes.forEach(
321
      ( mt, tp ) -> tp.getTabs().forEach( ( tab ) -> {
322
        final var node = tab.getContent();
323
        if( node instanceof TextEditor ) {
324
          save( ((TextEditor) node) );
325
        }
326
      } )
327
    );
328
  }
329
330
  /**
331
   * Requests that the active {@link TextEditor} saves itself. Don't bother
332
   * checking if modified first because if the user swaps external media from
333
   * an external source (e.g., USB thumb drive), save should not second-guess
334
   * the user: save always re-saves. Also, it's less code.
335
   */
336
  public void save() {
337
    save( getActiveTextEditor() );
338
  }
339
340
  /**
341
   * Saves the active {@link TextEditor} under a new name.
342
   *
343
   * @param file The new active editor {@link File} reference.
344
   */
345
  public void saveAs( final File file ) {
346
    assert file != null;
347
    final var editor = getActiveTextEditor();
348
    final var tab = getTab( editor );
349
350
    editor.rename( file );
351
    tab.ifPresent( t -> {
352
      t.setText( editor.getFilename() );
353
      t.setTooltip( createTooltip( file ) );
354
    } );
355
356
    save();
357
  }
358
359
  /**
360
   * Saves the given {@link TextResource} to a file. This is typically used
361
   * to save either an instance of {@link TextEditor} or {@link TextDefinition}.
362
   *
363
   * @param resource The resource to export.
364
   */
365
  private void save( final TextResource resource ) {
366
    try {
367
      resource.save();
368
    } catch( final Exception ex ) {
369
      clue( ex );
370
      sNotifier.alert(
371
        getWindow(), resource.getPath(), "TextResource.saveFailed", ex
372
      );
373
    }
374
  }
375
376
  /**
377
   * Closes all open {@link TextEditor}s; all {@link TextDefinition}s stay open.
378
   *
379
   * @return {@code true} when all editors, modified or otherwise, were
380
   * permitted to close; {@code false} when one or more editors were modified
381
   * and the user requested no closing.
382
   */
383
  public boolean closeAll() {
384
    var closable = true;
385
386
    for( final var entry : mTabPanes.entrySet() ) {
387
      final var tabPane = entry.getValue();
388
      final var tabIterator = tabPane.getTabs().iterator();
389
390
      while( tabIterator.hasNext() ) {
391
        final var tab = tabIterator.next();
392
        final var resource = tab.getContent();
393
394
        // The definition panes auto-save, so being specific here prevents
395
        // closing the definitions in the situation where the user wants to
396
        // continue editing (i.e., possibly save unsaved work).
397
        if( !(resource instanceof TextEditor) ) {
398
          continue;
399
        }
400
401
        if( canClose( (TextEditor) resource ) ) {
402
          tabIterator.remove();
403
          close( tab );
404
        }
405
        else {
406
          closable = false;
407
        }
408
      }
409
    }
410
411
    return closable;
412
  }
413
414
  /**
415
   * Calls the tab's {@link Tab#getOnClosed()} handler to carry out a close
416
   * event.
417
   *
418
   * @param tab The {@link Tab} that was closed.
419
   */
420
  private void close( final Tab tab ) {
421
    final var handler = tab.getOnClosed();
422
423
    if( handler != null ) {
424
      handler.handle( new ActionEvent() );
425
    }
426
  }
427
428
  /**
429
   * Closes the active tab; delegates to {@link #canClose(TextResource)}.
430
   */
431
  public void close() {
432
    final var editor = getActiveTextEditor();
433
    if( canClose( editor ) ) {
434
      close( editor );
435
    }
436
  }
437
438
  /**
439
   * Closes the given {@link TextResource}. This must not be called from within
440
   * a loop that iterates over the tab panes using {@code forEach}, lest a
441
   * concurrent modification exception be thrown.
442
   *
443
   * @param resource The {@link TextResource} to close, without confirming with
444
   *                 the user.
445
   */
446
  private void close( final TextResource resource ) {
447
    getTab( resource ).ifPresent(
448
      ( tab ) -> {
449
        tab.getTabPane().getTabs().remove( tab );
450
        close( tab );
451
      }
452
    );
453
  }
454
455
  /**
456
   * Answers whether the given {@link TextResource} may be closed.
457
   *
458
   * @param editor The {@link TextResource} to try closing.
459
   * @return {@code true} when the editor may be closed; {@code false} when
460
   * the user has requested to keep the editor open.
461
   */
462
  private boolean canClose( final TextResource editor ) {
463
    final var editorTab = getTab( editor );
464
    final var canClose = new AtomicBoolean( true );
465
466
    if( editor.isModified() ) {
467
      final var filename = new StringBuilder();
468
      editorTab.ifPresent( ( tab ) -> filename.append( tab.getText() ) );
469
470
      final var message = sNotifier.createNotification(
471
        Messages.get( "Alert.file.close.title" ),
472
        Messages.get( "Alert.file.close.text" ),
473
        filename.toString()
474
      );
475
476
      final var dialog = sNotifier.createConfirmation( getWindow(), message );
477
478
      dialog.showAndWait().ifPresent(
479
        save -> canClose.set( save == YES ? editor.save() : save == NO )
480
      );
481
    }
482
483
    return canClose.get();
484
  }
485
486
  private ObjectProperty<TextEditor> createActiveTextEditor() {
487
    final var editor = new SimpleObjectProperty<TextEditor>();
488
489
    editor.addListener( ( c, o, n ) -> {
490
      if( n != null ) {
491
        mPreview.setBaseUri( n.getPath() );
492
        process( n );
493
      }
494
    } );
495
496
    return editor;
497
  }
498
499
  /**
500
   * Adds the HTML preview tab to its own, singular tab pane.
501
   */
502
  public void viewPreview() {
503
    viewTab( mPreview, TEXT_HTML, "Pane.preview.title" );
504
  }
505
506
  /**
507
   * Adds the document outline tab to its own, singular tab pane.
508
   */
509
  public void viewOutline() {
510
    viewTab( mOutline, APP_DOCUMENT_OUTLINE, "Pane.outline.title" );
511
  }
512
513
  public void viewStatistics() {
514
    viewTab( mStatistics, APP_DOCUMENT_STATISTICS, "Pane.statistics.title" );
515
  }
516
517
  private void viewTab(
518
    final Node node, final MediaType mediaType, final String key ) {
519
    final var tabPane = obtainTabPane( mediaType );
520
521
    for( final var tab : tabPane.getTabs() ) {
522
      if( tab.getContent() == node ) {
523
        return;
524
      }
525
    }
526
527
    tabPane.getTabs().add( createTab( get( key ), node ) );
520528
    addTabPane( tabPane );
521529
  }
M src/main/java/com/keenwrite/MainScene.java
2121
import static com.keenwrite.events.StatusEvent.clue;
2222
import static com.keenwrite.preferences.ThemeProperty.toFilename;
23
import static com.keenwrite.preferences.WorkspaceKeys.KEY_UI_THEME_CUSTOM;
24
import static com.keenwrite.preferences.WorkspaceKeys.KEY_UI_THEME_SELECTION;
23
import static com.keenwrite.preferences.WorkspaceKeys.*;
2524
import static com.keenwrite.ui.actions.ApplicationBars.*;
2625
import static javafx.application.Platform.runLater;
M src/main/java/com/keenwrite/editors/definition/DefinitionEditor.java
2828
import java.util.regex.Pattern;
2929
30
import static com.keenwrite.Constants.DEFINITION_DEFAULT;
31
import static com.keenwrite.Messages.get;
32
import static com.keenwrite.events.StatusEvent.clue;
33
import static com.keenwrite.events.TextDefinitionFocusEvent.fireTextDefinitionFocus;
34
import static java.lang.String.format;
35
import static java.util.regex.Pattern.compile;
36
import static java.util.regex.Pattern.quote;
37
import static javafx.geometry.Pos.CENTER;
38
import static javafx.geometry.Pos.TOP_CENTER;
39
import static javafx.scene.control.SelectionMode.MULTIPLE;
40
import static javafx.scene.control.TreeItem.childrenModificationEvent;
41
import static javafx.scene.control.TreeItem.valueChangedEvent;
42
import static javafx.scene.input.KeyEvent.KEY_PRESSED;
43
44
/**
45
 * Provides the user interface that holds a {@link TreeView}, which
46
 * allows users to interact with key/value pairs loaded from the
47
 * document parser and adapted using a {@link TreeTransformer}.
48
 */
49
public final class DefinitionEditor extends BorderPane
50
  implements TextDefinition {
51
  private static final int GROUP_DELIMITED = 1;
52
53
  /**
54
   * Contains the root that is added to the view.
55
   */
56
  private final DefinitionTreeItem<String> mTreeRoot = createRootTreeItem();
57
58
  /**
59
   * Converts a tree item value to and from a string..
60
   */
61
  private final TreeItemConverter mConverter = new TreeItemConverter();
62
63
  /**
64
   * Contains a view of the definitions.
65
   */
66
  private final TreeView<String> mTreeView =
67
    new AltTreeView<>( mTreeRoot, mConverter );
68
69
  /**
70
   * Used to adapt the structured document into a {@link TreeView}.
71
   */
72
  private final TreeTransformer mTreeTransformer;
73
74
  /**
75
   * Handlers for key press events.
76
   */
77
  private final Set<EventHandler<? super KeyEvent>> mKeyEventHandlers
78
    = new HashSet<>();
79
80
  /**
81
   * File being edited by this editor instance.
82
   */
83
  private File mFile;
84
85
  /**
86
   * Opened file's character encoding, or {@link Constants#DEFAULT_CHARSET} if
87
   * either no encoding could be determined or this is a new (empty) file.
88
   */
89
  private final Charset mEncoding;
90
91
  /**
92
   * Tracks whether the in-memory definitions have changed with respect to the
93
   * persisted definitions.
94
   */
95
  private final BooleanProperty mModified = new SimpleBooleanProperty();
96
97
  /**
98
   * This is provided for unit tests that are not backed by files.
99
   *
100
   * @param treeTransformer Responsible for transforming the definitions into
101
   *                        {@link TreeItem} instances.
102
   */
103
  public DefinitionEditor(
104
    final TreeTransformer treeTransformer ) {
105
    this( DEFINITION_DEFAULT, treeTransformer );
106
  }
107
108
  /**
109
   * Constructs a definition pane with a given tree view root.
110
   *
111
   * @param file The file of definitions to maintain through the UI.
112
   */
113
  public DefinitionEditor(
114
    final File file,
115
    final TreeTransformer treeTransformer ) {
116
    assert file != null;
117
    assert treeTransformer != null;
118
119
    mFile = file;
120
    mTreeTransformer = treeTransformer;
121
122
    //mTreeView.setCellFactory( new TreeCellFactory() );
123
    mTreeView.setContextMenu( createContextMenu() );
124
    mTreeView.addEventFilter( KEY_PRESSED, this::keyEventFilter );
125
    mTreeView.focusedProperty().addListener( this::focused );
126
    getSelectionModel().setSelectionMode( MULTIPLE );
127
128
    final var buttonBar = new HBox();
129
    buttonBar.getChildren().addAll(
130
      createButton( "create", e -> createDefinition() ),
131
      createButton( "rename", e -> renameDefinition() ),
132
      createButton( "delete", e -> deleteDefinitions() )
133
    );
134
    buttonBar.setAlignment( CENTER );
135
    buttonBar.setSpacing( 10 );
136
137
    setTop( buttonBar );
138
    setCenter( mTreeView );
139
    setAlignment( buttonBar, TOP_CENTER );
140
    mEncoding = open( mFile );
141
142
    // After the file is opened, watch for changes, not before. Otherwise,
143
    // upon saving, users will be prompted to save a file that hasn't had
144
    // any modifications (from their perspective).
145
    addTreeChangeHandler( event -> mModified.set( true ) );
146
  }
147
148
  @Override
149
  public void setText( final String document ) {
150
    final var foster = mTreeTransformer.transform( document );
151
    final var biological = getTreeRoot();
152
153
    for( final var child : foster.getChildren() ) {
154
      biological.getChildren().add( child );
155
    }
156
157
    getTreeView().refresh();
158
  }
159
160
  @Override
161
  public String getText() {
162
    final var result = new StringBuilder( 32768 );
163
164
    try {
165
      final var root = getTreeView().getRoot();
166
      final var problem = isTreeWellFormed();
167
168
      problem.ifPresentOrElse(
169
        ( node ) -> clue( "yaml.error.tree.form", node ),
170
        () -> result.append( mTreeTransformer.transform( root ) )
171
      );
172
    } catch( final Exception ex ) {
173
      // Catch errors while checking for a well-formed tree (e.g., stack smash).
174
      // Also catch any transformation exceptions (e.g., Json processing).
175
      clue( ex );
176
    }
177
178
    return result.toString();
179
  }
180
181
  @Override
182
  public File getFile() {
183
    return mFile;
184
  }
185
186
  @Override
187
  public void rename( final File file ) {
188
    mFile = file;
189
  }
190
191
  @Override
192
  public Charset getEncoding() {
193
    return mEncoding;
194
  }
195
196
  @Override
197
  public Node getNode() {
198
    return this;
199
  }
200
201
  @Override
202
  public ReadOnlyBooleanProperty modifiedProperty() {
203
    return mModified;
204
  }
205
206
  @Override
207
  public void clearModifiedProperty() {
208
    mModified.setValue( false );
209
  }
210
211
  private Button createButton(
212
    final String msgKey, final EventHandler<ActionEvent> eventHandler ) {
213
    final var keyPrefix = "App.action.definition." + msgKey;
214
    final var button = new Button( get( keyPrefix + ".text" ) );
215
    final var icon = get( keyPrefix + ".icon" );
216
    final var glyph = FontAwesomeIcon.valueOf( icon.toUpperCase() );
217
218
    button.setOnAction( eventHandler );
219
    button.setGraphic(
220
      FontAwesomeIconFactory.get().createIcon( glyph )
221
    );
222
    button.setTooltip( new Tooltip( get( keyPrefix + ".tooltip" ) ) );
223
224
    return button;
225
  }
226
227
  @Override
228
  public Map<String, String> toMap() {
229
    return new TreeItemMapper().toMap( getTreeView().getRoot() );
230
  }
231
232
  @Override
233
  public Map<String, String> interpolate(
234
    final Map<String, String> map, final Tokens tokens ) {
235
236
    // Non-greedy match of key names delimited by definition tokens.
237
    final var pattern = compile(
238
      format( "(%s.*?%s)",
239
              quote( tokens.getBegan() ),
240
              quote( tokens.getEnded() )
241
      )
242
    );
243
244
    map.replaceAll( ( k, v ) -> resolve( map, v, pattern ) );
245
    return map;
246
  }
247
248
  /**
249
   * Given a value with zero or more key references, this will resolve all
250
   * the values, recursively. If a key cannot be de-referenced, the value will
251
   * contain the key name.
252
   *
253
   * @param map     Map to search for keys when resolving key references.
254
   * @param value   Value containing zero or more key references.
255
   * @param pattern The regular expression pattern to match variable key names.
256
   * @return The given value with all embedded key references interpolated.
257
   */
258
  private String resolve(
259
    final Map<String, String> map, String value, final Pattern pattern ) {
260
    final var matcher = pattern.matcher( value );
261
262
    while( matcher.find() ) {
263
      final var keyName = matcher.group( GROUP_DELIMITED );
264
      final var mapValue = map.get( keyName );
265
      final var keyValue = mapValue == null
266
        ? keyName
267
        : resolve( map, mapValue, pattern );
268
269
      value = value.replace( keyName, keyValue );
270
    }
271
272
    return value;
273
  }
274
275
276
  /**
277
   * Informs the caller of whenever any {@link TreeItem} in the {@link TreeView}
278
   * is modified. The modifications include: item value changes, item additions,
279
   * and item removals.
280
   * <p>
281
   * Safe to call multiple times; if a handler is already registered, the
282
   * old handler is used.
283
   * </p>
284
   *
285
   * @param handler The handler to call whenever any {@link TreeItem} changes.
286
   */
287
  public void addTreeChangeHandler(
288
    final EventHandler<TreeItem.TreeModificationEvent<Event>> handler ) {
289
    final var root = getTreeView().getRoot();
290
    root.addEventHandler( valueChangedEvent(), handler );
291
    root.addEventHandler( childrenModificationEvent(), handler );
292
  }
293
294
  /**
295
   * Answers whether the {@link TreeItem}s in the {@link TreeView} are suitably
296
   * well-formed for export. A tree is considered well-formed if the following
297
   * conditions are met:
298
   *
299
   * <ul>
300
   *   <li>The root node contains at least one child node having a leaf.</li>
301
   *   <li>There are no leaf nodes with sibling leaf nodes.</li>
302
   * </ul>
303
   *
304
   * @return {@code null} if the document is well-formed, otherwise the
305
   * problematic child {@link TreeItem}.
306
   */
307
  public Optional<TreeItem<String>> isTreeWellFormed() {
308
    final var root = getTreeView().getRoot();
309
310
    for( final var child : root.getChildren() ) {
311
      final var problemChild = isWellFormed( child );
312
313
      if( child.isLeaf() || problemChild != null ) {
314
        return Optional.ofNullable( problemChild );
315
      }
316
    }
317
318
    return Optional.empty();
319
  }
320
321
  /**
322
   * Determines whether the document is well-formed by ensuring that
323
   * child branches do not contain multiple leaves.
324
   *
325
   * @param item The sub-tree to check for well-formedness.
326
   * @return {@code null} when the tree is well-formed, otherwise the
327
   * problematic {@link TreeItem}.
328
   */
329
  private TreeItem<String> isWellFormed( final TreeItem<String> item ) {
330
    int childLeafs = 0;
331
    int childBranches = 0;
332
333
    for( final var child : item.getChildren() ) {
334
      if( child.isLeaf() ) {
335
        childLeafs++;
336
      }
337
      else {
338
        childBranches++;
339
      }
340
341
      final var problemChild = isWellFormed( child );
342
343
      if( problemChild != null ) {
344
        return problemChild;
345
      }
346
    }
347
348
    return ((childBranches > 0 && childLeafs == 0) ||
349
      (childBranches == 0 && childLeafs <= 1)) ? null : item;
350
  }
351
352
  @Override
353
  public DefinitionTreeItem<String> findLeafExact( final String text ) {
354
    return getTreeRoot().findLeafExact( text );
355
  }
356
357
  @Override
358
  public DefinitionTreeItem<String> findLeafContains( final String text ) {
359
    return getTreeRoot().findLeafContains( text );
360
  }
361
362
  @Override
363
  public DefinitionTreeItem<String> findLeafContainsNoCase(
364
    final String text ) {
365
    return getTreeRoot().findLeafContainsNoCase( text );
366
  }
367
368
  @Override
369
  public DefinitionTreeItem<String> findLeafStartsWith( final String text ) {
370
    return getTreeRoot().findLeafStartsWith( text );
371
  }
372
373
  public void select( final TreeItem<String> item ) {
374
    getSelectionModel().clearSelection();
375
    getSelectionModel().select( getTreeView().getRow( item ) );
376
  }
377
378
  /**
379
   * Collapses the tree, recursively.
380
   */
381
  public void collapse() {
382
    collapse( getTreeRoot().getChildren() );
383
  }
384
385
  /**
386
   * Collapses the tree, recursively.
387
   *
388
   * @param <T>   The type of tree item to expand (usually String).
389
   * @param nodes The nodes to collapse.
390
   */
391
  private <T> void collapse( final ObservableList<TreeItem<T>> nodes ) {
392
    for( final var node : nodes ) {
393
      node.setExpanded( false );
394
      collapse( node.getChildren() );
395
    }
396
  }
397
398
  /**
399
   * @return {@code true} when the user is editing a {@link TreeItem}.
400
   */
401
  private boolean isEditingTreeItem() {
402
    return getTreeView().editingItemProperty().getValue() != null;
403
  }
404
405
  /**
406
   * Changes to edit mode for the selected item.
407
   */
408
  @Override
409
  public void renameDefinition() {
410
    getTreeView().edit( getSelectedItem() );
411
  }
412
413
  /**
414
   * Removes all selected items from the {@link TreeView}.
415
   */
416
  @Override
417
  public void deleteDefinitions() {
418
    for( final var item : getSelectedItems() ) {
419
      final var parent = item.getParent();
420
421
      if( parent != null ) {
422
        parent.getChildren().remove( item );
423
      }
424
    }
425
  }
426
427
  /**
428
   * Deletes the selected item.
429
   */
430
  private void deleteSelectedItem() {
431
    final var c = getSelectedItem();
432
    getSiblings( c ).remove( c );
433
  }
434
435
  /**
436
   * Adds a new item under the selected item (or root if nothing is selected).
437
   * There are a few conditions to consider: when adding to the root,
438
   * when adding to a leaf, and when adding to a non-leaf. Items added to the
439
   * root must contain two items: a key and a value.
440
   */
441
  @Override
442
  public void createDefinition() {
443
    final var value = createDefinitionTreeItem();
444
    getSelectedItem().getChildren().add( value );
445
    expand( value );
446
    select( value );
447
  }
448
449
  private ContextMenu createContextMenu() {
450
    final var menu = new ContextMenu();
451
    final var items = menu.getItems();
452
453
    addMenuItem( items, "App.action.definition.create.text" )
454
      .setOnAction( e -> createDefinition() );
455
    addMenuItem( items, "App.action.definition.rename.text" )
456
      .setOnAction( e -> renameDefinition() );
457
    addMenuItem( items, "App.action.definition.delete.text" )
30
import static com.keenwrite.Constants.ACTION_PREFIX;
31
import static com.keenwrite.Constants.DEFINITION_DEFAULT;
32
import static com.keenwrite.Messages.get;
33
import static com.keenwrite.events.StatusEvent.clue;
34
import static com.keenwrite.events.TextDefinitionFocusEvent.fireTextDefinitionFocus;
35
import static java.lang.String.format;
36
import static java.util.regex.Pattern.compile;
37
import static java.util.regex.Pattern.quote;
38
import static javafx.geometry.Pos.CENTER;
39
import static javafx.geometry.Pos.TOP_CENTER;
40
import static javafx.scene.control.SelectionMode.MULTIPLE;
41
import static javafx.scene.control.TreeItem.childrenModificationEvent;
42
import static javafx.scene.control.TreeItem.valueChangedEvent;
43
import static javafx.scene.input.KeyEvent.KEY_PRESSED;
44
45
/**
46
 * Provides the user interface that holds a {@link TreeView}, which
47
 * allows users to interact with key/value pairs loaded from the
48
 * document parser and adapted using a {@link TreeTransformer}.
49
 */
50
public final class DefinitionEditor extends BorderPane
51
  implements TextDefinition {
52
  private static final int GROUP_DELIMITED = 1;
53
54
  /**
55
   * Contains the root that is added to the view.
56
   */
57
  private final DefinitionTreeItem<String> mTreeRoot = createRootTreeItem();
58
59
  /**
60
   * Contains a view of the definitions.
61
   */
62
  private final TreeView<String> mTreeView =
63
    new AltTreeView<>( mTreeRoot, new TreeItemConverter() );
64
65
  /**
66
   * Used to adapt the structured document into a {@link TreeView}.
67
   */
68
  private final TreeTransformer mTreeTransformer;
69
70
  /**
71
   * Handlers for key press events.
72
   */
73
  private final Set<EventHandler<? super KeyEvent>> mKeyEventHandlers
74
    = new HashSet<>();
75
76
  /**
77
   * File being edited by this editor instance.
78
   */
79
  private File mFile;
80
81
  /**
82
   * Opened file's character encoding, or {@link Constants#DEFAULT_CHARSET} if
83
   * either no encoding could be determined or this is a new (empty) file.
84
   */
85
  private final Charset mEncoding;
86
87
  /**
88
   * Tracks whether the in-memory definitions have changed with respect to the
89
   * persisted definitions.
90
   */
91
  private final BooleanProperty mModified = new SimpleBooleanProperty();
92
93
  /**
94
   * This is provided for unit tests that are not backed by files.
95
   *
96
   * @param treeTransformer Responsible for transforming the definitions into
97
   *                        {@link TreeItem} instances.
98
   */
99
  public DefinitionEditor(
100
    final TreeTransformer treeTransformer ) {
101
    this( DEFINITION_DEFAULT, treeTransformer );
102
  }
103
104
  /**
105
   * Constructs a definition pane with a given tree view root.
106
   *
107
   * @param file The file of definitions to maintain through the UI.
108
   */
109
  public DefinitionEditor(
110
    final File file,
111
    final TreeTransformer treeTransformer ) {
112
    assert file != null;
113
    assert treeTransformer != null;
114
115
    mFile = file;
116
    mTreeTransformer = treeTransformer;
117
118
    //mTreeView.setCellFactory( new TreeCellFactory() );
119
    mTreeView.setContextMenu( createContextMenu() );
120
    mTreeView.addEventFilter( KEY_PRESSED, this::keyEventFilter );
121
    mTreeView.focusedProperty().addListener( this::focused );
122
    getSelectionModel().setSelectionMode( MULTIPLE );
123
124
    final var buttonBar = new HBox();
125
    buttonBar.getChildren().addAll(
126
      createButton( "create", e -> createDefinition() ),
127
      createButton( "rename", e -> renameDefinition() ),
128
      createButton( "delete", e -> deleteDefinitions() )
129
    );
130
    buttonBar.setAlignment( CENTER );
131
    buttonBar.setSpacing( 10 );
132
133
    setTop( buttonBar );
134
    setCenter( mTreeView );
135
    setAlignment( buttonBar, TOP_CENTER );
136
    mEncoding = open( mFile );
137
138
    // After the file is opened, watch for changes, not before. Otherwise,
139
    // upon saving, users will be prompted to save a file that hasn't had
140
    // any modifications (from their perspective).
141
    addTreeChangeHandler( event -> mModified.set( true ) );
142
  }
143
144
  @Override
145
  public void setText( final String document ) {
146
    final var foster = mTreeTransformer.transform( document );
147
    final var biological = getTreeRoot();
148
149
    for( final var child : foster.getChildren() ) {
150
      biological.getChildren().add( child );
151
    }
152
153
    getTreeView().refresh();
154
  }
155
156
  @Override
157
  public String getText() {
158
    final var result = new StringBuilder( 32768 );
159
160
    try {
161
      final var root = getTreeView().getRoot();
162
      final var problem = isTreeWellFormed();
163
164
      problem.ifPresentOrElse(
165
        ( node ) -> clue( "yaml.error.tree.form", node ),
166
        () -> result.append( mTreeTransformer.transform( root ) )
167
      );
168
    } catch( final Exception ex ) {
169
      // Catch errors while checking for a well-formed tree (e.g., stack smash).
170
      // Also catch any transformation exceptions (e.g., Json processing).
171
      clue( ex );
172
    }
173
174
    return result.toString();
175
  }
176
177
  @Override
178
  public File getFile() {
179
    return mFile;
180
  }
181
182
  @Override
183
  public void rename( final File file ) {
184
    mFile = file;
185
  }
186
187
  @Override
188
  public Charset getEncoding() {
189
    return mEncoding;
190
  }
191
192
  @Override
193
  public Node getNode() {
194
    return this;
195
  }
196
197
  @Override
198
  public ReadOnlyBooleanProperty modifiedProperty() {
199
    return mModified;
200
  }
201
202
  @Override
203
  public void clearModifiedProperty() {
204
    mModified.setValue( false );
205
  }
206
207
  private Button createButton(
208
    final String msgKey, final EventHandler<ActionEvent> eventHandler ) {
209
    final var keyPrefix = Constants.ACTION_PREFIX + "definition." + msgKey;
210
    final var button = new Button( get( keyPrefix + ".text" ) );
211
    final var icon = get( keyPrefix + ".icon" );
212
    final var glyph = FontAwesomeIcon.valueOf( icon.toUpperCase() );
213
214
    button.setOnAction( eventHandler );
215
    button.setGraphic(
216
      FontAwesomeIconFactory.get().createIcon( glyph )
217
    );
218
    button.setTooltip( new Tooltip( get( keyPrefix + ".tooltip" ) ) );
219
220
    return button;
221
  }
222
223
  @Override
224
  public Map<String, String> toMap() {
225
    return new TreeItemMapper().toMap( getTreeView().getRoot() );
226
  }
227
228
  @Override
229
  public Map<String, String> interpolate(
230
    final Map<String, String> map, final Tokens tokens ) {
231
232
    // Non-greedy match of key names delimited by definition tokens.
233
    final var pattern = compile(
234
      format( "(%s.*?%s)",
235
              quote( tokens.getBegan() ),
236
              quote( tokens.getEnded() )
237
      )
238
    );
239
240
    map.replaceAll( ( k, v ) -> resolve( map, v, pattern ) );
241
    return map;
242
  }
243
244
  /**
245
   * Given a value with zero or more key references, this will resolve all
246
   * the values, recursively. If a key cannot be de-referenced, the value will
247
   * contain the key name.
248
   *
249
   * @param map     Map to search for keys when resolving key references.
250
   * @param value   Value containing zero or more key references.
251
   * @param pattern The regular expression pattern to match variable key names.
252
   * @return The given value with all embedded key references interpolated.
253
   */
254
  private String resolve(
255
    final Map<String, String> map, String value, final Pattern pattern ) {
256
    final var matcher = pattern.matcher( value );
257
258
    while( matcher.find() ) {
259
      final var keyName = matcher.group( GROUP_DELIMITED );
260
      final var mapValue = map.get( keyName );
261
      final var keyValue = mapValue == null
262
        ? keyName
263
        : resolve( map, mapValue, pattern );
264
265
      value = value.replace( keyName, keyValue );
266
    }
267
268
    return value;
269
  }
270
271
272
  /**
273
   * Informs the caller of whenever any {@link TreeItem} in the {@link TreeView}
274
   * is modified. The modifications include: item value changes, item additions,
275
   * and item removals.
276
   * <p>
277
   * Safe to call multiple times; if a handler is already registered, the
278
   * old handler is used.
279
   * </p>
280
   *
281
   * @param handler The handler to call whenever any {@link TreeItem} changes.
282
   */
283
  public void addTreeChangeHandler(
284
    final EventHandler<TreeItem.TreeModificationEvent<Event>> handler ) {
285
    final var root = getTreeView().getRoot();
286
    root.addEventHandler( valueChangedEvent(), handler );
287
    root.addEventHandler( childrenModificationEvent(), handler );
288
  }
289
290
  /**
291
   * Answers whether the {@link TreeItem}s in the {@link TreeView} are suitably
292
   * well-formed for export. A tree is considered well-formed if the following
293
   * conditions are met:
294
   *
295
   * <ul>
296
   *   <li>The root node contains at least one child node having a leaf.</li>
297
   *   <li>There are no leaf nodes with sibling leaf nodes.</li>
298
   * </ul>
299
   *
300
   * @return {@code null} if the document is well-formed, otherwise the
301
   * problematic child {@link TreeItem}.
302
   */
303
  public Optional<TreeItem<String>> isTreeWellFormed() {
304
    final var root = getTreeView().getRoot();
305
306
    for( final var child : root.getChildren() ) {
307
      final var problemChild = isWellFormed( child );
308
309
      if( child.isLeaf() || problemChild != null ) {
310
        return Optional.ofNullable( problemChild );
311
      }
312
    }
313
314
    return Optional.empty();
315
  }
316
317
  /**
318
   * Determines whether the document is well-formed by ensuring that
319
   * child branches do not contain multiple leaves.
320
   *
321
   * @param item The sub-tree to check for well-formedness.
322
   * @return {@code null} when the tree is well-formed, otherwise the
323
   * problematic {@link TreeItem}.
324
   */
325
  private TreeItem<String> isWellFormed( final TreeItem<String> item ) {
326
    int childLeafs = 0;
327
    int childBranches = 0;
328
329
    for( final var child : item.getChildren() ) {
330
      if( child.isLeaf() ) {
331
        childLeafs++;
332
      }
333
      else {
334
        childBranches++;
335
      }
336
337
      final var problemChild = isWellFormed( child );
338
339
      if( problemChild != null ) {
340
        return problemChild;
341
      }
342
    }
343
344
    return ((childBranches > 0 && childLeafs == 0) ||
345
      (childBranches == 0 && childLeafs <= 1)) ? null : item;
346
  }
347
348
  @Override
349
  public DefinitionTreeItem<String> findLeafExact( final String text ) {
350
    return getTreeRoot().findLeafExact( text );
351
  }
352
353
  @Override
354
  public DefinitionTreeItem<String> findLeafContains( final String text ) {
355
    return getTreeRoot().findLeafContains( text );
356
  }
357
358
  @Override
359
  public DefinitionTreeItem<String> findLeafContainsNoCase(
360
    final String text ) {
361
    return getTreeRoot().findLeafContainsNoCase( text );
362
  }
363
364
  @Override
365
  public DefinitionTreeItem<String> findLeafStartsWith( final String text ) {
366
    return getTreeRoot().findLeafStartsWith( text );
367
  }
368
369
  public void select( final TreeItem<String> item ) {
370
    getSelectionModel().clearSelection();
371
    getSelectionModel().select( getTreeView().getRow( item ) );
372
  }
373
374
  /**
375
   * Collapses the tree, recursively.
376
   */
377
  public void collapse() {
378
    collapse( getTreeRoot().getChildren() );
379
  }
380
381
  /**
382
   * Collapses the tree, recursively.
383
   *
384
   * @param <T>   The type of tree item to expand (usually String).
385
   * @param nodes The nodes to collapse.
386
   */
387
  private <T> void collapse( final ObservableList<TreeItem<T>> nodes ) {
388
    for( final var node : nodes ) {
389
      node.setExpanded( false );
390
      collapse( node.getChildren() );
391
    }
392
  }
393
394
  /**
395
   * @return {@code true} when the user is editing a {@link TreeItem}.
396
   */
397
  private boolean isEditingTreeItem() {
398
    return getTreeView().editingItemProperty().getValue() != null;
399
  }
400
401
  /**
402
   * Changes to edit mode for the selected item.
403
   */
404
  @Override
405
  public void renameDefinition() {
406
    getTreeView().edit( getSelectedItem() );
407
  }
408
409
  /**
410
   * Removes all selected items from the {@link TreeView}.
411
   */
412
  @Override
413
  public void deleteDefinitions() {
414
    for( final var item : getSelectedItems() ) {
415
      final var parent = item.getParent();
416
417
      if( parent != null ) {
418
        parent.getChildren().remove( item );
419
      }
420
    }
421
  }
422
423
  /**
424
   * Deletes the selected item.
425
   */
426
  private void deleteSelectedItem() {
427
    final var c = getSelectedItem();
428
    getSiblings( c ).remove( c );
429
  }
430
431
  /**
432
   * Adds a new item under the selected item (or root if nothing is selected).
433
   * There are a few conditions to consider: when adding to the root,
434
   * when adding to a leaf, and when adding to a non-leaf. Items added to the
435
   * root must contain two items: a key and a value.
436
   */
437
  @Override
438
  public void createDefinition() {
439
    final var value = createDefinitionTreeItem();
440
    getSelectedItem().getChildren().add( value );
441
    expand( value );
442
    select( value );
443
  }
444
445
  private ContextMenu createContextMenu() {
446
    final var menu = new ContextMenu();
447
    final var items = menu.getItems();
448
449
    addMenuItem( items, ACTION_PREFIX + "definition.create.text" )
450
      .setOnAction( e -> createDefinition() );
451
    addMenuItem( items, ACTION_PREFIX + "definition.rename.text" )
452
      .setOnAction( e -> renameDefinition() );
453
    addMenuItem( items, ACTION_PREFIX + "definition.delete.text" )
458454
      .setOnAction( e -> deleteSelectedItem() );
459455
M src/main/java/com/keenwrite/editors/definition/package-info.java
1
/* Copyright 2020-2021 White Magic Software, Ltd.
2
 *
3
 * All rights reserved.
4
 *
5
 * Redistribution and use in source and binary forms, with or without
6
 * modification, are permitted provided that the following conditions are met:
7
 *
8
 *  o Redistributions of source code must retain the above copyright
9
 *    notice, this list of conditions and the following disclaimer.
10
 *
11
 *  o Redistributions in binary form must reproduce the above copyright
12
 *    notice, this list of conditions and the following disclaimer in the
13
 *    documentation and/or other materials provided with the distribution.
14
 *
15
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
16
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
17
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
18
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
19
 * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
20
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
21
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
22
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
23
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
24
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
25
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
26
 */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
272
283
/**
M src/main/java/com/keenwrite/editors/definition/yaml/package-info.java
1
/* Copyright 2020-2021 White Magic Software, Ltd.
2
 *
3
 * All rights reserved.
4
 *
5
 * Redistribution and use in source and binary forms, with or without
6
 * modification, are permitted provided that the following conditions are met:
7
 *
8
 *  o Redistributions of source code must retain the above copyright
9
 *    notice, this list of conditions and the following disclaimer.
10
 *
11
 *  o Redistributions in binary form must reproduce the above copyright
12
 *    notice, this list of conditions and the following disclaimer in the
13
 *    documentation and/or other materials provided with the distribution.
14
 *
15
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
16
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
17
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
18
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
19
 * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
20
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
21
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
22
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
23
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
24
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
25
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
26
 */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
272
283
/**
M src/main/java/com/keenwrite/editors/markdown/MarkdownEditor.java
4040
import static java.lang.String.format;
4141
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 org.apache.commons.lang3.StringUtils.stripEnd;
47
import static org.apache.commons.lang3.StringUtils.stripStart;
48
import static org.fxmisc.richtext.model.StyleSpans.singleton;
49
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
50
import static org.fxmisc.wellbehaved.event.InputMap.consume;
51
52
/**
53
 * Responsible for editing Markdown documents.
54
 */
55
public final class MarkdownEditor extends BorderPane implements TextEditor {
56
  /**
57
   * Regular expression that matches the type of markup block. This is used
58
   * when Enter is pressed to continue the block environment.
59
   */
60
  private static final Pattern PATTERN_AUTO_INDENT = Pattern.compile(
61
    "(\\s*[*+-]\\s+|\\s*[0-9]+\\.\\s+|\\s+)(.*)" );
62
63
  /**
64
   * The text editor.
65
   */
66
  private final StyleClassedTextArea mTextArea =
67
    new StyleClassedTextArea( false );
68
69
  /**
70
   * Wraps the text editor in scrollbars.
71
   */
72
  private final VirtualizedScrollPane<StyleClassedTextArea> mScrollPane =
73
    new VirtualizedScrollPane<>( mTextArea );
74
75
  private final Workspace mWorkspace;
76
77
  /**
78
   * Tracks where the caret is located in this document. This offers observable
79
   * properties for caret position changes.
80
   */
81
  private final Caret mCaret = createCaret( mTextArea );
82
83
  /**
84
   * File being edited by this editor instance.
85
   */
86
  private File mFile;
87
88
  /**
89
   * Set to {@code true} upon text or caret position changes. Value is {@code
90
   * false} by default.
91
   */
92
  private final BooleanProperty mDirty = new SimpleBooleanProperty();
93
94
  /**
95
   * Opened file's character encoding, or {@link Constants#DEFAULT_CHARSET} if
96
   * either no encoding could be determined or this is a new (empty) file.
97
   */
98
  private final Charset mEncoding;
99
100
  /**
101
   * Tracks whether the in-memory definitions have changed with respect to the
102
   * persisted definitions.
103
   */
104
  private final BooleanProperty mModified = new SimpleBooleanProperty();
105
106
  public MarkdownEditor( final Workspace workspace ) {
107
    this( DOCUMENT_DEFAULT, workspace );
108
  }
109
110
  public MarkdownEditor( final File file, final Workspace workspace ) {
111
    mEncoding = open( mFile = file );
112
    mWorkspace = workspace;
113
114
    initTextArea( mTextArea );
115
    initStyle( mTextArea );
116
    initScrollPane( mScrollPane );
117
    initSpellchecker( mTextArea );
118
    initHotKeys();
119
    initUndoManager();
120
  }
121
122
  private void initTextArea( final StyleClassedTextArea textArea ) {
123
    textArea.setWrapText( true );
124
    textArea.requestFollowCaret();
125
    textArea.moveTo( 0 );
126
127
    textArea.textProperty().addListener( ( c, o, n ) -> {
128
      // Fire, regardless of whether the caret position has changed.
129
      mDirty.set( false );
130
131
      // Prevent a caret position change from raising the dirty bits.
132
      mDirty.set( true );
133
    } );
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
    textArea.focusedProperty().addListener( ( c, o, n ) -> {
142
      if( n != null && n ) {
143
        fireTextEditorFocus( this );
144
      }
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( getStylesheetPath( getLocale() ) );
153
154
    localeProperty().addListener( ( c, o, n ) -> {
155
      if( n != null ) {
156
        stylesheets.clear();
157
        stylesheets.add( getStylesheetPath( getLocale() ) );
158
      }
159
    } );
160
161
    fontNameProperty().addListener(
162
      ( c, o, n ) ->
163
        mTextArea.setStyle( format( "-fx-font-family: '%s';", getFontName() ) )
164
    );
165
166
    fontSizeProperty().addListener(
167
      ( c, o, n ) ->
168
        mTextArea.setStyle( format( "-fx-font-size: %spt;", getFontSize() ) )
169
    );
170
  }
171
172
  private void initScrollPane(
173
    final VirtualizedScrollPane<StyleClassedTextArea> scrollpane ) {
174
    scrollpane.setVbarPolicy( ALWAYS );
175
    setCenter( scrollpane );
176
  }
177
178
  private void initSpellchecker( final StyleClassedTextArea textarea ) {
179
    final var speller = new TextEditorSpeller();
180
    speller.checkDocument( textarea );
181
    speller.checkParagraphs( textarea );
182
  }
183
184
  private void initHotKeys() {
185
    addEventListener( keyPressed( ENTER ), this::onEnterPressed );
186
    addEventListener( keyPressed( X, CONTROL_DOWN ), this::cut );
187
    addEventListener( keyPressed( TAB ), this::tab );
188
    addEventListener( keyPressed( TAB, SHIFT_DOWN ), this::untab );
189
    addEventListener( keyPressed( INSERT ), this::onInsertPressed );
190
  }
191
192
  private void initUndoManager() {
193
    final var undoManager = getUndoManager();
194
    final var markedPosition = undoManager.atMarkedPositionProperty();
195
196
    undoManager.forgetHistory();
197
    undoManager.mark();
198
    mModified.bind( Bindings.not( markedPosition ) );
199
  }
200
201
  @Override
202
  public void moveTo( final int offset ) {
203
    assert 0 <= offset && offset <= mTextArea.getLength();
204
    mTextArea.moveTo( offset );
205
    mTextArea.requestFollowCaret();
206
  }
207
208
  /**
209
   * Delegate the focus request to the text area itself.
210
   */
211
  @Override
212
  public void requestFocus() {
213
    mTextArea.requestFocus();
214
  }
215
216
  @Override
217
  public void setText( final String text ) {
218
    mTextArea.clear();
219
    mTextArea.appendText( text );
220
    mTextArea.getUndoManager().mark();
221
  }
222
223
  @Override
224
  public String getText() {
225
    return mTextArea.getText();
226
  }
227
228
  @Override
229
  public Charset getEncoding() {
230
    return mEncoding;
231
  }
232
233
  @Override
234
  public File getFile() {
235
    return mFile;
236
  }
237
238
  @Override
239
  public void rename( final File file ) {
240
    mFile = file;
241
  }
242
243
  @Override
244
  public void undo() {
245
    final var manager = getUndoManager();
246
    xxdo( manager::isUndoAvailable, manager::undo, "Main.status.error.undo" );
247
  }
248
249
  @Override
250
  public void redo() {
251
    final var manager = getUndoManager();
252
    xxdo( manager::isRedoAvailable, manager::redo, "Main.status.error.redo" );
253
  }
254
255
  /**
256
   * Performs an undo or redo action, if possible, otherwise displays an error
257
   * message to the user.
258
   *
259
   * @param ready  Answers whether the action can be executed.
260
   * @param action The action to execute.
261
   * @param key    The informational message key having a value to display if
262
   *               the {@link Supplier} is not ready.
263
   */
264
  private void xxdo(
265
    final Supplier<Boolean> ready, final Runnable action, final String key ) {
266
    if( ready.get() ) {
267
      action.run();
268
    }
269
    else {
270
      clue( key );
271
    }
272
  }
273
274
  @Override
275
  public void cut() {
276
    final var selected = mTextArea.getSelectedText();
277
278
    // Emulate selecting the current line by firing Home then Shift+Down Arrow.
279
    if( selected == null || selected.isEmpty() ) {
280
      // Note: mTextArea.selectLine() does not select empty lines.
281
      mTextArea.fireEvent( keyDown( HOME, false ) );
282
      mTextArea.fireEvent( keyDown( DOWN, true ) );
283
    }
284
285
    mTextArea.cut();
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
    enwrap( "\n\n```\n", "\n```\n\n" );
341
  }
342
343
  @Override
344
  public void heading( final int level ) {
345
    final var hashes = new String( new char[ level ] ).replace( "\0", "#" );
346
    block( format( "%s ", hashes ) );
347
  }
348
349
  @Override
350
  public void unorderedList() {
351
    block( "* " );
352
  }
353
354
  @Override
355
  public void orderedList() {
356
    block( "1. " );
357
  }
358
359
  @Override
360
  public void horizontalRule() {
361
    block( format( "---%n%n" ) );
362
  }
363
364
  @Override
365
  public Node getNode() {
366
    return this;
367
  }
368
369
  @Override
370
  public ReadOnlyBooleanProperty modifiedProperty() {
371
    return mModified;
372
  }
373
374
  @Override
375
  public void clearModifiedProperty() {
376
    getUndoManager().mark();
377
  }
378
379
  @Override
380
  public VirtualizedScrollPane<StyleClassedTextArea> getScrollPane() {
381
    return mScrollPane;
382
  }
383
384
  @Override
385
  public StyleClassedTextArea getTextArea() {
386
    return mTextArea;
387
  }
388
389
  private final Map<String, IndexRange> mStyles = new HashMap<>();
390
391
  @Override
392
  public void stylize( final IndexRange range, final String style ) {
393
    final var began = range.getStart();
394
    final var ended = range.getEnd() + 1;
395
396
    assert 0 <= began && began <= ended;
397
    assert style != null;
398
399
    // TODO: Ensure spell check and find highlights can coexist.
400
//    final var spans = mTextArea.getStyleSpans( range );
401
//    System.out.println( "SPANS: " + spans );
402
403
//    final var spans = mTextArea.getStyleSpans( range );
404
//    mTextArea.setStyleSpans( began, merge( spans, range.getLength(), style
405
//    ) );
406
407
//    final var builder = new StyleSpansBuilder<Collection<String>>();
408
//    builder.add( singleton( style ), range.getLength() + 1 );
409
//    mTextArea.setStyleSpans( began, builder.create() );
410
411
//    final var s = mTextArea.getStyleSpans( began, ended );
412
//    System.out.println( "STYLES: " +s );
413
414
    mStyles.put( style, range );
415
    mTextArea.setStyleClass( began, ended, style );
416
417
    // Ensure that whenever the user interacts with the text that the found
418
    // word will have its highlighting removed. The handler removes itself.
419
    // This won't remove the highlighting if the caret position moves by mouse.
420
    final var handler = mTextArea.getOnKeyPressed();
421
    mTextArea.setOnKeyPressed( ( event ) -> {
422
      mTextArea.setOnKeyPressed( handler );
423
      unstylize( style );
424
    } );
425
426
    //mTextArea.setStyleSpans(began, ended, s);
427
  }
428
429
  private static StyleSpans<Collection<String>> merge(
430
    StyleSpans<Collection<String>> spans, int len, String style ) {
431
    spans = spans.overlay(
432
      singleton( singletonList( style ), len ),
433
      ( bottomSpan, list ) -> {
434
        final List<String> l =
435
          new ArrayList<>( bottomSpan.size() + list.size() );
436
        l.addAll( bottomSpan );
437
        l.addAll( list );
438
        return l;
439
      } );
440
441
    return spans;
442
  }
443
444
  @Override
445
  public void unstylize( final String style ) {
446
    final var indexes = mStyles.remove( style );
447
    if( indexes != null ) {
448
      mTextArea.clearStyle( indexes.getStart(), indexes.getEnd() + 1 );
449
    }
450
  }
451
452
  @Override
453
  public Caret getCaret() {
454
    return mCaret;
455
  }
456
457
  private Caret createCaret( final StyleClassedTextArea editor ) {
458
    return Caret
459
      .builder()
460
      .with( Caret.Mutator::setEditor, editor )
461
      .build();
462
  }
463
464
  /**
465
   * This method adds listeners to editor events.
466
   *
467
   * @param <T>      The event type.
468
   * @param <U>      The consumer type for the given event type.
469
   * @param event    The event of interest.
470
   * @param consumer The method to call when the event happens.
471
   */
472
  public <T extends Event, U extends T> void addEventListener(
473
    final EventPattern<? super T, ? extends U> event,
474
    final Consumer<? super U> consumer ) {
475
    Nodes.addInputMap( mTextArea, consume( event, consumer ) );
476
  }
477
478
  private void onEnterPressed( final KeyEvent ignored ) {
479
    final var currentLine = getCaretParagraph();
480
    final var matcher = PATTERN_AUTO_INDENT.matcher( currentLine );
481
482
    // By default, insert a new line by itself.
483
    String newText = NEWLINE;
484
485
    // If the pattern was matched then determine what block type to continue.
486
    if( matcher.matches() ) {
487
      if( matcher.group( 2 ).isEmpty() ) {
488
        final var pos = mTextArea.getCaretPosition();
489
        mTextArea.selectRange( pos - currentLine.length(), pos );
490
      }
491
      else {
492
        // Indent the new line with the same whitespace characters and
493
        // list markers as current line. This ensures that the indentation
494
        // is propagated.
495
        newText = newText.concat( matcher.group( 1 ) );
496
      }
497
    }
498
499
    mTextArea.replaceSelection( newText );
500
  }
501
502
  /**
503
   * TODO: 105 - Insert key toggle overwrite (typeover) mode
504
   *
505
   * @param ignored Unused.
506
   */
507
  private void onInsertPressed( final KeyEvent ignored ) {
508
  }
509
510
  private void cut( final KeyEvent event ) {
511
    cut();
512
  }
513
514
  private void tab( final KeyEvent event ) {
515
    final var range = mTextArea.selectionProperty().getValue();
516
    final var sb = new StringBuilder( 1024 );
517
518
    if( range.getLength() > 0 ) {
519
      final var selection = mTextArea.getSelectedText();
520
521
      selection.lines().forEach(
522
        ( l ) -> sb.append( "\t" ).append( l ).append( NEWLINE )
523
      );
524
    }
525
    else {
526
      sb.append( "\t" );
527
    }
528
529
    mTextArea.replaceSelection( sb.toString() );
530
  }
531
532
  private void untab( final KeyEvent event ) {
533
    final var range = mTextArea.selectionProperty().getValue();
534
535
    if( range.getLength() > 0 ) {
536
      final var selection = mTextArea.getSelectedText();
537
      final var sb = new StringBuilder( selection.length() );
538
539
      selection.lines().forEach(
540
        ( l ) -> sb.append( l.startsWith( "\t" ) ? l.substring( 1 ) : l )
541
                   .append( NEWLINE )
542
      );
543
544
      mTextArea.replaceSelection( sb.toString() );
545
    }
546
    else {
547
      final var p = getCaretParagraph();
548
549
      if( p.startsWith( "\t" ) ) {
550
        mTextArea.selectParagraph();
551
        mTextArea.replaceSelection( p.substring( 1 ) );
552
      }
553
    }
554
  }
555
556
  /**
557
   * Observers may listen for changes to the property returned from this method
558
   * to receive notifications when either the text or caret have changed. This
559
   * should not be used to track whether the text has been modified.
560
   */
561
  public void addDirtyListener( ChangeListener<Boolean> listener ) {
562
    mDirty.addListener( listener );
563
  }
564
565
  /**
566
   * Surrounds the selected text or word under the caret in Markdown markup.
567
   *
568
   * @param token The beginning and ending token for enclosing the text.
569
   */
570
  private void enwrap( final String token ) {
571
    enwrap( token, token );
572
  }
573
574
  /**
575
   * Surrounds the selected text or word under the caret in Markdown markup.
576
   *
577
   * @param began The beginning token for enclosing the text.
578
   * @param ended The ending token for enclosing the text.
579
   */
580
  private void enwrap( final String began, String ended ) {
581
    // Ensure selected text takes precedence over the word at caret position.
582
    final var selected = mTextArea.selectionProperty().getValue();
583
    final var range = selected.getLength() == 0
584
      ? getCaretWord()
585
      : selected;
586
    String text = mTextArea.getText( range );
587
588
    int length = range.getLength();
589
    text = stripStart( text, null );
590
    final int beganIndex = range.getStart() + (length - text.length());
591
592
    length = text.length();
593
    text = stripEnd( text, null );
594
    final int endedIndex = range.getEnd() - (length - text.length());
595
596
    mTextArea.replaceText( beganIndex, endedIndex, began + text + ended );
597
  }
598
599
  /**
600
   * Inserts the given block-level markup at the current caret position
601
   * within the document. This will prepend two blank lines to ensure that
602
   * the block element begins at the start of a new line.
603
   *
604
   * @param markup The text to insert at the caret.
605
   */
606
  private void block( final String markup ) {
607
    final int pos = mTextArea.getCaretPosition();
608
    mTextArea.insertText( pos, format( "%n%n%s", markup ) );
609
  }
610
611
  /**
612
   * Returns the caret position within the current paragraph.
613
   *
614
   * @return A value from 0 to the length of the current paragraph.
615
   */
616
  private int getCaretColumn() {
617
    return mTextArea.getCaretColumn();
618
  }
619
620
  @Override
621
  public IndexRange getCaretWord() {
622
    final var paragraph = getCaretParagraph();
623
    final var length = paragraph.length();
624
    final var column = getCaretColumn();
625
626
    var began = column;
627
    var ended = column;
628
629
    while( began > 0 && !isWhitespace( paragraph.charAt( began - 1 ) ) ) {
630
      began--;
631
    }
632
633
    while( ended < length && !isWhitespace( paragraph.charAt( ended ) ) ) {
634
      ended++;
635
    }
636
637
    final var iterator = BreakIterator.getWordInstance();
638
    iterator.setText( paragraph );
639
640
    while( began < length && iterator.isBoundary( began + 1 ) ) {
641
      began++;
642
    }
643
644
    while( ended > 0 && iterator.isBoundary( ended - 1 ) ) {
645
      ended--;
646
    }
647
648
    final var offset = getCaretDocumentOffset( column );
649
650
    return IndexRange.normalize( began + offset, ended + offset );
651
  }
652
653
  private int getCaretDocumentOffset( final int column ) {
654
    return mTextArea.getCaretPosition() - column;
655
  }
656
657
  /**
658
   * Returns the index of the paragraph where the caret resides.
659
   *
660
   * @return A number greater than or equal to 0.
661
   */
662
  private int getCurrentParagraph() {
663
    return mTextArea.getCurrentParagraph();
664
  }
665
666
  /**
667
   * Returns the text for the paragraph that contains the caret.
668
   *
669
   * @return A non-null string, possibly empty.
670
   */
671
  private String getCaretParagraph() {
672
    return getText( getCurrentParagraph() );
673
  }
674
675
  @Override
676
  public String getText( final int paragraph ) {
677
    return mTextArea.getText( paragraph );
678
  }
679
680
  @Override
681
  public String getText( final IndexRange indexes )
682
    throws IndexOutOfBoundsException {
683
    return mTextArea.getText( indexes.getStart(), indexes.getEnd() );
684
  }
685
686
  @Override
687
  public void replaceText( final IndexRange indexes, final String s ) {
688
    mTextArea.replaceText( indexes, s );
689
  }
690
691
  private UndoManager<?> getUndoManager() {
692
    return mTextArea.getUndoManager();
693
  }
694
695
  /**
696
   * Returns the path to a {@link Locale}-specific stylesheet.
697
   *
698
   * @return A non-null string to inject into the HTML document head.
699
   */
700
  private static String getStylesheetPath( final Locale locale ) {
701
    return get(
702
      sSettings.getSetting( STYLESHEET_MARKDOWN_LOCALE, "" ),
703
      locale.getLanguage(),
704
      locale.getScript(),
705
      locale.getCountry()
706
    );
707
  }
708
709
  private Locale getLocale() {
710
    return localeProperty().toLocale();
711
  }
712
713
  private LocaleProperty localeProperty() {
714
    return mWorkspace.localeProperty( KEY_LANGUAGE_LOCALE );
42
import static javafx.application.Platform.runLater;
43
import static javafx.scene.control.ScrollPane.ScrollBarPolicy.ALWAYS;
44
import static javafx.scene.input.KeyCode.*;
45
import static javafx.scene.input.KeyCombination.CONTROL_DOWN;
46
import static javafx.scene.input.KeyCombination.SHIFT_DOWN;
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 final 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
136
    textArea.caretPositionProperty().addListener( ( c, o, n ) -> {
137
      // Fire when the caret position has changed and the text has not.
138
      mDirty.set( true );
139
      mDirty.set( false );
140
    } );
141
142
    textArea.focusedProperty().addListener( ( c, o, n ) -> {
143
      if( n != null && n ) {
144
        fireTextEditorFocus( this );
145
      }
146
    } );
147
  }
148
149
  private void initStyle( final StyleClassedTextArea textArea ) {
150
    textArea.getStyleClass().add( "markdown" );
151
152
    final var stylesheets = textArea.getStylesheets();
153
    stylesheets.add( getStylesheetPath( getLocale() ) );
154
155
    localeProperty().addListener( ( c, o, n ) -> {
156
      if( n != null ) {
157
        stylesheets.clear();
158
        stylesheets.add( getStylesheetPath( getLocale() ) );
159
      }
160
    } );
161
162
    fontNameProperty().addListener(
163
      ( c, o, n ) ->
164
        setFont( mTextArea, getFontName(), getFontSize() )
165
    );
166
167
    fontSizeProperty().addListener(
168
      ( c, o, n ) ->
169
        setFont( mTextArea, getFontName(), getFontSize() )
170
    );
171
172
    setFont( mTextArea, getFontName(), getFontSize() );
173
  }
174
175
  private void initScrollPane(
176
    final VirtualizedScrollPane<StyleClassedTextArea> scrollpane ) {
177
    scrollpane.setVbarPolicy( ALWAYS );
178
    setCenter( scrollpane );
179
  }
180
181
  private void initSpellchecker( final StyleClassedTextArea textarea ) {
182
    final var speller = new TextEditorSpeller();
183
    speller.checkDocument( textarea );
184
    speller.checkParagraphs( textarea );
185
  }
186
187
  private void initHotKeys() {
188
    addEventListener( keyPressed( ENTER ), this::onEnterPressed );
189
    addEventListener( keyPressed( X, CONTROL_DOWN ), this::cut );
190
    addEventListener( keyPressed( TAB ), this::tab );
191
    addEventListener( keyPressed( TAB, SHIFT_DOWN ), this::untab );
192
    addEventListener( keyPressed( INSERT ), this::onInsertPressed );
193
  }
194
195
  private void initUndoManager() {
196
    final var undoManager = getUndoManager();
197
    final var markedPosition = undoManager.atMarkedPositionProperty();
198
199
    undoManager.forgetHistory();
200
    undoManager.mark();
201
    mModified.bind( Bindings.not( markedPosition ) );
202
  }
203
204
  @Override
205
  public void moveTo( final int offset ) {
206
    assert 0 <= offset && offset <= mTextArea.getLength();
207
    mTextArea.moveTo( offset );
208
    mTextArea.requestFollowCaret();
209
  }
210
211
  /**
212
   * Delegate the focus request to the text area itself.
213
   */
214
  @Override
215
  public void requestFocus() {
216
    mTextArea.requestFocus();
217
  }
218
219
  @Override
220
  public void setText( final String text ) {
221
    mTextArea.clear();
222
    mTextArea.appendText( text );
223
    mTextArea.getUndoManager().mark();
224
  }
225
226
  @Override
227
  public String getText() {
228
    return mTextArea.getText();
229
  }
230
231
  @Override
232
  public Charset getEncoding() {
233
    return mEncoding;
234
  }
235
236
  @Override
237
  public File getFile() {
238
    return mFile;
239
  }
240
241
  @Override
242
  public void rename( final File file ) {
243
    mFile = file;
244
  }
245
246
  @Override
247
  public void undo() {
248
    final var manager = getUndoManager();
249
    xxdo( manager::isUndoAvailable, manager::undo, "Main.status.error.undo" );
250
  }
251
252
  @Override
253
  public void redo() {
254
    final var manager = getUndoManager();
255
    xxdo( manager::isRedoAvailable, manager::redo, "Main.status.error.redo" );
256
  }
257
258
  /**
259
   * Performs an undo or redo action, if possible, otherwise displays an error
260
   * message to the user.
261
   *
262
   * @param ready  Answers whether the action can be executed.
263
   * @param action The action to execute.
264
   * @param key    The informational message key having a value to display if
265
   *               the {@link Supplier} is not ready.
266
   */
267
  private void xxdo(
268
    final Supplier<Boolean> ready, final Runnable action, final String key ) {
269
    if( ready.get() ) {
270
      action.run();
271
    }
272
    else {
273
      clue( key );
274
    }
275
  }
276
277
  @Override
278
  public void cut() {
279
    final var selected = mTextArea.getSelectedText();
280
281
    // Emulate selecting the current line by firing Home then Shift+Down Arrow.
282
    if( selected == null || selected.isEmpty() ) {
283
      // Note: mTextArea.selectLine() does not select empty lines.
284
      mTextArea.fireEvent( keyDown( HOME, false ) );
285
      mTextArea.fireEvent( keyDown( DOWN, true ) );
286
    }
287
288
    mTextArea.cut();
289
  }
290
291
  @Override
292
  public void copy() {
293
    mTextArea.copy();
294
  }
295
296
  @Override
297
  public void paste() {
298
    mTextArea.paste();
299
  }
300
301
  @Override
302
  public void selectAll() {
303
    mTextArea.selectAll();
304
  }
305
306
  @Override
307
  public void bold() {
308
    enwrap( "**" );
309
  }
310
311
  @Override
312
  public void italic() {
313
    enwrap( "*" );
314
  }
315
316
  @Override
317
  public void superscript() {
318
    enwrap( "^" );
319
  }
320
321
  @Override
322
  public void subscript() {
323
    enwrap( "~" );
324
  }
325
326
  @Override
327
  public void strikethrough() {
328
    enwrap( "~~" );
329
  }
330
331
  @Override
332
  public void blockquote() {
333
    block( "> " );
334
  }
335
336
  @Override
337
  public void code() {
338
    enwrap( "`" );
339
  }
340
341
  @Override
342
  public void fencedCodeBlock() {
343
    enwrap( "\n\n```\n", "\n```\n\n" );
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
  private void onEnterPressed( final KeyEvent ignored ) {
482
    final var currentLine = getCaretParagraph();
483
    final var matcher = PATTERN_AUTO_INDENT.matcher( currentLine );
484
485
    // By default, insert a new line by itself.
486
    String newText = NEWLINE;
487
488
    // If the pattern was matched then determine what block type to continue.
489
    if( matcher.matches() ) {
490
      if( matcher.group( 2 ).isEmpty() ) {
491
        final var pos = mTextArea.getCaretPosition();
492
        mTextArea.selectRange( pos - currentLine.length(), pos );
493
      }
494
      else {
495
        // Indent the new line with the same whitespace characters and
496
        // list markers as current line. This ensures that the indentation
497
        // is propagated.
498
        newText = newText.concat( matcher.group( 1 ) );
499
      }
500
    }
501
502
    mTextArea.replaceSelection( newText );
503
  }
504
505
  /**
506
   * TODO: 105 - Insert key toggle overwrite (typeover) mode
507
   *
508
   * @param ignored Unused.
509
   */
510
  private void onInsertPressed( final KeyEvent ignored ) {
511
  }
512
513
  private void cut( final KeyEvent event ) {
514
    cut();
515
  }
516
517
  private void tab( final KeyEvent event ) {
518
    final var range = mTextArea.selectionProperty().getValue();
519
    final var sb = new StringBuilder( 1024 );
520
521
    if( range.getLength() > 0 ) {
522
      final var selection = mTextArea.getSelectedText();
523
524
      selection.lines().forEach(
525
        ( l ) -> sb.append( "\t" ).append( l ).append( NEWLINE )
526
      );
527
    }
528
    else {
529
      sb.append( "\t" );
530
    }
531
532
    mTextArea.replaceSelection( sb.toString() );
533
  }
534
535
  private void untab( final KeyEvent event ) {
536
    final var range = mTextArea.selectionProperty().getValue();
537
538
    if( range.getLength() > 0 ) {
539
      final var selection = mTextArea.getSelectedText();
540
      final var sb = new StringBuilder( selection.length() );
541
542
      selection.lines().forEach(
543
        ( l ) -> sb.append( l.startsWith( "\t" ) ? l.substring( 1 ) : l )
544
                   .append( NEWLINE )
545
      );
546
547
      mTextArea.replaceSelection( sb.toString() );
548
    }
549
    else {
550
      final var p = getCaretParagraph();
551
552
      if( p.startsWith( "\t" ) ) {
553
        mTextArea.selectParagraph();
554
        mTextArea.replaceSelection( p.substring( 1 ) );
555
      }
556
    }
557
  }
558
559
  /**
560
   * Observers may listen for changes to the property returned from this method
561
   * to receive notifications when either the text or caret have changed. This
562
   * should not be used to track whether the text has been modified.
563
   */
564
  public void addDirtyListener( ChangeListener<Boolean> listener ) {
565
    mDirty.addListener( listener );
566
  }
567
568
  /**
569
   * Surrounds the selected text or word under the caret in Markdown markup.
570
   *
571
   * @param token The beginning and ending token for enclosing the text.
572
   */
573
  private void enwrap( final String token ) {
574
    enwrap( token, token );
575
  }
576
577
  /**
578
   * Surrounds the selected text or word under the caret in Markdown markup.
579
   *
580
   * @param began The beginning token for enclosing the text.
581
   * @param ended The ending token for enclosing the text.
582
   */
583
  private void enwrap( final String began, String ended ) {
584
    // Ensure selected text takes precedence over the word at caret position.
585
    final var selected = mTextArea.selectionProperty().getValue();
586
    final var range = selected.getLength() == 0
587
      ? getCaretWord()
588
      : selected;
589
    String text = mTextArea.getText( range );
590
591
    int length = range.getLength();
592
    text = stripStart( text, null );
593
    final int beganIndex = range.getStart() + (length - text.length());
594
595
    length = text.length();
596
    text = stripEnd( text, null );
597
    final int endedIndex = range.getEnd() - (length - text.length());
598
599
    mTextArea.replaceText( beganIndex, endedIndex, began + text + ended );
600
  }
601
602
  /**
603
   * Inserts the given block-level markup at the current caret position
604
   * within the document. This will prepend two blank lines to ensure that
605
   * the block element begins at the start of a new line.
606
   *
607
   * @param markup The text to insert at the caret.
608
   */
609
  private void block( final String markup ) {
610
    final int pos = mTextArea.getCaretPosition();
611
    mTextArea.insertText( pos, format( "%n%n%s", markup ) );
612
  }
613
614
  /**
615
   * Returns the caret position within the current paragraph.
616
   *
617
   * @return A value from 0 to the length of the current paragraph.
618
   */
619
  private int getCaretColumn() {
620
    return mTextArea.getCaretColumn();
621
  }
622
623
  @Override
624
  public IndexRange getCaretWord() {
625
    final var paragraph = getCaretParagraph();
626
    final var length = paragraph.length();
627
    final var column = getCaretColumn();
628
629
    var began = column;
630
    var ended = column;
631
632
    while( began > 0 && !isWhitespace( paragraph.charAt( began - 1 ) ) ) {
633
      began--;
634
    }
635
636
    while( ended < length && !isWhitespace( paragraph.charAt( ended ) ) ) {
637
      ended++;
638
    }
639
640
    final var iterator = BreakIterator.getWordInstance();
641
    iterator.setText( paragraph );
642
643
    while( began < length && iterator.isBoundary( began + 1 ) ) {
644
      began++;
645
    }
646
647
    while( ended > 0 && iterator.isBoundary( ended - 1 ) ) {
648
      ended--;
649
    }
650
651
    final var offset = getCaretDocumentOffset( column );
652
653
    return IndexRange.normalize( began + offset, ended + offset );
654
  }
655
656
  private int getCaretDocumentOffset( final int column ) {
657
    return mTextArea.getCaretPosition() - column;
658
  }
659
660
  /**
661
   * Returns the index of the paragraph where the caret resides.
662
   *
663
   * @return A number greater than or equal to 0.
664
   */
665
  private int getCurrentParagraph() {
666
    return mTextArea.getCurrentParagraph();
667
  }
668
669
  /**
670
   * Returns the text for the paragraph that contains the caret.
671
   *
672
   * @return A non-null string, possibly empty.
673
   */
674
  private String getCaretParagraph() {
675
    return getText( getCurrentParagraph() );
676
  }
677
678
  @Override
679
  public String getText( final int paragraph ) {
680
    return mTextArea.getText( paragraph );
681
  }
682
683
  @Override
684
  public String getText( final IndexRange indexes )
685
    throws IndexOutOfBoundsException {
686
    return mTextArea.getText( indexes.getStart(), indexes.getEnd() );
687
  }
688
689
  @Override
690
  public void replaceText( final IndexRange indexes, final String s ) {
691
    mTextArea.replaceText( indexes, s );
692
  }
693
694
  private UndoManager<?> getUndoManager() {
695
    return mTextArea.getUndoManager();
696
  }
697
698
  /**
699
   * Returns the path to a {@link Locale}-specific stylesheet.
700
   *
701
   * @return A non-null string to inject into the HTML document head.
702
   */
703
  private static String getStylesheetPath( final Locale locale ) {
704
    return get(
705
      sSettings.getSetting( STYLESHEET_MARKDOWN_LOCALE, "" ),
706
      locale.getLanguage(),
707
      locale.getScript(),
708
      locale.getCountry()
709
    );
710
  }
711
712
  private Locale getLocale() {
713
    return localeProperty().toLocale();
714
  }
715
716
  private LocaleProperty localeProperty() {
717
    return mWorkspace.localeProperty( KEY_LANGUAGE_LOCALE );
718
  }
719
720
  /**
721
   * Sets the font family name and font size at the same time. When the
722
   * workspace is loaded, the default font values are changed, which results
723
   * in this method being called.
724
   *
725
   * @param area Change the font settings for this text area.
726
   * @param name New font family name to apply.
727
   * @param points New font size to apply (in points, not pixels).
728
   */
729
  private void setFont(
730
    final StyleClassedTextArea area, final String name, final double points ) {
731
    runLater( () -> area.setStyle(
732
      format(
733
        "-fx-font-family:'%s';-fx-font-size:%spx;", name, toPixels( points )
734
      )
735
    ) );
715736
  }
716737
M src/main/java/com/keenwrite/events/CaretNavigationEvent.java
22
package com.keenwrite.events;
33
4
import com.keenwrite.outline.DocumentOutline;
4
import com.keenwrite.ui.outline.DocumentOutline;
55
66
/**
A src/main/java/com/keenwrite/events/DocumentChangedEvent.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.events;
3
4
import org.jsoup.nodes.Document;
5
6
import static com.keenwrite.util.MurmurHash.hash32;
7
import static java.lang.System.currentTimeMillis;
8
9
/**
10
 * Collates information about an HTML document that has changed.
11
 */
12
public class DocumentChangedEvent implements AppEvent {
13
  private static final int SEED = (int) currentTimeMillis();
14
15
  private final String mText;
16
17
  /**
18
   * Hash the document so subscribers are only informed upon changes.
19
   */
20
  private static int sHash;
21
22
  /**
23
   * Creates an event with the new plain text document, having all variables
24
   * substituted and all markup removed.
25
   *
26
   * @param text The document text that has changed since the last time this
27
   *             type of event was fired.
28
   */
29
  private DocumentChangedEvent( final String text ) {
30
    mText = text;
31
  }
32
33
  /**
34
   * When the given document may have changed. This will only fire a change
35
   * event if the given document has changed from the last time this
36
   * event was fired. The document is first converted to plain text before
37
   * the comparison is made.
38
   *
39
   * @param html The document that may have changed.
40
   */
41
  public static void fireDocumentChangedEvent( final Document html ) {
42
    final var text = html.wholeText();
43
    final var hash = hash32( text, 0, text.length(), SEED );
44
45
    if( hash != sHash ) {
46
      sHash = hash;
47
      new DocumentChangedEvent( text ).fire();
48
    }
49
  }
50
51
  /**
52
   * Returns the text that has changed.
53
   *
54
   * @return The new document text.
55
   */
56
  public String getDocument() {
57
    return mText;
58
  }
59
60
  /**
61
   * Returns the document.
62
   *
63
   * @return The value from {@link #getDocument()}.
64
   */
65
  @Override
66
  public String toString() {
67
    return getDocument();
68
  }
69
}
170
M src/main/java/com/keenwrite/events/ParseHeadingEvent.java
5252
    assert text != null;
5353
    assert 1 <= level && level <= 6;
54
    assert offset >= 0;
54
    assert 0 <= offset;
5555
    new ParseHeadingEvent( level, text, offset ).fire();
5656
  }
...
7373
  }
7474
75
  /**
76
   * Returns an offset into the document where the heading is found.
77
   *
78
   * @return A zero-based document offset.
79
   */
7580
  public int getOffset() {
7681
    return mOffset;
M src/main/java/com/keenwrite/events/StatusEvent.java
6969
7070
    if( trace != null ) {
71
      sb.append( trace.getMessage().trim() ).append( NEWLINE );
7271
      stream( trace.getStackTrace() )
7372
        .takeWhile( StatusEvent::filter )
...
8584
      clazz.contains( "org.renjin." ) ||
8685
      clazz.contains( "sun." ) ||
86
      clazz.contains( "flexmark." ) ||
8787
      clazz.contains( "java." );
8888
  }
A src/main/java/com/keenwrite/events/WordCountEvent.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.events;
3
4
/**
5
 * Collates information about the word count changing.
6
 */
7
public class WordCountEvent implements AppEvent {
8
  /**
9
   * Number of words in the document.
10
   */
11
  private final int mCount;
12
13
  private WordCountEvent( final int count ) {
14
    mCount = count;
15
  }
16
17
  /**
18
   * Publishes an event that indicates the number of words in the document.
19
   *
20
   * @param count The approximate number of words in the document.
21
   */
22
  public static void fireWordCountEvent( final int count ) {
23
    new WordCountEvent( count ).fire();
24
  }
25
26
  public int getCount() {
27
    return mCount;
28
  }
29
}
130
A src/main/java/com/keenwrite/heuristics/package-info.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
3
/**
4
 * This package contains classes to help with word count. In logographic,
5
 * or other non-alphabetic languages, word tokenization cannot rely on
6
 * spaces. Instead, we need to employ a more sophisticated approach using
7
 * natural language parsing (NLP).
8
 */
9
package com.keenwrite.heuristics;
110
M src/main/java/com/keenwrite/io/MediaType.java
1717
 */
1818
public enum MediaType {
19
  APP_JAVA_OBJECT(
20
    APPLICATION, "x-java-serialized-object"
21
  ),
19
  /*
20
   * Internal values applied to non-editor tabs.
21
   */
2222
  APP_DOCUMENT_OUTLINE(
2323
    APPLICATION, "x-document-outline"
24
  ),
25
  APP_DOCUMENT_STATISTICS(
26
    APPLICATION, "x-document-statistics"
27
  ),
28
29
  /*
30
   * Internal values used to distinguish document outline tabs from editors.
31
   */
32
  APP_JAVA_OBJECT(
33
    APPLICATION, "x-java-serialized-object"
2434
  ),
2535
36
  /*
37
   * Standard font types.
38
   */
2639
  FONT_OTF( "otf" ),
2740
  FONT_TTF( "ttf" ),
2841
42
  /*
43
   * Standard image types.
44
   */
2945
  IMAGE_APNG( "apng" ),
3046
  IMAGE_ACES( "aces" ),
...
6884
  IMAGE_WMF( "wmf" ),
6985
86
  /*
87
   * Document types for editing or displaying documents, mix of standard and
88
   * application-specific.
89
   */
7090
  TEXT_HTML( TEXT, "html" ),
7191
  TEXT_MARKDOWN( TEXT, "markdown" ),
7292
  TEXT_PLAIN( TEXT, "plain" ),
7393
  TEXT_R_MARKDOWN( TEXT, "R+markdown" ),
7494
  TEXT_R_XML( TEXT, "R+xml" ),
7595
  TEXT_YAML( TEXT, "yaml" ),
7696
97
  /*
98
   * When all other lights go out.
99
   */
77100
  UNDEFINED( TypeName.UNDEFINED, "undefined" );
78101
D src/main/java/com/keenwrite/outline/DocumentOutline.java
1
package com.keenwrite.outline;
2
3
import com.keenwrite.events.Bus;
4
import com.keenwrite.events.ParseHeadingEvent;
5
import javafx.event.Event;
6
import javafx.event.EventDispatchChain;
7
import javafx.event.EventDispatcher;
8
import javafx.scene.control.TreeCell;
9
import javafx.scene.control.TreeItem;
10
import javafx.scene.control.TreeView;
11
import javafx.scene.input.MouseEvent;
12
import javafx.scene.text.Text;
13
import javafx.util.Callback;
14
import org.greenrobot.eventbus.Subscribe;
15
16
import static com.keenwrite.Constants.ICON_SIZE_DEFAULT;
17
import static com.keenwrite.events.Bus.register;
18
import static com.keenwrite.events.CaretNavigationEvent.fireCaretNavigationEvent;
19
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.valueOf;
20
import static de.jensd.fx.glyphs.fontawesome.utils.FontAwesomeIconFactory.get;
21
import static javafx.application.Platform.runLater;
22
import static javafx.scene.input.MouseButton.PRIMARY;
23
import static javafx.scene.input.MouseEvent.MOUSE_PRESSED;
24
25
public class DocumentOutline extends TreeView<ParseHeadingEvent> {
26
  private TreeItem<ParseHeadingEvent> mCurrent;
27
28
  /**
29
   * Registers with the {@link Bus}.
30
   */
31
  public DocumentOutline() {
32
    register( this );
33
34
    // Override double-click to issue a caret navigation event.
35
    setCellFactory( new Callback<>() {
36
      @Override
37
      public TreeCell<ParseHeadingEvent> call(
38
        TreeView<ParseHeadingEvent> treeView ) {
39
        TreeCell<ParseHeadingEvent> cell = new TreeCell<>() {
40
          @Override
41
          protected void updateItem( ParseHeadingEvent item, boolean empty ) {
42
            super.updateItem( item, empty );
43
            if( empty || item == null ) {
44
              setText( null );
45
              setGraphic( null );
46
            }
47
            else {
48
              setText( item.toString() );
49
              setGraphic( createIcon() );
50
            }
51
          }
52
        };
53
54
        cell.addEventFilter( MOUSE_PRESSED, event -> {
55
          if( event.getButton() == PRIMARY && event.getClickCount() % 2 == 0 ) {
56
            fireCaretNavigationEvent( cell.getItem().getOffset() );
57
            event.consume();
58
          }
59
        } );
60
61
        return cell;
62
      }
63
    } );
64
  }
65
66
  /**
67
   * Updates the {@link TreeView} with the given event data. This method will
68
   * track the most recently added {@link TreeItem} so that the nesting
69
   * hierarchy reflects the document hierarchy.
70
   *
71
   * @param event Represents a document heading to add to the tree.
72
   */
73
  @Subscribe
74
  public void handle( final ParseHeadingEvent event ) {
75
    runLater(
76
      () -> mCurrent = event.isNewOutline() ? clear( event ) : addItem( event )
77
    );
78
  }
79
80
  private TreeItem<ParseHeadingEvent> clear( final ParseHeadingEvent event ) {
81
    final var root = createTreeItem( event );
82
    setRoot( root );
83
    setShowRoot( false );
84
    return root;
85
  }
86
87
  /**
88
   * This method is called once for every heading in the document. The event
89
   * data directly corresponds to the sequence of headings in the document.
90
   * The given event data contains a level that is relative to the last
91
   * item in the tree.
92
   *
93
   * @param next Contains a level value to indicate heading depth.
94
   */
95
  private TreeItem<ParseHeadingEvent> addItem( final ParseHeadingEvent next ) {
96
    var parent = mCurrent;
97
    final var item = createTreeItem( next );
98
    final var curr = parent.getValue();
99
    final var currLevel = curr.getLevel();
100
    final var nextLevel = next.getLevel();
101
    var deltaLevel = currLevel - nextLevel + 1;
102
103
    while( deltaLevel > 0 && parent != null ) {
104
      parent = parent.getParent();
105
      deltaLevel--;
106
    }
107
108
    if( parent == null ) {
109
      parent = getRoot();
110
    }
111
112
    parent.getChildren().add( item );
113
114
    return item;
115
  }
116
117
  private TreeItem<ParseHeadingEvent> createTreeItem(
118
    final ParseHeadingEvent event ) {
119
    final var item = new TreeItem<>( event, createIcon() );
120
    item.setExpanded( true );
121
    return item;
122
  }
123
124
  private Text createIcon() {
125
    return get().createIcon( valueOf( "BOOKMARK" ), ICON_SIZE_DEFAULT );
126
  }
127
128
  private class TreeMouseEventDispatcher implements EventDispatcher {
129
    private final EventDispatcher mDispatcher;
130
131
    public TreeMouseEventDispatcher( final EventDispatcher dispatcher ) {
132
      mDispatcher = dispatcher;
133
    }
134
135
    @Override
136
    public Event dispatchEvent( final Event e, final EventDispatchChain tail ) {
137
      if( e instanceof MouseEvent ) {
138
        final var event = (MouseEvent) e;
139
        if( event.getButton() == PRIMARY && event.getClickCount() >= 2 ) {
140
          e.consume();
141
        }
142
      }
143
144
      return mDispatcher.dispatchEvent( e, tail );
145
    }
146
  }
147
}
1481
M src/main/java/com/keenwrite/preview/HtmlPanel.java
1919
2020
import static com.keenwrite.events.FileOpenEvent.fireFileOpenEvent;
21
import static com.keenwrite.events.DocumentChangedEvent.fireDocumentChangedEvent;
2122
import static com.keenwrite.events.StatusEvent.clue;
2223
import static com.keenwrite.util.ProtocolScheme.getProtocol;
...
106107
   */
107108
  public void render( final String html, final String baseUri ) {
108
    final var doc = CONVERTER.fromJsoup( parse( html ) );
109
    final var soup = parse( html );
110
    final var doc = CONVERTER.fromJsoup( soup );
109111
    final Runnable renderDocument = () -> setDocument( doc, baseUri, XNH );
110112
...
118120
      invokeLater( renderDocument );
119121
    }
122
123
    // When the text changes, let subscribers know. This allows for text
124
    // analysis to occur on a separate thread.
125
    fireDocumentChangedEvent( soup );
120126
  }
121127
M src/main/java/com/keenwrite/preview/HtmlPreview.java
134134
135135
  /**
136
   * Clears the caches then rerenders the content.
136
   * Clears the caches then re-renders the content.
137137
   */
138138
  public void refresh() {
...
183183
      url == null ? "" : format( HTML_STYLESHEET, url ),
184184
      getFontFamily(),
185
      (int) (getFontSize() * (1 + 1 / 3f)),
185
      toPixels( getFontSize() ),
186186
      base.isBlank() ? "" : format( HTML_BASE, base )
187187
    );
D src/main/java/com/keenwrite/preview/SmoothScrollPane.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.preview;
3
4
import javax.swing.*;
5
import java.awt.*;
6
import java.util.function.Consumer;
7
8
import static java.awt.Scrollbar.VERTICAL;
9
import static java.lang.Math.min;
10
import static javafx.animation.Interpolator.EASE_BOTH;
11
12
/**
13
 * Responsible for smoothing out the scrolling using an easing algorithm.
14
 *
15
 * @deprecated Does not refresh properly, has tearing of large images, and
16
 * jerks around when dragging the thumb (track).
17
 */
18
@Deprecated
19
public class SmoothScrollPane extends JScrollPane {
20
21
  public SmoothScrollPane( final Component component ) {
22
    super( component );
23
    setVerticalScrollBarPolicy( VERTICAL_SCROLLBAR_ALWAYS );
24
  }
25
26
  @Override
27
  public ScrollBar createVerticalScrollBar() {
28
    return new SmoothScrollBar( VERTICAL );
29
  }
30
31
  private class SmoothScrollBar extends ScrollBar implements Consumer<Integer> {
32
    private final Animator mAnimator = new Animator( this, () -> {
33
      // Fails to fix refresh problems when scrolling finishes. This is the
34
      // reason the class is deprecated. Calling invokeLater helps a little.
35
      SmoothScrollPane.this.getViewport().revalidate();
36
      revalidate();
37
      repaint();
38
    } );
39
40
    public SmoothScrollBar( final int orientation ) {
41
      super( orientation );
42
    }
43
44
    @Override
45
    public void setValue( final int nPos ) {
46
      final var oPos = getModel().getValue();
47
48
      mAnimator.stop();
49
      mAnimator.restart( oPos, nPos, 250 );
50
      new Thread( mAnimator ).start();
51
    }
52
53
    @Override
54
    public void accept( final Integer nPos ) {
55
      super.setValue( nPos );
56
    }
57
  }
58
59
  private static class Animator implements Runnable {
60
    private final Consumer<Integer> mAction;
61
    private final Runnable mComplete;
62
63
    private int mOldPos;
64
    private int mNewPos;
65
    private long mBegan;
66
    private long mEnded;
67
    private volatile boolean mRunning;
68
69
    public Animator( final Consumer<Integer> action, final Runnable complete ) {
70
      mAction = action;
71
      mComplete = complete;
72
    }
73
74
    /**
75
     * @param oPos Old scroll bar position.
76
     * @param nPos New scroll bar position.
77
     * @param time Total time to complete the scroll event (in milliseconds).
78
     */
79
    public void restart( final int oPos, final int nPos, final int time ) {
80
      mOldPos = oPos;
81
      mNewPos = nPos;
82
      mBegan = System.nanoTime();
83
      mEnded = time * 1_000_000L;
84
      mRunning = true;
85
    }
86
87
    public void stop() {
88
      mRunning = false;
89
    }
90
91
    @Override
92
    public void run() {
93
      double ratio;
94
95
      do {
96
        ratio = min( (double) (System.nanoTime() - mBegan) / mEnded, 1.0 );
97
        final int nPos = EASE_BOTH.interpolate( mOldPos, mNewPos, ratio );
98
99
        mAction.accept( nPos );
100
      } while( ratio <= 1 && mRunning );
101
102
      mComplete.run();
103
    }
104
  }
105
}
1061
M src/main/java/com/keenwrite/processors/ProcessorFactory.java
4444
4545
    final var processor = switch( context.getFileType() ) {
46
      //case RMARKDOWN -> createRProcessor( successor );
4746
      case SOURCE, RMARKDOWN -> createMarkdownProcessor( successor );
48
      case RXML -> createRXMLProcessor( successor );
49
      case XML -> createXMLProcessor( successor );
47
      case RXML -> createRXmlProcessor( successor );
48
      case XML -> createXmlProcessor( successor );
5049
      default -> createPreformattedProcessor( successor );
5150
    };
...
102101
    final Processor<String> successor ) {
103102
    return new DefinitionProcessor( successor, getProcessorContext() );
104
  }
105
106
  private Processor<String> createRProcessor(
107
    final Processor<String> successor ) {
108
    return MarkdownProcessor.create( successor, getProcessorContext() );
109103
  }
110104
111
  protected Processor<String> createRXMLProcessor(
105
  protected Processor<String> createRXmlProcessor(
112106
    final Processor<String> successor ) {
113
    final var xmlp = new XmlProcessor( successor, getProcessorContext() );
114
    return createRProcessor( xmlp );
107
    final var context = getProcessorContext();
108
    final var rp = MarkdownProcessor.create( successor, context );
109
    return new XmlProcessor( rp, context );
115110
  }
116111
117
  private Processor<String> createXMLProcessor(
112
  private Processor<String> createXmlProcessor(
118113
    final Processor<String> successor ) {
119114
    final var xmlp = new XmlProcessor( successor, getProcessorContext() );
M src/main/java/com/keenwrite/processors/XmlProcessor.java
22
package com.keenwrite.processors;
33
4
import net.sf.saxon.Configuration;
45
import net.sf.saxon.TransformerFactoryImpl;
6
import net.sf.saxon.om.IgnorableSpaceStrippingRule;
57
import net.sf.saxon.trans.XPathException;
68
...
1820
import java.nio.file.Paths;
1921
22
import static com.keenwrite.events.StatusEvent.clue;
2023
import static javax.xml.stream.XMLInputFactory.newInstance;
2124
import static net.sf.saxon.tree.util.ProcInstParser.getPseudoAttribute;
...
3639
3740
  private final XMLInputFactory mXmlInputFactory = newInstance();
41
  private final Configuration mConfiguration = new Configuration();
3842
  private final TransformerFactory mTransformerFactory =
39
    new TransformerFactoryImpl();
43
    new TransformerFactoryImpl(mConfiguration);
4044
  private Transformer mTransformer;
4145
...
5963
    // Bubble problems up to the user interface, rather than standard error.
6064
    mTransformerFactory.setErrorListener( this );
65
    final var options = mConfiguration.getParseOptions();
66
    options.setSpaceStrippingRule( IgnorableSpaceStrippingRule.getInstance() );
6167
  }
6268
...
7278
      return text.isEmpty() ? text : transform( text );
7379
    } catch( final Exception ex ) {
80
      clue( ex );
7481
      throw new RuntimeException( ex );
7582
    }
...
8592
   */
8693
  private String transform( final String text ) throws Exception {
87
    // Extract the XML stylesheet processing instruction.
88
    final String template = getXsltFilename( text );
89
    final Path xsl = getXslPath( template );
90
9194
    try(
92
      final StringWriter output = new StringWriter( text.length() );
93
      final StringReader input = new StringReader( text ) ) {
95
      final var output = new StringWriter( text.length() );
96
      final var input = new StringReader( text ) ) {
97
      // Extract the XML stylesheet processing instruction.
98
      final var template = getXsltFilename( text );
99
      final var xsl = getXslPath( template );
94100
95101
      // TODO: Use FileWatchService
...
155161
  private String getXsltFilename( final String xml )
156162
    throws XMLStreamException, XPathException {
157
    String result = "";
163
    var result = "";
158164
159
    try( final StringReader sr = new StringReader( xml ) ) {
160
      final XMLEventReader reader = createXmlEventReader( sr );
161
      boolean found = false;
162
      int count = 0;
165
    try( final var sr = new StringReader( xml ) ) {
166
      final var reader = createXmlEventReader( sr );
167
      var found = false;
168
      var count = 0;
163169
164170
      // If the processing instruction wasn't found in the first 10 lines,
...
198204
  @Override
199205
  public void warning( final TransformerException ex ) {
200
    throw new RuntimeException( ex );
206
    clue( ex );
201207
  }
202208
203209
  /**
204210
   * Called when the XSL transformer issues an error.
205211
   *
206212
   * @param ex The problem the transformer encountered.
207213
   */
208214
  @Override
209215
  public void error( final TransformerException ex ) {
210
    throw new RuntimeException( ex );
216
    clue( ex );
211217
  }
212218
213219
  /**
214220
   * Called when the XSL transformer issues a fatal error, which is probably
215
   * a bit over-dramatic a method name.
221
   * a bit over-dramatic for a method name.
216222
   *
217223
   * @param ex The problem the transformer encountered.
218224
   */
219225
  @Override
220226
  public void fatalError( final TransformerException ex ) {
221
    throw new RuntimeException( ex );
227
    clue( ex );
222228
  }
223229
}
M src/main/java/com/keenwrite/processors/markdown/BaseMarkdownProcessor.java
3535
    super( successor );
3636
37
    final var extensions = new ArrayList<Extension>();
38
    init( extensions, context );
37
    final var extensions = createExtensions( context );
3938
4039
    mParser = Parser.builder().extensions( extensions ).build();
...
4746
   * HTML entities.
4847
   *
49
   * @param extensions A {@link List} of {@link Extension} instances that
50
   *                   change the {@link Parser}'s behaviour.
51
   * @param context    The context that subclasses use to configure custom
52
   *                   extension behaviour.
48
   * @param context The context that subclasses use to configure custom
49
   *                extension behaviour.
50
   * @return A {@link List} of {@link Extension} instances that change the
51
   * {@link Parser}'s behaviour.
5352
   */
54
  void init(
55
    final List<Extension> extensions, final ProcessorContext context ) {
53
  List<Extension> createExtensions( final ProcessorContext context ) {
54
    final var extensions = new ArrayList<Extension>();
55
5656
    extensions.add( DefinitionExtension.create() );
5757
    extensions.add( StrikethroughSubscriptExtension.create() );
5858
    extensions.add( SuperscriptExtension.create() );
5959
    extensions.add( TablesExtension.create() );
6060
    extensions.add( TypographicExtension.create() );
61
62
    return extensions;
6163
  }
6264
M src/main/java/com/keenwrite/processors/markdown/MarkdownProcessor.java
33
44
import com.keenwrite.io.MediaType;
5
import com.keenwrite.processors.DefinitionProcessor;
56
import com.keenwrite.processors.Processor;
67
import com.keenwrite.processors.ProcessorContext;
7
import com.keenwrite.processors.markdown.extensions.DocumentOutlineExtension;
8
import com.keenwrite.processors.markdown.extensions.FencedBlockExtension;
9
import com.keenwrite.processors.markdown.extensions.ImageLinkExtension;
10
import com.keenwrite.processors.markdown.extensions.CaretExtension;
8
import com.keenwrite.processors.markdown.extensions.*;
119
import com.keenwrite.processors.markdown.extensions.r.RExtension;
1210
import com.keenwrite.processors.markdown.extensions.tex.TeXExtension;
1311
import com.keenwrite.processors.r.RProcessor;
1412
import com.vladsch.flexmark.util.misc.Extension;
1513
14
import java.util.ArrayList;
1615
import java.util.List;
1716
...
4847
   * formats can be edited.
4948
   *
50
   * @param extensions {@link List} of extensions invoked when parsing Markdown.
51
   * @param context    Contains necessary information needed to create
52
   *                   extensions used by the Markdown parser.
49
   * @param context Contains necessary information needed to create
50
   *                extensions used by the Markdown parser.
51
   * @return {@link List} of extensions invoked when parsing Markdown.
5352
   */
5453
  @Override
55
  void init(
56
    final List<Extension> extensions, final ProcessorContext context ) {
54
  List<Extension> createExtensions( final ProcessorContext context ) {
5755
    final var editorFile = context.getDocumentPath();
5856
    final var mediaType = MediaType.valueFrom( editorFile );
5957
    final Processor<String> processor;
58
    final List<Extension> extensions = new ArrayList<>();
6059
6160
    if( mediaType == TEXT_R_MARKDOWN || mediaType == TEXT_R_XML ) {
6261
      final var rProcessor = new RProcessor( context );
6362
      extensions.add( RExtension.create( rProcessor, context ) );
6463
      processor = rProcessor;
6564
    }
6665
    else {
67
      processor = IDENTITY;
66
      processor = new DefinitionProcessor( IDENTITY, context );
6867
    }
6968
7069
    // Add typographic, table, strikethrough, and similar extensions.
71
    super.init( extensions, context );
70
    extensions.addAll( super.createExtensions( context ) );
7271
7372
    extensions.add( ImageLinkExtension.create( context ) );
74
    extensions.add( TeXExtension.create( context, processor ) );
75
    extensions.add( FencedBlockExtension.create( context ) );
73
    extensions.add( TeXExtension.create( processor, context ) );
74
    extensions.add( FencedBlockExtension.create( processor ) );
7675
    extensions.add( CaretExtension.create( context ) );
7776
    extensions.add( DocumentOutlineExtension.create( processor ) );
77
    return extensions;
7878
  }
7979
}
M src/main/java/com/keenwrite/processors/markdown/extensions/DocumentOutlineExtension.java
4141
4242
  private class HeadingNodePostProcessor extends NodePostProcessor {
43
4443
    @Override
4544
    public void process(
M src/main/java/com/keenwrite/processors/markdown/extensions/FencedBlockExtension.java
33
44
import com.keenwrite.processors.DefinitionProcessor;
5
import com.keenwrite.processors.ProcessorContext;
5
import com.keenwrite.processors.Processor;
66
import com.keenwrite.processors.markdown.MarkdownProcessor;
77
import com.vladsch.flexmark.ast.FencedCodeBlock;
...
2020
import static com.keenwrite.Constants.DIAGRAM_SERVER_NAME;
2121
import static com.keenwrite.events.StatusEvent.clue;
22
import static com.keenwrite.processors.IdentityProcessor.IDENTITY;
2322
import static com.vladsch.flexmark.html.HtmlRenderer.Builder;
2423
import static com.vladsch.flexmark.html.renderer.LinkType.LINK;
...
3635
  private final static int DIAGRAM_STYLE_LEN = DIAGRAM_STYLE.length();
3736
38
  private final DefinitionProcessor mProcessor;
37
  private final Processor<String> mProcessor;
3938
40
  public FencedBlockExtension( final ProcessorContext context ) {
41
    assert context != null;
42
    mProcessor = new DefinitionProcessor( IDENTITY, context );
39
  public FencedBlockExtension( final Processor<String> processor ) {
40
    assert processor != null;
41
    mProcessor = processor;
4342
  }
4443
...
5756
   * </p>
5857
   *
59
   * @param context Used to create a new {@link DefinitionProcessor}.
58
   * @param processor Used to pre-process the text.
6059
   * @return A new {@link FencedBlockExtension} capable of shunting ASCII
6160
   * diagrams to a service for conversion to SVG.
6261
   */
63
  public static FencedBlockExtension create( final ProcessorContext context ) {
64
    return new FencedBlockExtension( context );
62
  public static FencedBlockExtension create(
63
    final Processor<String> processor ) {
64
    return new FencedBlockExtension( processor );
6565
  }
6666
...
9595
          final var type = style.substring( DIAGRAM_STYLE_LEN );
9696
          final var content = node.getContentChars().normalizeEOL();
97
          final var text = FencedBlockExtension.this.mProcessor.apply( content );
97
          final var text = mProcessor.apply( content );
9898
          final var encoded = encode( text );
9999
          final var source = format(
M src/main/java/com/keenwrite/processors/markdown/extensions/ImageLinkExtension.java
3636
  private final ExportFormat mExportFormat;
3737
38
  private ImageLinkExtension( @NotNull final ProcessorContext context ) {
38
  private ImageLinkExtension(
39
    @NotNull final ProcessorContext context ) {
3940
    mBaseDir = context.getBaseDir();
4041
    mWorkspace = context.getWorkspace();
...
5657
  public void extend(
5758
    @NotNull final Builder builder, @NotNull final String rendererType ) {
58
    builder.linkResolverFactory( new Factory() );
59
    builder.linkResolverFactory( new ResolverFactory() );
5960
  }
6061
61
  private class Factory extends IndependentLinkResolverFactory {
62
  private final class ResolverFactory extends IndependentLinkResolverFactory {
6263
    @Override
6364
    public @NotNull LinkResolver apply(
...
7778
      @NotNull final LinkResolverBasicContext context,
7879
      @NotNull final ResolvedLink link ) {
79
      return node instanceof Image ? resolve( link ) : link;
80
      return node instanceof Image ? forImage( link ) : link;
8081
    }
8182
...
9394
     * @return The {@link ResolvedLink} instance used to render the link.
9495
     */
95
    private ResolvedLink resolve( final ResolvedLink link ) {
96
    private ResolvedLink forImage( final ResolvedLink link ) {
9697
      var uri = link.getUrl();
9798
      final var protocol = getProtocol( uri );
M src/main/java/com/keenwrite/processors/markdown/extensions/r/RExtension.java
3636
 */
3737
public final class RExtension implements ParserExtension {
38
  private final InlineParserFactory FACTORY = CustomParser::new;
38
  private final InlineParserFactory INLINE_FACTORY = InlineParser::new;
3939
  private final RProcessor mProcessor;
4040
  private final BaseMarkdownProcessor mMarkdownProcessor;
...
5757
  @Override
5858
  public void extend( final Builder builder ) {
59
    builder.customInlineParserFactory( FACTORY );
59
    builder.customInlineParserFactory( INLINE_FACTORY );
6060
  }
6161
...
7676
   * </p>
7777
   */
78
  private class CustomParser extends InlineParserImpl {
79
    private CustomParser(
78
  private class InlineParser extends InlineParserImpl {
79
    private InlineParser(
8080
      final DataHolder options,
8181
      final BitSet specialCharacters,
...
9797
     * changes the behaviour to retain R code snippets, identified by
9898
     * {@link RSigilOperator#PREFIX}, so that subsequent processing can
99
     * invoke R. If other languages are added, the {@link CustomParser} will
99
     * invoke R. If other languages are added, the {@link InlineParser} will
100100
     * have to be rewritten to identify more than merely R.
101101
     *
M src/main/java/com/keenwrite/processors/markdown/extensions/tex/TeXExtension.java
2727
2828
  /**
29
   * Responsible for pre-parsing the input.
30
   */
31
  private final Processor<String> mProcessor;
32
33
  /**
2934
   * Controls how the node renderer produces TeX code within HTML output.
3035
   */
3136
  private final ExportFormat mExportFormat;
32
33
  private final Processor<String> mProcessor;
3437
3538
  private TeXExtension(
36
    final ProcessorContext context, final Processor<String> processor ) {
37
    mExportFormat = context.getExportFormat();
39
    final Processor<String> processor, final ProcessorContext context  ) {
3840
    mProcessor = processor;
41
    mExportFormat = context.getExportFormat();
3942
  }
4043
4144
  /**
4245
   * Creates an extension capable of handling delimited TeX code in Markdown.
4346
   *
4447
   * @return The new {@link TeXExtension}, never {@code null}.
4548
   */
4649
  public static TeXExtension create(
47
    final ProcessorContext context, final Processor<String> processor ) {
48
    return new TeXExtension( context, processor );
50
    final Processor<String> processor, final ProcessorContext context  ) {
51
    return new TeXExtension( processor, context );
4952
  }
5053
M src/main/java/com/keenwrite/spelling/api/package-info.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved.
2
 *
3
 * Redistribution and use in source and binary forms, with or without
4
 * modification, are permitted provided that the following conditions are met:
5
 *
6
 *  o Redistributions of source code must retain the above copyright
7
 *    notice, this list of conditions and the following disclaimer.
8
 *
9
 *  o Redistributions in binary form must reproduce the above copyright
10
 *    notice, this list of conditions and the following disclaimer in the
11
 *    documentation and/or other materials provided with the distribution.
12
 *
13
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
14
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
15
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
16
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
17
 * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
18
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
19
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
20
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
21
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
22
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
23
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
24
 */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
252
263
/**
27
 * This package defines interfaces for spell checking implementations.
4
 * This package contains interfaces for spell checking implementations.
285
 */
296
package com.keenwrite.spelling.api;
M src/main/java/com/keenwrite/spelling/impl/SymSpellSpeller.java
3030
public class SymSpellSpeller implements SpellChecker {
3131
  private final BreakIterator mBreakIterator = BreakIterator.getWordInstance();
32
3332
  private final SymSpell mSymSpell;
3433
...
5352
5453
  private static SpellChecker forLexicon(
55
      final Collection<String> lexiconWords ) {
54
    final Collection<String> lexiconWords ) {
5655
    assert lexiconWords != null && !lexiconWords.isEmpty();
5756
5857
    final var builder = new SymSpellBuilder()
59
        .setLexiconWords( lexiconWords );
58
      .setLexiconWords( lexiconWords );
6059
6160
    return new SymSpellSpeller( builder.build() );
...
9695
  @Override
9796
  public void proofread(
98
      final String text, final SpellCheckListener consumer ) {
97
    final String text, final SpellCheckListener consumer ) {
9998
    assert text != null;
10099
    assert consumer != null;
101100
102101
    mBreakIterator.setText( text );
103102
104103
    int boundaryIndex = mBreakIterator.first();
105104
    int previousIndex = 0;
106105
107106
    while( boundaryIndex != BreakIterator.DONE ) {
108
      final var lex = text.substring( previousIndex, boundaryIndex )
109
                          .toLowerCase();
107
      final var lex =
108
        text.substring( previousIndex, boundaryIndex ).toLowerCase();
110109
111110
      // Get the lexeme for the possessive.
...
122121
  }
123122
124
  @SuppressWarnings("SameParameterValue")
123
  @SuppressWarnings( "SameParameterValue" )
125124
  private static Collection<String> readLexicon( final String filename )
126
      throws Exception {
125
    throws Exception {
127126
    final var path = '/' + LEXICONS_DIRECTORY + '/' + filename;
128127
129128
    try( final var resource =
130
             SymSpellSpeller.class.getResourceAsStream( path ) ) {
129
           SymSpellSpeller.class.getResourceAsStream( path ) ) {
131130
      if( resource == null ) {
132131
        throw new MissingFileException( path );
M src/main/java/com/keenwrite/spelling/impl/package-info.java
1
/* Copyright 2020-2021 White Magic Software, Ltd.
2
 *
3
 * All rights reserved.
4
 *
5
 * Redistribution and use in source and binary forms, with or without
6
 * modification, are permitted provided that the following conditions are met:
7
 *
8
 *  o Redistributions of source code must retain the above copyright
9
 *    notice, this list of conditions and the following disclaimer.
10
 *
11
 *  o Redistributions in binary form must reproduce the above copyright
12
 *    notice, this list of conditions and the following disclaimer in the
13
 *    documentation and/or other materials provided with the distribution.
14
 *
15
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
16
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
17
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
18
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
19
 * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
20
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
21
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
22
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
23
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
24
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
25
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
26
 */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
272
283
/**
M src/main/java/com/keenwrite/ui/actions/Action.java
1818
import java.util.List;
1919
20
import static com.keenwrite.Constants.ACTION_PREFIX;
2021
import static de.jensd.fx.glyphs.fontawesome.utils.FontAwesomeIconFactory.get;
2122
import static javafx.scene.input.KeyCombination.valueOf;
...
147148
    /**
148149
     * Sets the text, icon, and accelerator for a given action identifier.
149
     * See the "App.action" entries in the messages properties file for details.
150
     * See the messages properties file for details.
150151
     *
151152
     * @param id The identifier to look up in the properties file.
152153
     * @return An instance of {@link Builder} that can be built into an
153154
     * instance of {@link Action}.
154155
     */
155156
    public Builder setId( final String id ) {
156
      final var prefix = "App.action." + id + ".";
157
      final var prefix = ACTION_PREFIX + id + ".";
157158
      final var text = prefix + "text";
158159
      final var icon = prefix + "icon";
M src/main/java/com/keenwrite/ui/actions/ApplicationActions.java
366366
  }
367367
368
  public void view‿statistics() {
369
    getMainPane().viewStatistics();
370
  }
371
368372
  public void view‿menubar() {
369373
    getMainScene().toggleMenuBar();
M src/main/java/com/keenwrite/ui/actions/ApplicationBars.java
120120
      addAction( "view.preview", e -> actions.view‿preview() ),
121121
      addAction( "view.outline", e -> actions.view‿outline() ),
122
      addAction( "view.statistics", e-> actions.view‿statistics() ),
122123
      SEPARATOR_ACTION,
123124
      addAction( "view.menubar", e -> actions.view‿menubar() ),
M src/main/java/com/keenwrite/ui/actions/FileChooserCommand.java
6060
      "Dialog.file.choose.open.title" );
6161
    final var list = dialog.showOpenMultipleDialog( mParent );
62
    final List<java.io.File> selected = list == null ? List.of() : list;
62
    final List<File> selected = list == null ? List.of() : list;
6363
    final var files = new ArrayList<File>( selected.size() );
6464
...
109109
  /**
110110
   * Opens a new {@link FileChooser} at the previously selected directory.
111
   * If the initial directory is missing, this will attempt to default to
112
   * the user's home directory. If the home directory is missing, this will
113
   * use whatever JavaFX chooses for the initial directory. Without such an
114
   * intervention, an {@link IllegalArgumentException} would be thrown.
111115
   *
112116
   * @param key Message key from resource bundle.
113117
   * @return {@link FileChooser} GUI allowing the user to pick a file.
114118
   */
115119
  private FileChooser createFileChooser( final String key ) {
120
    final var prefDir = mDirectory.getValue();
121
    final var openDir = prefDir.isDirectory() ? prefDir : USER_DIRECTORY;
116122
    final var chooser = new FileChooser();
117123
118124
    chooser.setTitle( get( key ) );
119125
    chooser.getExtensionFilters().addAll( createExtensionFilters() );
120
    chooser.setInitialDirectory( mDirectory.getValue() );
126
    chooser.setInitialDirectory( openDir.isDirectory() ? openDir : null );
121127
122128
    return chooser;
M src/main/java/com/keenwrite/ui/actions/package-info.java
1
/* Copyright 2020-2021 White Magic Software, Ltd.
2
 *
3
 * All rights reserved.
4
 *
5
 * Redistribution and use in source and binary forms, with or without
6
 * modification, are permitted provided that the following conditions are met:
7
 *
8
 *  o Redistributions of source code must retain the above copyright
9
 *    notice, this list of conditions and the following disclaimer.
10
 *
11
 *  o Redistributions in binary form must reproduce the above copyright
12
 *    notice, this list of conditions and the following disclaimer in the
13
 *    documentation and/or other materials provided with the distribution.
14
 *
15
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
16
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
17
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
18
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
19
 * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
20
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
21
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
22
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
23
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
24
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
25
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
26
 */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
272
283
/**
A src/main/java/com/keenwrite/ui/heuristics/DocumentStatistics.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.ui.heuristics;
3
4
import com.keenwrite.events.DocumentChangedEvent;
5
import com.keenwrite.preferences.Workspace;
6
import com.keenwrite.preview.HtmlPanel;
7
import com.keenwrite.util.MurmurHash;
8
import com.whitemagicsoftware.wordcount.Tokenizer;
9
import com.whitemagicsoftware.wordcount.TokenizerException;
10
import com.whitemagicsoftware.wordcount.TokenizerFactory;
11
import javafx.beans.property.IntegerProperty;
12
import javafx.beans.property.SimpleIntegerProperty;
13
import javafx.beans.property.SimpleStringProperty;
14
import javafx.beans.property.StringProperty;
15
import javafx.collections.ObservableList;
16
import javafx.collections.transformation.SortedList;
17
import javafx.scene.control.TableColumn;
18
import javafx.scene.control.TableView;
19
import org.greenrobot.eventbus.Subscribe;
20
import org.jsoup.Jsoup;
21
22
import java.util.Locale;
23
24
import static com.keenwrite.events.Bus.register;
25
import static com.keenwrite.events.StatusEvent.clue;
26
import static com.keenwrite.events.WordCountEvent.fireWordCountEvent;
27
import static com.keenwrite.preferences.WorkspaceKeys.KEY_LANGUAGE_LOCALE;
28
import static com.keenwrite.preferences.WorkspaceKeys.KEY_UI_FONT_EDITOR_NAME;
29
import static java.lang.String.format;
30
import static java.util.Locale.ENGLISH;
31
import static javafx.application.Platform.runLater;
32
import static javafx.collections.FXCollections.observableArrayList;
33
34
/**
35
 * Responsible for displaying document statistics, such as word count and
36
 * word frequency.
37
 */
38
public final class DocumentStatistics
39
  extends TableView<DocumentStatistics.StatEntry> {
40
  /**
41
   * Parses documents into word counts.
42
   */
43
  private static Tokenizer sTokenizer = createTokenizer( ENGLISH );
44
45
  private final ObservableList<StatEntry> mItems = observableArrayList();
46
47
  /**
48
   * Creates a new observer of document change events that will gather and
49
   * display document statistics (e.g., word counts).
50
   *
51
   * @param workspace Settings used to configure the statistics engine.
52
   */
53
  public DocumentStatistics( final Workspace workspace ) {
54
    final var sortedItems = new SortedList<>( mItems );
55
    sortedItems.comparatorProperty().bind( comparatorProperty() );
56
    setItems( sortedItems );
57
58
    initView();
59
    initListeners( workspace );
60
    register( this );
61
62
    final var fontName = workspace.stringProperty( KEY_UI_FONT_EDITOR_NAME );
63
64
    fontName.addListener(
65
      ( c, o, n ) -> {
66
        if( n != null ) {
67
          setFontFamily( n );
68
        }
69
      }
70
    );
71
72
    setFontFamily( fontName.getValue() );
73
  }
74
75
  /**
76
   * Called when the hashcode for the current document changes. This happens
77
   * when non-collapsable-whitespace is added to the document. When the
78
   * document is sent to {@link HtmlPanel} for rendering, the parsed
79
   * {@link Jsoup} document is converted to text. If that text differs
80
   * (using {@link MurmurHash}), then this method is called. The implication
81
   * is that all variables and executable statements have been replaced.
82
   * An event bus subscriber is used so that text processing occurs outside
83
   * of the UI processing threads.
84
   *
85
   * @param event Container for the document text that has changed.
86
   */
87
  @Subscribe
88
  public void handle( final DocumentChangedEvent event ) {
89
    try {
90
      final var tokens = sTokenizer.tokenize( event.getDocument() );
91
      final var sum = new int[]{0};
92
93
      runLater( () -> {
94
        mItems.clear();
95
        tokens.forEach( ( k, v ) -> {
96
          final var count = v[ 0 ];
97
          if( count > 2 ) {
98
            mItems.add( new StatEntry( k, count ) );
99
          }
100
          sum[ 0 ] += count;
101
        } );
102
103
        fireWordCountEvent( sum[ 0 ] );
104
      } );
105
106
    } catch( final TokenizerException ex ) {
107
      clue( ex );
108
    }
109
  }
110
111
  @SuppressWarnings( "unchecked" )
112
  private void initView() {
113
    final TableColumn<StatEntry, String> colWord = createColumn( "Word" );
114
    final TableColumn<StatEntry, Number> colCount = createColumn( "Count" );
115
116
    colWord.setCellValueFactory( stat -> stat.getValue().wordProperty() );
117
    colCount.setCellValueFactory( stat -> stat.getValue().tallyProperty() );
118
    colCount.setComparator( colCount.getComparator().reversed() );
119
120
    final var columns = getColumns();
121
    columns.add( colWord );
122
    columns.add( colCount );
123
124
    setMaxWidth( Double.MAX_VALUE );
125
    setPrefWidth( 128 );
126
    setColumnResizePolicy( CONSTRAINED_RESIZE_POLICY );
127
    getSortOrder().setAll( colCount, colWord );
128
129
    getStyleClass().add( "" );
130
  }
131
132
  private void initListeners( final Workspace workspace ) {
133
    final var property = workspace.localeProperty( KEY_LANGUAGE_LOCALE );
134
    property.addListener(
135
      ( c, o, n ) -> sTokenizer = createTokenizer( property.toLocale() )
136
    );
137
  }
138
139
  /**
140
   * Creates a tokenizer for English text (can handle most Latin languages).
141
   *
142
   * @return An English-based tokenizer for counting words.
143
   */
144
  private static Tokenizer createTokenizer( final Locale language ) {
145
    return TokenizerFactory.create( language );
146
  }
147
148
  private <E, T> TableColumn<E, T> createColumn( final String key ) {
149
    return new TableColumn<>( key );
150
  }
151
152
  private void setFontFamily( final String value ) {
153
    runLater( () -> setStyle( format( "-fx-font-family:'%s';", value ) ) );
154
  }
155
156
  /**
157
   * Represents the number of times a word appears in a document.
158
   */
159
  protected static final class StatEntry {
160
    private final StringProperty mWord;
161
    private final IntegerProperty mTally;
162
163
    public StatEntry( final String word, final int tally ) {
164
      mWord = new SimpleStringProperty( word );
165
      mTally = new SimpleIntegerProperty( tally );
166
    }
167
168
    private StringProperty wordProperty() {
169
      return mWord;
170
    }
171
172
    private IntegerProperty tallyProperty() {
173
      return mTally;
174
    }
175
  }
176
}
1177
M src/main/java/com/keenwrite/ui/listeners/CaretListener.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
12
package com.keenwrite.ui.listeners;
23
34
import com.keenwrite.Caret;
45
import com.keenwrite.editors.TextEditor;
6
import com.keenwrite.events.WordCountEvent;
57
import javafx.beans.property.ReadOnlyObjectProperty;
68
import javafx.beans.value.ChangeListener;
79
import javafx.beans.value.ObservableValue;
810
import javafx.scene.control.Label;
911
import javafx.scene.layout.VBox;
12
import org.greenrobot.eventbus.Subscribe;
1013
14
import static com.keenwrite.events.Bus.register;
15
import static javafx.application.Platform.runLater;
1116
import static javafx.geometry.Pos.BASELINE_CENTER;
1217
...
2328
  private final Label mLineNumberText = new Label();
2429
  private volatile Caret mCaret;
30
31
  /**
32
   * Approximate number of words in the document.
33
   */
34
  private volatile int mCount;
2535
2636
  public CaretListener( final ReadOnlyObjectProperty<TextEditor> editor ) {
...
3747
3848
    updateListener( editor.get().getCaret() );
49
    register( this );
3950
  }
4051
...
4859
  @Override
4960
  public void changed(
50
      final ObservableValue<? extends Integer> c,
51
      final Integer o, final Integer n ) {
61
    final ObservableValue<? extends Integer> c,
62
    final Integer o, final Integer n ) {
63
    updateLineNumber();
64
  }
65
66
  @Subscribe
67
  public void handle( final WordCountEvent event ) {
68
    mCount = event.getCount();
5269
    updateLineNumber();
5370
  }
...
6582
6683
  private void updateLineNumber() {
67
    mLineNumberText.setText( mCaret.toString() );
84
    runLater(
85
      () -> mLineNumberText.setText( mCaret.toString() + " | " + mCount )
86
    );
6887
  }
6988
}
M src/main/java/com/keenwrite/ui/logging/LogView.java
1717
1818
import static com.keenwrite.Bootstrap.APP_TITLE_LOWERCASE;
19
import static com.keenwrite.Constants.ACTION_PREFIX;
1920
import static com.keenwrite.Constants.ICON_DIALOG;
2021
import static com.keenwrite.Messages.get;
...
4950
  private static final int CACHE_SIZE = 150;
5051
51
  private final ObservableList<LogEntry> mEntries = observableArrayList();
52
  private final TableView<LogEntry> mTable = new TableView<>( mEntries );
52
  private final ObservableList<LogEntry> mItems = observableArrayList();
53
  private final TableView<LogEntry> mTable = new TableView<>( mItems );
5354
5455
  public LogView() {
5556
    super( INFORMATION );
56
    setTitle( get( "App.action.view.issues.text" ) );
57
    setTitle( get( ACTION_PREFIX + "view.issues.text" ) );
5758
    initModality( NONE );
5859
    initTableView();
...
6667
  @Subscribe
6768
  public void log( final StatusEvent event ) {
68
    runLater( () ->{
69
    runLater( () -> {
6970
      final var logEntry = new LogEntry( event );
7071
71
      if( !mEntries.contains( logEntry ) ) {
72
        mEntries.add( logEntry );
72
      if( !mItems.contains( logEntry ) ) {
73
        mItems.add( logEntry );
7374
74
        while( mEntries.size() > CACHE_SIZE ) {
75
          mEntries.remove( 0 );
75
        while( mItems.size() > CACHE_SIZE ) {
76
          mItems.remove( 0 );
7677
        }
7778
7879
        mTable.scrollTo( logEntry );
7980
      }
80
    });
81
    } );
8182
  }
8283
...
9394
   */
9495
  public void clear() {
95
    mEntries.clear();
96
    mItems.clear();
9697
    clue();
9798
  }
A src/main/java/com/keenwrite/ui/outline/DocumentOutline.java
1
package com.keenwrite.ui.outline;
2
3
import com.keenwrite.events.Bus;
4
import com.keenwrite.events.ParseHeadingEvent;
5
import javafx.scene.control.TreeCell;
6
import javafx.scene.control.TreeItem;
7
import javafx.scene.control.TreeView;
8
import javafx.scene.text.Text;
9
import javafx.util.Callback;
10
import org.greenrobot.eventbus.Subscribe;
11
12
import static com.keenwrite.Constants.ICON_SIZE_DEFAULT;
13
import static com.keenwrite.events.Bus.register;
14
import static com.keenwrite.events.CaretNavigationEvent.fireCaretNavigationEvent;
15
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.valueOf;
16
import static de.jensd.fx.glyphs.fontawesome.utils.FontAwesomeIconFactory.get;
17
import static javafx.application.Platform.runLater;
18
import static javafx.scene.input.MouseButton.PRIMARY;
19
import static javafx.scene.input.MouseEvent.MOUSE_PRESSED;
20
21
public class DocumentOutline extends TreeView<ParseHeadingEvent> {
22
  private TreeItem<ParseHeadingEvent> mCurrent;
23
24
  /**
25
   * Registers with the {@link Bus}.
26
   */
27
  public DocumentOutline() {
28
    register( this );
29
30
    // Override double-click to issue a caret navigation event.
31
    setCellFactory( new Callback<>() {
32
      @Override
33
      public TreeCell<ParseHeadingEvent> call(
34
        TreeView<ParseHeadingEvent> treeView ) {
35
        TreeCell<ParseHeadingEvent> cell = new TreeCell<>() {
36
          @Override
37
          protected void updateItem( ParseHeadingEvent item, boolean empty ) {
38
            super.updateItem( item, empty );
39
            if( empty || item == null ) {
40
              setText( null );
41
              setGraphic( null );
42
            }
43
            else {
44
              setText( item.toString() );
45
              setGraphic( createIcon() );
46
            }
47
          }
48
        };
49
50
        cell.addEventFilter( MOUSE_PRESSED, event -> {
51
          if( event.getButton() == PRIMARY && event.getClickCount() % 2 == 0 ) {
52
            fireCaretNavigationEvent( cell.getItem().getOffset() );
53
            event.consume();
54
          }
55
        } );
56
57
        return cell;
58
      }
59
    } );
60
  }
61
62
  /**
63
   * Updates the {@link TreeView} with the given event data. This method will
64
   * track the most recently added {@link TreeItem} so that the nesting
65
   * hierarchy reflects the document hierarchy.
66
   *
67
   * @param event Represents a document heading to add to the tree.
68
   */
69
  @Subscribe
70
  public void handle( final ParseHeadingEvent event ) {
71
    runLater(
72
      () -> mCurrent = event.isNewOutline() ? clear( event ) : addItem( event )
73
    );
74
  }
75
76
  private TreeItem<ParseHeadingEvent> clear( final ParseHeadingEvent event ) {
77
    final var root = createTreeItem( event );
78
    setRoot( root );
79
    setShowRoot( false );
80
    return root;
81
  }
82
83
  /**
84
   * This method is called once for every heading in the document. The event
85
   * data directly corresponds to the sequence of headings in the document.
86
   * The given event data contains a level that is relative to the last
87
   * item in the tree.
88
   *
89
   * @param next Contains a level value to indicate heading depth.
90
   */
91
  private TreeItem<ParseHeadingEvent> addItem( final ParseHeadingEvent next ) {
92
    var parent = mCurrent;
93
    final var item = createTreeItem( next );
94
    final var curr = parent.getValue();
95
    final var currLevel = curr.getLevel();
96
    final var nextLevel = next.getLevel();
97
    var deltaLevel = currLevel - nextLevel + 1;
98
99
    while( deltaLevel > 0 && parent != null ) {
100
      parent = parent.getParent();
101
      deltaLevel--;
102
    }
103
104
    if( parent == null ) {
105
      parent = getRoot();
106
    }
107
108
    parent.getChildren().add( item );
109
110
    return item;
111
  }
112
113
  private TreeItem<ParseHeadingEvent> createTreeItem(
114
    final ParseHeadingEvent event ) {
115
    final var item = new TreeItem<>( event, createIcon() );
116
    item.setExpanded( true );
117
    return item;
118
  }
119
120
  private Text createIcon() {
121
    return get().createIcon( valueOf( "BOOKMARK" ), ICON_SIZE_DEFAULT );
122
  }
123
}
1124
M src/main/java/com/keenwrite/ui/tree/AltTreeCellFactory.java
77
import javafx.util.StringConverter;
88
9
//import javafx.collections.ObservableList;
10
//import javafx.scene.control.TreeCell;
11
//import javafx.scene.control.TreeItem;
12
//import javafx.scene.control.TreeView;
13
//import javafx.scene.input.ClipboardContent;
14
//import javafx.scene.input.DataFormat;
15
//import javafx.scene.input.DragEvent;
16
//import javafx.scene.input.MouseEvent;
17
//import javafx.util.StringConverter;
18
//import java.util.Objects;
19
//import static javafx.scene.input.TransferMode.MOVE;
20
219
/**
2210
 * Responsible for creating new {@link TreeCell} instances.
11
 * <p>
12
 * TODO: #22 -- Upon refactoring variable functionality, re-instate drag & drop.
13
 * </p>
2314
 *
2415
 * @param <T> The data type stored in the tree.
...
3627
    return new AltTreeCell<>( mConverter );
3728
  }
38
39
//  private static final String STYLE_CLASS_DROP_TARGET = "drop-target";
40
//  private static final DataFormat JAVA_FORMAT =
41
//      new DataFormat( APP_JAVA_OBJECT.toString() );
42
//
43
//  private TreeItem<String> mDraggedTreeItem;
44
//  private TreeCell<String> mTargetCell;
45
//
46
//  @Override
47
//  public TreeCell<String> call( final TreeView<String> treeView ) {
48
//    final var cell = createTreeCell();
49
//
50
//    cell.setOnDragDetected( event -> dragDetected( event, cell ) );
51
//    cell.setOnDragOver( event -> dragOver( event, cell ) );
52
//    cell.setOnDragDropped( event -> dragDropped( event, cell, treeView ) );
53
//    cell.setOnDragDone( event -> dragClear() );
54
//
55
//    return cell;
56
//  }
57
//
58
//  private TreeCell<String> createTreeCell() {
59
//  }
60
//
61
//  /**
62
//   * Drag start.
63
//   *
64
//   * @param event    The drag start {@link MouseEvent}.
65
//   * @param treeCell The cell being dragged.
66
//   */
67
//private void dragDetected(
68
//  final MouseEvent event, final TreeCell<String> treeCell ) {
69
//  final var sourceItem = treeCell.getTreeItem();
70
//
71
//  // Prevent dragging the root item.
72
//  if( sourceItem != null && sourceItem.getParent() != null ) {
73
//    final var dragboard = treeCell.startDragAndDrop( MOVE );
74
//    final var clipboard = new ClipboardContent();
75
//    clipboard.put( JAVA_FORMAT, sourceItem.getValue() );
76
//    dragboard.setContent( clipboard );
77
//    dragboard.setDragView( treeCell.snapshot( null, null ) );
78
//    event.consume();
79
//
80
//    mDraggedTreeItem = sourceItem;
81
//  }
82
//}
83
//
84
//  /**
85
//   * Drag over another {@link TreeCell} instance.
86
//   *
87
//   * @param event    The drag over {@link DragEvent}.
88
//   * @param treeCell The cell dragged over.
89
//   * @throws IllegalStateException Drag transfer "move" mode denied.
90
//   */
91
//  private void dragOver(
92
//    final DragEvent event, final TreeCell<String> treeCell ) {
93
//    if( event.getDragboard().hasContent( JAVA_FORMAT ) ) {
94
//      final var thisItem = treeCell.getTreeItem();
95
//
96
//      if( mDraggedTreeItem == null ||
97
//        thisItem == null ||
98
//        thisItem == mDraggedTreeItem ) {
99
//        return;
100
//      }
101
//
102
//      // Ignore dragging over the root item.
103
//      if( mDraggedTreeItem.getParent() == null ) {
104
//        dragClear();
105
//        return;
106
//      }
107
//
108
//      event.acceptTransferModes( MOVE );
109
//
110
//      if( !Objects.equals( mTargetCell, treeCell ) ) {
111
//        dragClear();
112
//        mTargetCell = treeCell;
113
//        mTargetCell.getStyleClass().add( STYLE_CLASS_DROP_TARGET );
114
//      }
115
//    }
116
//  }
117
//
118
//  /**
119
//   * Dragged item is dropped
120
//   *
121
//   * @param event    The drag dropped {@link DragEvent}.
122
//   * @param treeCell The cell dropped onto.
123
//   */
124
//  private void dragDropped( final DragEvent event,
125
//                            final TreeCell<String> treeCell,
126
//                            final TreeView<String> treeView ) {
127
//    if( !event.getDragboard().hasContent( JAVA_FORMAT ) ) {
128
//      return;
129
//    }
130
//
131
//    final var sourceItem = mDraggedTreeItem;
132
//    final var sourceItemParent = mDraggedTreeItem.getParent();
133
//    final var targetItem = treeCell.getTreeItem();
134
//    final var targetItemParent = targetItem.getParent();
135
//
136
//    sourceItemParent.getChildren().remove( sourceItem );
137
//
138
//    final ObservableList<TreeItem<String>> children;
139
//    final int index;
140
//
141
//    // Dropping onto a parent node makes the source item the first child.
142
//    if( Objects.equals( sourceItemParent, targetItem ) ) {
143
//      children = targetItem.getChildren();
144
//      index = 0;
145
//    }
146
//    else if( targetItemParent != null) {
147
//      children = targetItemParent.getChildren();
148
//      index = children.indexOf( targetItem ) + 1;
149
//    }
150
//    else {
151
//      children = sourceItemParent.getChildren();
152
//      index = 0;
153
//    }
154
//
155
//    children.add( index, sourceItem );
156
//
157
//    treeView.getSelectionModel().clearSelection();
158
//    treeView.getSelectionModel().select( sourceItem );
159
//
160
//    // TODO: Notify a listener of the old and new tree item position.
161
//
162
//    event.setDropCompleted( true );
163
//  }
164
//
165
//  private void dragClear() {
166
//    final var targetCell = mTargetCell;
167
//
168
//    if( targetCell != null ) {
169
//      targetCell.getStyleClass().remove( STYLE_CLASS_DROP_TARGET );
170
//    }
171
//  }
17229
}
17330
M src/main/java/com/keenwrite/ui/tree/AltTreeView.java
99
 * Responsible for allowing users to edit items in the tree as well as
1010
 * drag and drop. The goal is to be a drop-in replacement for the regular
11
 * JavaFX {@link TreeView} that does not offer editing and moving {@link
11
 * JavaFX {@link TreeView}, which does not offer editing and moving {@link
1212
 * TreeItem} instances.
1313
 *
A src/main/java/com/keenwrite/util/MurmurHash.java
1
package com.keenwrite.util;
2
3
/**
4
 * The MurmurHash3 algorithm was created by Austin Appleby and placed in the
5
 * public domain. This Java port was authored by Yonik Seeley and also placed
6
 * into the public domain. The author hereby disclaims copyright to this
7
 * source code.
8
 * <p>
9
 * This produces exactly the same hash values as the final C++ version and is
10
 * thus suitable for producing the same hash values across platforms.
11
 * <p>
12
 * The 32-bit x86 version of this hash should be the fastest variant for
13
 * relatively short keys like ids. Using {@link #hash32} is a
14
 * good choice for longer strings or returning more than 32 hashed bits.
15
 * <p>
16
 * The x86 and x64 versions do not produce the same results because
17
 * algorithms are optimized for their respective platforms.
18
 * <p>
19
 * Code clean-up by White Magic Software, Ltd.
20
 * </p>
21
 */
22
public final class MurmurHash {
23
  /**
24
   * Returns the 32-bit x86-optimized hash of the UTF-8 bytes of the String
25
   * without actually encoding the string to a temporary buffer. This is over
26
   * twice as fast as hashing the result of {@link String#getBytes()}.
27
   */
28
  @SuppressWarnings( "unused" )
29
  public static int hash32( CharSequence data, int offset, int len, int seed ) {
30
    final int c1 = 0xcc9e2d51;
31
    final int c2 = 0x1b873593;
32
33
    int h1 = seed;
34
35
    int pos = offset;
36
    int end = offset + len;
37
    int k1 = 0;
38
    int k2;
39
    int shift = 0;
40
    int bits;
41
    int nBytes = 0;   // length in UTF8 bytes
42
43
    while( pos < end ) {
44
      int code = data.charAt( pos++ );
45
      if( code < 0x80 ) {
46
        k2 = code;
47
        bits = 8;
48
      }
49
      else if( code < 0x800 ) {
50
        k2 = (0xC0 | (code >> 6))
51
          | ((0x80 | (code & 0x3F)) << 8);
52
        bits = 16;
53
      }
54
      else if( code < 0xD800 || code > 0xDFFF || pos >= end ) {
55
        // we check for pos>=end to encode an unpaired surrogate as 3 bytes.
56
        k2 = (0xE0 | (code >> 12))
57
          | ((0x80 | ((code >> 6) & 0x3F)) << 8)
58
          | ((0x80 | (code & 0x3F)) << 16);
59
        bits = 24;
60
      }
61
      else {
62
        // surrogate pair
63
        // int utf32 = pos < end ? (int) data.charAt(pos++) : 0;
64
        int utf32 = data.charAt( pos++ );
65
        utf32 = ((code - 0xD7C0) << 10) + (utf32 & 0x3FF);
66
        k2 = (0xff & (0xF0 | (utf32 >> 18)))
67
          | ((0x80 | ((utf32 >> 12) & 0x3F))) << 8
68
          | ((0x80 | ((utf32 >> 6) & 0x3F))) << 16
69
          | (0x80 | (utf32 & 0x3F)) << 24;
70
        bits = 32;
71
      }
72
73
      k1 |= k2 << shift;
74
75
      // int used_bits = 32 - shift;  // how many bits of k2 were used in k1.
76
      // int unused_bits = bits - used_bits; //  (bits-(32-shift)) ==
77
      // bits+shift-32  == bits-newshift
78
79
      shift += bits;
80
      if( shift >= 32 ) {
81
        // mix after we have a complete word
82
83
        k1 *= c1;
84
        k1 = (k1 << 15) | (k1 >>> 17);  // ROTL32(k1,15);
85
        k1 *= c2;
86
87
        h1 ^= k1;
88
        h1 = (h1 << 13) | (h1 >>> 19);  // ROTL32(h1,13);
89
        h1 = h1 * 5 + 0xe6546b64;
90
91
        shift -= 32;
92
        // unfortunately, java won't let you shift 32 bits off, so we need to
93
        // check for 0
94
        if( shift != 0 ) {
95
          k1 = k2 >>> (bits - shift);   // bits used == bits - newshift
96
        }
97
        else {
98
          k1 = 0;
99
        }
100
        nBytes += 4;
101
      }
102
103
    } // inner
104
105
    // handle tail
106
    if( shift > 0 ) {
107
      nBytes += shift >> 3;
108
      k1 *= c1;
109
      k1 = (k1 << 15) | (k1 >>> 17);  // ROTL32(k1,15);
110
      k1 *= c2;
111
      h1 ^= k1;
112
    }
113
114
    // finalization
115
    h1 ^= nBytes;
116
117
    // fmix(h1);
118
    h1 ^= h1 >>> 16;
119
    h1 *= 0x85ebca6b;
120
    h1 ^= h1 >>> 13;
121
    h1 *= 0xc2b2ae35;
122
    h1 ^= h1 >>> 16;
123
124
    return h1;
125
  }
126
}
1127
M src/main/resources/com/keenwrite/messages.properties
167167
168168
# ########################################################################
169
# Failure messages with respect to YAML files.
170
# ########################################################################
171
172
yaml.error.open=Could not open YAML file (ensure non-empty file).
173
yaml.error.unresolvable=Too much indirection for: ''{0}'' = ''{1}''.
174
yaml.error.missing=Empty variable value for key ''{0}''.
175
yaml.error.tree.form=Unassigned variable near ''{0}''.
176
177
# ########################################################################
178
# Text Resource
179
# ########################################################################
180
181
TextResource.load.error.unsaved=The file ''{0}'' is unsaved or does not exist.
182
TextResource.load.error.permissions=The file ''{0}'' must be readable and writable.
183
184
# ########################################################################
185
# Text Resources
186
# ########################################################################
187
188
TextResource.saveFailed.message=Failed to save ''{0}''.\n\nReason: {1}
189
TextResource.saveFailed.title=Save
190
191
# ########################################################################
192
# File Open
193
# ########################################################################
194
195
Dialog.file.choose.open.title=Open File
196
Dialog.file.choose.save.title=Save File
197
Dialog.file.choose.export.title=Export File
198
199
Dialog.file.choose.filter.title.source=Source Files
200
Dialog.file.choose.filter.title.definition=Variable Files
201
Dialog.file.choose.filter.title.xml=XML Files
202
Dialog.file.choose.filter.title.all=All Files
203
204
# ########################################################################
205
# Browse File
206
# ########################################################################
207
208
BrowseFileButton.chooser.title=Browse for local file
209
BrowseFileButton.chooser.allFilesFilter=All Files
210
BrowseFileButton.tooltip=${BrowseFileButton.chooser.title}
211
212
# ########################################################################
213
# Alert Dialog
214
# ########################################################################
215
216
Alert.file.close.title=Close
217
Alert.file.close.text=Save changes to {0}?
218
219
# ########################################################################
220
# Image Dialog
221
# ########################################################################
222
223
Dialog.image.title=Image
224
Dialog.image.chooser.imagesFilter=Images
225
Dialog.image.previewLabel.text=Markdown Preview\:
226
Dialog.image.textLabel.text=Alternate Text\:
227
Dialog.image.titleLabel.text=Title (tooltip)\:
228
Dialog.image.urlLabel.text=Image URL\:
229
230
# ########################################################################
231
# Hyperlink Dialog
232
# ########################################################################
233
234
Dialog.link.title=Link
235
Dialog.link.previewLabel.text=Markdown Preview\:
236
Dialog.link.textLabel.text=Link Text\:
237
Dialog.link.titleLabel.text=Title (tooltip)\:
238
Dialog.link.urlLabel.text=Link URL\:
239
240
# ########################################################################
241
# About Dialog
242
# ########################################################################
243
244
Dialog.about.title=About {0}
245
Dialog.about.header={0}
246
Dialog.about.content=Copyright 2016-{0} White Magic Software, Ltd.\n\nVersion: {1}
247
248
# ########################################################################
249
# Application Actions
250
# ########################################################################
251
252
App.action.file.new.description=Create a new file
253
App.action.file.new.accelerator=Shortcut+N
254
App.action.file.new.icon=FILE_ALT
255
App.action.file.new.text=_New
256
257
App.action.file.open.description=Open a new file
258
App.action.file.open.accelerator=Shortcut+O
259
App.action.file.open.text=_Open...
260
App.action.file.open.icon=FOLDER_OPEN_ALT
261
262
App.action.file.close.description=Close the current document
263
App.action.file.close.accelerator=Shortcut+W
264
App.action.file.close.text=_Close
265
266
App.action.file.close_all.description=Close all open documents
267
App.action.file.close_all.accelerator=Ctrl+F4
268
App.action.file.close_all.text=Close All
269
270
App.action.file.save.description=Save the document
271
App.action.file.save.accelerator=Shortcut+S
272
App.action.file.save.text=_Save
273
App.action.file.save.icon=FLOPPY_ALT
274
275
App.action.file.save_as.description=Rename the current document
276
App.action.file.save_as.text=Save _As
277
278
App.action.file.save_all.description=Save all open documents
279
App.action.file.save_all.accelerator=Shortcut+Shift+S
280
App.action.file.save_all.text=Save A_ll
281
282
App.action.file.export.html_svg.description=Export the current document as HTML + SVG
283
App.action.file.export.text=_Export As
284
App.action.file.export.html_svg.text=HTML and S_VG
285
286
App.action.file.export.html_tex.description=Export the current document as HTML + TeX
287
App.action.file.export.html_tex.text=HTML and _TeX
288
289
App.action.file.export.markdown.description=Export the current document as Markdown
290
App.action.file.export.markdown.text=Markdown
291
292
App.action.file.exit.description=Quit the application
293
App.action.file.exit.text=E_xit
294
295
296
App.action.edit.undo.description=Undo the previous edit
297
App.action.edit.undo.accelerator=Shortcut+Z
298
App.action.edit.undo.text=_Undo
299
App.action.edit.undo.icon=UNDO
300
301
App.action.edit.redo.description=Redo the previous edit
302
App.action.edit.redo.accelerator=Shortcut+Y
303
App.action.edit.redo.text=_Redo
304
App.action.edit.redo.icon=REPEAT
305
306
App.action.edit.cut.description=Delete the selected text or line
307
App.action.edit.cut.accelerator=Shortcut+X
308
App.action.edit.cut.text=Cu_t
309
App.action.edit.cut.icon=CUT
310
311
App.action.edit.copy.description=Copy the selected text
312
App.action.edit.copy.accelerator=Shortcut+C
313
App.action.edit.copy.text=_Copy
314
App.action.edit.copy.icon=COPY
315
316
App.action.edit.paste.description=Paste from the clipboard
317
App.action.edit.paste.accelerator=Shortcut+V
318
App.action.edit.paste.text=_Paste
319
App.action.edit.paste.icon=PASTE
320
321
App.action.edit.select_all.description=Highlight the current document text
322
App.action.edit.select_all.accelerator=Shortcut+A
323
App.action.edit.select_all.text=Select _All
324
325
App.action.edit.find.description=Search for text in the document
326
App.action.edit.find.accelerator=Shortcut+F
327
App.action.edit.find.text=_Find
328
App.action.edit.find.icon=SEARCH
329
330
App.action.edit.find_next.description=Find next occurrence
331
App.action.edit.find_next.accelerator=F3
332
App.action.edit.find_next.text=Find _Next
333
334
App.action.edit.find_prev.description=Find previous occurrence
335
App.action.edit.find_prev.accelerator=Shift+F3
336
App.action.edit.find_prev.text=Find _Prev
337
338
App.action.edit.preferences.description=Edit user preferences
339
App.action.edit.preferences.accelerator=Ctrl+Alt+S
340
App.action.edit.preferences.text=_Preferences
341
342
343
App.action.format.bold.description=Insert strong text
344
App.action.format.bold.accelerator=Shortcut+B
345
App.action.format.bold.text=_Bold
346
App.action.format.bold.icon=BOLD
347
348
App.action.format.italic.description=Insert text emphasis
349
App.action.format.italic.accelerator=Shortcut+I
350
App.action.format.italic.text=_Italic
351
App.action.format.italic.icon=ITALIC
352
353
App.action.format.superscript.description=Insert superscript text
354
App.action.format.superscript.accelerator=Shortcut+[
355
App.action.format.superscript.text=Su_perscript
356
App.action.format.superscript.icon=SUPERSCRIPT
357
358
App.action.format.subscript.description=Insert subscript text
359
App.action.format.subscript.accelerator=Shortcut+]
360
App.action.format.subscript.text=Su_bscript
361
App.action.format.subscript.icon=SUBSCRIPT
362
363
App.action.format.strikethrough.description=Insert struck text
364
App.action.format.strikethrough.accelerator=Shortcut+T
365
App.action.format.strikethrough.text=Stri_kethrough
366
App.action.format.strikethrough.icon=STRIKETHROUGH
367
368
369
App.action.insert.blockquote.description=Insert blockquote
370
App.action.insert.blockquote.accelerator=Ctrl+Q
371
App.action.insert.blockquote.text=_Blockquote
372
App.action.insert.blockquote.icon=QUOTE_LEFT
373
374
App.action.insert.code.description=Insert inline code
375
App.action.insert.code.accelerator=Shortcut+K
376
App.action.insert.code.text=Inline _Code
377
App.action.insert.code.icon=CODE
378
379
App.action.insert.fenced_code_block.description=Insert code block
380
App.action.insert.fenced_code_block.accelerator=Shortcut+Shift+K
381
App.action.insert.fenced_code_block.text=_Fenced Code Block
382
App.action.insert.fenced_code_block.prompt.text=Enter code here
383
App.action.insert.fenced_code_block.icon=FILE_CODE_ALT
384
385
App.action.insert.link.description=Insert hyperlink
386
App.action.insert.link.accelerator=Shortcut+L
387
App.action.insert.link.text=_Link...
388
App.action.insert.link.icon=LINK
389
390
App.action.insert.image.description=Insert image
391
App.action.insert.image.accelerator=Shortcut+G
392
App.action.insert.image.text=_Image...
393
App.action.insert.image.icon=PICTURE_ALT
394
395
App.action.insert.heading.description=Insert heading level
396
App.action.insert.heading.accelerator=Shortcut+
397
App.action.insert.heading.icon=HEADER
398
399
App.action.insert.heading_1.description=${App.action.insert.heading.description} 1
400
App.action.insert.heading_1.accelerator=${App.action.insert.heading.accelerator}1
401
App.action.insert.heading_1.text=Heading _1
402
App.action.insert.heading_1.icon=${App.action.insert.heading.icon}
403
404
App.action.insert.heading_2.description=${App.action.insert.heading.description} 2
405
App.action.insert.heading_2.accelerator=${App.action.insert.heading.accelerator}2
406
App.action.insert.heading_2.text=Heading _2
407
App.action.insert.heading_2.icon=${App.action.insert.heading.icon}
408
409
App.action.insert.heading_3.description=${App.action.insert.heading.description} 3
410
App.action.insert.heading_3.accelerator=${App.action.insert.heading.accelerator}3
411
App.action.insert.heading_3.text=Heading _3
412
App.action.insert.heading_3.icon=${App.action.insert.heading.icon}
413
414
App.action.insert.unordered_list.description=Insert bulleted list
415
App.action.insert.unordered_list.accelerator=Shortcut+U
416
App.action.insert.unordered_list.text=_Unordered List
417
App.action.insert.unordered_list.icon=LIST_UL
418
419
App.action.insert.ordered_list.description=Insert enumerated list
420
App.action.insert.ordered_list.accelerator=Shortcut+Shift+O
421
App.action.insert.ordered_list.text=_Ordered List
422
App.action.insert.ordered_list.icon=LIST_OL
423
424
App.action.insert.horizontal_rule.description=Insert horizontal rule
425
App.action.insert.horizontal_rule.accelerator=Shortcut+H
426
App.action.insert.horizontal_rule.text=_Horizontal Rule
427
App.action.insert.horizontal_rule.icon=LIST_OL
428
429
430
App.action.definition.create.description=Create a new variable
431
App.action.definition.create.text=_Create
432
App.action.definition.create.icon=TREE
433
App.action.definition.create.tooltip=Add new item (Insert)
434
435
App.action.definition.rename.description=Rename the selected variable
436
App.action.definition.rename.text=_Rename
437
App.action.definition.rename.icon=EDIT
438
App.action.definition.rename.tooltip=Rename selected item (F2)
439
440
App.action.definition.delete.description=Delete the selected variables
441
App.action.definition.delete.text=De_lete
442
App.action.definition.delete.icon=TRASH
443
App.action.definition.delete.tooltip=Delete selected items (Delete)
444
445
App.action.definition.insert.description=Insert a variable
446
App.action.definition.insert.accelerator=Ctrl+Space
447
App.action.definition.insert.text=_Insert
448
App.action.definition.insert.icon=STAR
449
450
451
App.action.view.refresh.description=Clear all caches
452
App.action.view.refresh.accelerator=F5
453
App.action.view.refresh.text=Refresh
454
455
App.action.view.preview.description=Open document preview
456
App.action.view.preview.accelerator=F6
457
App.action.view.preview.text=Preview
458
459
App.action.view.outline.description=Open document outline
460
App.action.view.outline.accelerator=F7
461
App.action.view.outline.text=Outline
462
463
App.action.view.files.description=Open file system browser
464
App.action.view.files.accelerator=F8
465
App.action.view.files.text=File system
466
467
App.action.view.menubar.description=Toggle menu bar
468
App.action.view.menubar.accelerator=Ctrl+F9
469
App.action.view.menubar.text=Menu bar
470
471
App.action.view.toolbar.description=Toggle tool bar
472
App.action.view.toolbar.accelerator=Ctrl+Shift+F9
473
App.action.view.toolbar.text=Tool bar
474
475
App.action.view.statusbar.description=Toggle status bar
476
App.action.view.statusbar.accelerator=Ctrl+Shift+Alt+F9
477
App.action.view.statusbar.text=Status bar
478
479
App.action.view.issues.description=Open document issues
480
App.action.view.issues.accelerator=F12
481
App.action.view.issues.text=Issues
482
483
484
App.action.help.about.description=Show help dialog
485
App.action.help.about.accelerator=F1
486
App.action.help.about.text=About
487
App.action.help.about.icon=INFO
169
# Document Outline Pane
170
# ########################################################################
171
172
Pane.statistics.title=Statistics
173
174
# ########################################################################
175
# Failure messages with respect to YAML files.
176
# ########################################################################
177
178
yaml.error.open=Could not open YAML file (ensure non-empty file).
179
yaml.error.unresolvable=Too much indirection for: ''{0}'' = ''{1}''.
180
yaml.error.missing=Empty variable value for key ''{0}''.
181
yaml.error.tree.form=Unassigned variable near ''{0}''.
182
183
# ########################################################################
184
# Text Resource
185
# ########################################################################
186
187
TextResource.load.error.unsaved=The file ''{0}'' is unsaved or does not exist.
188
TextResource.load.error.permissions=The file ''{0}'' must be readable and writable.
189
190
# ########################################################################
191
# Text Resources
192
# ########################################################################
193
194
TextResource.saveFailed.message=Failed to save ''{0}''.\n\nReason: {1}
195
TextResource.saveFailed.title=Save
196
197
# ########################################################################
198
# File Open
199
# ########################################################################
200
201
Dialog.file.choose.open.title=Open File
202
Dialog.file.choose.save.title=Save File
203
Dialog.file.choose.export.title=Export File
204
205
Dialog.file.choose.filter.title.source=Source Files
206
Dialog.file.choose.filter.title.definition=Variable Files
207
Dialog.file.choose.filter.title.xml=XML Files
208
Dialog.file.choose.filter.title.all=All Files
209
210
# ########################################################################
211
# Browse File
212
# ########################################################################
213
214
BrowseFileButton.chooser.title=Browse for local file
215
BrowseFileButton.chooser.allFilesFilter=All Files
216
BrowseFileButton.tooltip=${BrowseFileButton.chooser.title}
217
218
# ########################################################################
219
# Alert Dialog
220
# ########################################################################
221
222
Alert.file.close.title=Close
223
Alert.file.close.text=Save changes to {0}?
224
225
# ########################################################################
226
# Image Dialog
227
# ########################################################################
228
229
Dialog.image.title=Image
230
Dialog.image.chooser.imagesFilter=Images
231
Dialog.image.previewLabel.text=Markdown Preview\:
232
Dialog.image.textLabel.text=Alternate Text\:
233
Dialog.image.titleLabel.text=Title (tooltip)\:
234
Dialog.image.urlLabel.text=Image URL\:
235
236
# ########################################################################
237
# Hyperlink Dialog
238
# ########################################################################
239
240
Dialog.link.title=Link
241
Dialog.link.previewLabel.text=Markdown Preview\:
242
Dialog.link.textLabel.text=Link Text\:
243
Dialog.link.titleLabel.text=Title (tooltip)\:
244
Dialog.link.urlLabel.text=Link URL\:
245
246
# ########################################################################
247
# About Dialog
248
# ########################################################################
249
250
Dialog.about.title=About {0}
251
Dialog.about.header={0}
252
Dialog.about.content=Copyright 2016-{0} White Magic Software, Ltd.\n\nVersion: {1}
253
254
# ########################################################################
255
# Application Actions
256
# ########################################################################
257
258
Action.file.new.description=Create a new file
259
Action.file.new.accelerator=Shortcut+N
260
Action.file.new.icon=FILE_ALT
261
Action.file.new.text=_New
262
263
Action.file.open.description=Open a new file
264
Action.file.open.accelerator=Shortcut+O
265
Action.file.open.text=_Open...
266
Action.file.open.icon=FOLDER_OPEN_ALT
267
268
Action.file.close.description=Close the current document
269
Action.file.close.accelerator=Shortcut+W
270
Action.file.close.text=_Close
271
272
Action.file.close_all.description=Close all open documents
273
Action.file.close_all.accelerator=Ctrl+F4
274
Action.file.close_all.text=Close All
275
276
Action.file.save.description=Save the document
277
Action.file.save.accelerator=Shortcut+S
278
Action.file.save.text=_Save
279
Action.file.save.icon=FLOPPY_ALT
280
281
Action.file.save_as.description=Rename the current document
282
Action.file.save_as.text=Save _As
283
284
Action.file.save_all.description=Save all open documents
285
Action.file.save_all.accelerator=Shortcut+Shift+S
286
Action.file.save_all.text=Save A_ll
287
288
Action.file.export.html_svg.description=Export the current document as HTML + SVG
289
Action.file.export.text=_Export As
290
Action.file.export.html_svg.text=HTML and S_VG
291
292
Action.file.export.html_tex.description=Export the current document as HTML + TeX
293
Action.file.export.html_tex.text=HTML and _TeX
294
295
Action.file.export.markdown.description=Export the current document as Markdown
296
Action.file.export.markdown.text=Markdown
297
298
Action.file.exit.description=Quit the application
299
Action.file.exit.text=E_xit
300
301
302
Action.edit.undo.description=Undo the previous edit
303
Action.edit.undo.accelerator=Shortcut+Z
304
Action.edit.undo.text=_Undo
305
Action.edit.undo.icon=UNDO
306
307
Action.edit.redo.description=Redo the previous edit
308
Action.edit.redo.accelerator=Shortcut+Y
309
Action.edit.redo.text=_Redo
310
Action.edit.redo.icon=REPEAT
311
312
Action.edit.cut.description=Delete the selected text or line
313
Action.edit.cut.accelerator=Shortcut+X
314
Action.edit.cut.text=Cu_t
315
Action.edit.cut.icon=CUT
316
317
Action.edit.copy.description=Copy the selected text
318
Action.edit.copy.accelerator=Shortcut+C
319
Action.edit.copy.text=_Copy
320
Action.edit.copy.icon=COPY
321
322
Action.edit.paste.description=Paste from the clipboard
323
Action.edit.paste.accelerator=Shortcut+V
324
Action.edit.paste.text=_Paste
325
Action.edit.paste.icon=PASTE
326
327
Action.edit.select_all.description=Highlight the current document text
328
Action.edit.select_all.accelerator=Shortcut+A
329
Action.edit.select_all.text=Select _All
330
331
Action.edit.find.description=Search for text in the document
332
Action.edit.find.accelerator=Shortcut+F
333
Action.edit.find.text=_Find
334
Action.edit.find.icon=SEARCH
335
336
Action.edit.find_next.description=Find next occurrence
337
Action.edit.find_next.accelerator=F3
338
Action.edit.find_next.text=Find _Next
339
340
Action.edit.find_prev.description=Find previous occurrence
341
Action.edit.find_prev.accelerator=Shift+F3
342
Action.edit.find_prev.text=Find _Prev
343
344
Action.edit.preferences.description=Edit user preferences
345
Action.edit.preferences.accelerator=Ctrl+Alt+S
346
Action.edit.preferences.text=_Preferences
347
348
349
Action.format.bold.description=Insert strong text
350
Action.format.bold.accelerator=Shortcut+B
351
Action.format.bold.text=_Bold
352
Action.format.bold.icon=BOLD
353
354
Action.format.italic.description=Insert text emphasis
355
Action.format.italic.accelerator=Shortcut+I
356
Action.format.italic.text=_Italic
357
Action.format.italic.icon=ITALIC
358
359
Action.format.superscript.description=Insert superscript text
360
Action.format.superscript.accelerator=Shortcut+[
361
Action.format.superscript.text=Su_perscript
362
Action.format.superscript.icon=SUPERSCRIPT
363
364
Action.format.subscript.description=Insert subscript text
365
Action.format.subscript.accelerator=Shortcut+]
366
Action.format.subscript.text=Su_bscript
367
Action.format.subscript.icon=SUBSCRIPT
368
369
Action.format.strikethrough.description=Insert struck text
370
Action.format.strikethrough.accelerator=Shortcut+T
371
Action.format.strikethrough.text=Stri_kethrough
372
Action.format.strikethrough.icon=STRIKETHROUGH
373
374
375
Action.insert.blockquote.description=Insert blockquote
376
Action.insert.blockquote.accelerator=Ctrl+Q
377
Action.insert.blockquote.text=_Blockquote
378
Action.insert.blockquote.icon=QUOTE_LEFT
379
380
Action.insert.code.description=Insert inline code
381
Action.insert.code.accelerator=Shortcut+K
382
Action.insert.code.text=Inline _Code
383
Action.insert.code.icon=CODE
384
385
Action.insert.fenced_code_block.description=Insert code block
386
Action.insert.fenced_code_block.accelerator=Shortcut+Shift+K
387
Action.insert.fenced_code_block.text=_Fenced Code Block
388
Action.insert.fenced_code_block.prompt.text=Enter code here
389
Action.insert.fenced_code_block.icon=FILE_CODE_ALT
390
391
Action.insert.link.description=Insert hyperlink
392
Action.insert.link.accelerator=Shortcut+L
393
Action.insert.link.text=_Link...
394
Action.insert.link.icon=LINK
395
396
Action.insert.image.description=Insert image
397
Action.insert.image.accelerator=Shortcut+G
398
Action.insert.image.text=_Image...
399
Action.insert.image.icon=PICTURE_ALT
400
401
Action.insert.heading.description=Insert heading level
402
Action.insert.heading.accelerator=Shortcut+
403
Action.insert.heading.icon=HEADER
404
405
Action.insert.heading_1.description=${Action.insert.heading.description} 1
406
Action.insert.heading_1.accelerator=${Action.insert.heading.accelerator}1
407
Action.insert.heading_1.text=Heading _1
408
Action.insert.heading_1.icon=${Action.insert.heading.icon}
409
410
Action.insert.heading_2.description=${Action.insert.heading.description} 2
411
Action.insert.heading_2.accelerator=${Action.insert.heading.accelerator}2
412
Action.insert.heading_2.text=Heading _2
413
Action.insert.heading_2.icon=${Action.insert.heading.icon}
414
415
Action.insert.heading_3.description=${Action.insert.heading.description} 3
416
Action.insert.heading_3.accelerator=${Action.insert.heading.accelerator}3
417
Action.insert.heading_3.text=Heading _3
418
Action.insert.heading_3.icon=${Action.insert.heading.icon}
419
420
Action.insert.unordered_list.description=Insert bulleted list
421
Action.insert.unordered_list.accelerator=Shortcut+U
422
Action.insert.unordered_list.text=_Unordered List
423
Action.insert.unordered_list.icon=LIST_UL
424
425
Action.insert.ordered_list.description=Insert enumerated list
426
Action.insert.ordered_list.accelerator=Shortcut+Shift+O
427
Action.insert.ordered_list.text=_Ordered List
428
Action.insert.ordered_list.icon=LIST_OL
429
430
Action.insert.horizontal_rule.description=Insert horizontal rule
431
Action.insert.horizontal_rule.accelerator=Shortcut+H
432
Action.insert.horizontal_rule.text=_Horizontal Rule
433
Action.insert.horizontal_rule.icon=LIST_OL
434
435
436
Action.definition.create.description=Create a new variable
437
Action.definition.create.text=_Create
438
Action.definition.create.icon=TREE
439
Action.definition.create.tooltip=Add new item (Insert)
440
441
Action.definition.rename.description=Rename the selected variable
442
Action.definition.rename.text=_Rename
443
Action.definition.rename.icon=EDIT
444
Action.definition.rename.tooltip=Rename selected item (F2)
445
446
Action.definition.delete.description=Delete the selected variables
447
Action.definition.delete.text=De_lete
448
Action.definition.delete.icon=TRASH
449
Action.definition.delete.tooltip=Delete selected items (Delete)
450
451
Action.definition.insert.description=Insert a variable
452
Action.definition.insert.accelerator=Ctrl+Space
453
Action.definition.insert.text=_Insert
454
Action.definition.insert.icon=STAR
455
456
457
Action.view.refresh.description=Clear all caches
458
Action.view.refresh.accelerator=F5
459
Action.view.refresh.text=Refresh
460
461
Action.view.preview.description=Open document preview
462
Action.view.preview.accelerator=F6
463
Action.view.preview.text=Preview
464
465
Action.view.outline.description=Open document outline
466
Action.view.outline.accelerator=F7
467
Action.view.outline.text=Outline
468
469
Action.view.statistics.description=Open document word counts
470
Action.view.statistics.accelerator=F8
471
Action.view.statistics.text=Statistics
472
473
474
Action.view.files.description=Open file system browser
475
Action.view.files.accelerator=F8
476
Action.view.files.text=File system
477
478
Action.view.menubar.description=Toggle menu bar
479
Action.view.menubar.accelerator=Ctrl+F9
480
Action.view.menubar.text=Menu bar
481
482
Action.view.toolbar.description=Toggle tool bar
483
Action.view.toolbar.accelerator=Ctrl+Shift+F9
484
Action.view.toolbar.text=Tool bar
485
486
Action.view.statusbar.description=Toggle status bar
487
Action.view.statusbar.accelerator=Ctrl+Shift+Alt+F9
488
Action.view.statusbar.text=Status bar
489
490
Action.view.issues.description=Open document issues
491
Action.view.issues.accelerator=F12
492
Action.view.issues.text=Issues
493
494
495
Action.help.about.description=Show help dialog
496
Action.help.about.accelerator=F1
497
Action.help.about.text=About
498
Action.help.about.icon=INFO
488499
M src/main/resources/com/keenwrite/themes/haunted_grey.css
8282
}
8383
84
/* Avoid clipping text descenders in statistics table row. */
85
.table-row-cell {
86
  -fx-cell-size: 30px;
87
}
88