Dave Jarvis' Repositories

git clone https://repo.autonoma.ca/repo/keenwrite.git
M README.md
4242
1. Open a terminal window.
4343
1. Verify the installation: `java -version`
44
1. Download [keenwrite.sh](https://raw.githubusercontent.com/DaveJarvis/keenwrite/master/keenwrite.sh).
4445
1. Make `keenwrite.sh` executable.
4546
1. Run: `./keenwrite.sh`
M installer.sh
1212
readonly APP_NAME=$(find "${SCRIPT_DIR}/src" -type f -name "settings.properties" -exec cat {} \; | grep "application.title=" | cut -d'=' -f2)
1313
readonly FILE_APP_JAR="${APP_NAME}.jar"
14
15
# For GTK version, see https://bugs.openjdk.java.net/browse/JDK-8156779
1416
readonly OPT_JAVA=$(cat << END_OF_ARGS
17
-Djdk.gtk.version=2 \
1518
--add-opens=javafx.controls/javafx.scene.control=ALL-UNNAMED \
1619
--add-opens=javafx.controls/javafx.scene.control.skin=ALL-UNNAMED \
...
2326
--add-exports=javafx.graphics/com.sun.javafx.text=ALL-UNNAMED \
2427
--add-exports=javafx.graphics/com.sun.javafx.scene=ALL-UNNAMED \
28
--add-exports=javafx.graphics/com.sun.javafx.scene.text=ALL-UNNAMED \
2529
--add-exports=javafx.graphics/com.sun.javafx.scene.traversal=ALL-UNNAMED
2630
END_OF_ARGS
M keenwrite.sh
1414
  --add-exports=javafx.graphics/com.sun.javafx.scene.text=ALL-UNNAMED \
1515
  --add-exports=javafx.graphics/com.sun.javafx.scene.traversal=ALL-UNNAMED \
16
  -jar build/libs/keenwrite.jar
16
  -jar keenwrite.jar
1717
1818
D libs/flying-saucer-core-9.1.22.jar
Binary file
A libs/flying-saucer-core-9.1.23.jar
Binary file
D libs/jsymspell/jsymspell-core-1.0.jar
Binary file
A libs/jsymspell-1.0.jar
Binary file
M src/main/java/com/keenwrite/MainPane.java
10271027
   * @param event Ignored.
10281028
   */
1029
  @SuppressWarnings( "unused" )
10301029
  private void autoinsert( final KeyEvent event ) {
10311030
    autoinsert();
M src/main/java/com/keenwrite/editors/markdown/MarkdownEditor.java
1414
import javafx.event.Event;
1515
import javafx.scene.Node;
16
import javafx.scene.control.IndexRange;
17
import javafx.scene.input.KeyEvent;
18
import javafx.scene.layout.BorderPane;
19
import org.fxmisc.flowless.VirtualizedScrollPane;
20
import org.fxmisc.richtext.StyleClassedTextArea;
21
import org.fxmisc.richtext.model.StyleSpans;
22
import org.fxmisc.undo.UndoManager;
23
import org.fxmisc.wellbehaved.event.EventPattern;
24
import org.fxmisc.wellbehaved.event.Nodes;
25
26
import java.io.File;
27
import java.nio.charset.Charset;
28
import java.text.BreakIterator;
29
import java.util.*;
30
import java.util.function.Consumer;
31
import java.util.function.Supplier;
32
import java.util.regex.Pattern;
33
34
import static com.keenwrite.MainApp.keyDown;
35
import static com.keenwrite.Messages.get;
36
import static com.keenwrite.constants.Constants.*;
37
import static com.keenwrite.events.StatusEvent.clue;
38
import static com.keenwrite.events.TextEditorFocusEvent.fireTextEditorFocus;
39
import static com.keenwrite.io.MediaType.TEXT_MARKDOWN;
40
import static com.keenwrite.io.MediaType.TEXT_R_MARKDOWN;
41
import static com.keenwrite.preferences.WorkspaceKeys.*;
42
import static java.lang.Character.isWhitespace;
43
import static java.lang.String.format;
44
import static java.util.Collections.singletonList;
45
import static javafx.application.Platform.runLater;
46
import static javafx.scene.control.ScrollPane.ScrollBarPolicy.ALWAYS;
47
import static javafx.scene.input.KeyCode.*;
48
import static javafx.scene.input.KeyCombination.CONTROL_DOWN;
49
import static javafx.scene.input.KeyCombination.SHIFT_DOWN;
50
import static org.apache.commons.lang3.StringUtils.stripEnd;
51
import static org.apache.commons.lang3.StringUtils.stripStart;
52
import static org.fxmisc.richtext.model.StyleSpans.singleton;
53
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
54
import static org.fxmisc.wellbehaved.event.InputMap.consume;
55
56
/**
57
 * Responsible for editing Markdown documents.
58
 */
59
public final class MarkdownEditor extends BorderPane implements TextEditor {
60
  /**
61
   * Regular expression that matches the type of markup block. This is used
62
   * when Enter is pressed to continue the block environment.
63
   */
64
  private static final Pattern PATTERN_AUTO_INDENT = Pattern.compile(
65
    "(\\s*[*+-]\\s+|\\s*[0-9]+\\.\\s+|\\s+)(.*)" );
66
67
  /**
68
   * The text editor.
69
   */
70
  private final StyleClassedTextArea mTextArea =
71
    new StyleClassedTextArea( false );
72
73
  /**
74
   * Wraps the text editor in scrollbars.
75
   */
76
  private final VirtualizedScrollPane<StyleClassedTextArea> mScrollPane =
77
    new VirtualizedScrollPane<>( mTextArea );
78
79
  private final Workspace mWorkspace;
80
81
  /**
82
   * Tracks where the caret is located in this document. This offers observable
83
   * properties for caret position changes.
84
   */
85
  private final Caret mCaret = createCaret( mTextArea );
86
87
  /**
88
   * File being edited by this editor instance.
89
   */
90
  private File mFile;
91
92
  /**
93
   * Set to {@code true} upon text or caret position changes. Value is {@code
94
   * false} by default.
95
   */
96
  private final BooleanProperty mDirty = new SimpleBooleanProperty();
97
98
  /**
99
   * Opened file's character encoding, or {@link Constants#DEFAULT_CHARSET} if
100
   * either no encoding could be determined or this is a new (empty) file.
101
   */
102
  private final Charset mEncoding;
103
104
  /**
105
   * Tracks whether the in-memory definitions have changed with respect to the
106
   * persisted definitions.
107
   */
108
  private final BooleanProperty mModified = new SimpleBooleanProperty();
109
110
  public MarkdownEditor( final Workspace workspace ) {
111
    this( DOCUMENT_DEFAULT, workspace );
112
  }
113
114
  public MarkdownEditor( final File file, final Workspace workspace ) {
115
    mEncoding = open( mFile = file );
116
    mWorkspace = workspace;
117
118
    initTextArea( mTextArea );
119
    initStyle( mTextArea );
120
    initScrollPane( mScrollPane );
121
    initSpellchecker( mTextArea );
122
    initHotKeys();
123
    initUndoManager();
124
  }
125
126
  private void initTextArea( final StyleClassedTextArea textArea ) {
127
    textArea.setWrapText( true );
128
    textArea.requestFollowCaret();
129
    textArea.moveTo( 0 );
130
131
    textArea.textProperty().addListener( ( c, o, n ) -> {
132
      // Fire, regardless of whether the caret position has changed.
133
      mDirty.set( false );
134
135
      // Prevent a caret position change from raising the dirty bits.
136
      mDirty.set( true );
137
    } );
138
139
    textArea.caretPositionProperty().addListener( ( c, o, n ) -> {
140
      // Fire when the caret position has changed and the text has not.
141
      mDirty.set( true );
142
      mDirty.set( false );
143
    } );
144
145
    textArea.focusedProperty().addListener( ( c, o, n ) -> {
146
      if( n != null && n ) {
147
        fireTextEditorFocus( this );
148
      }
149
    } );
150
  }
151
152
  private void initStyle( final StyleClassedTextArea textArea ) {
153
    textArea.getStyleClass().add( "markdown" );
154
155
    final var stylesheets = textArea.getStylesheets();
156
    stylesheets.add( getStylesheetPath( getLocale() ) );
157
158
    localeProperty().addListener( ( c, o, n ) -> {
159
      if( n != null ) {
160
        stylesheets.clear();
161
        stylesheets.add( getStylesheetPath( getLocale() ) );
162
      }
163
    } );
164
165
    fontNameProperty().addListener(
166
      ( c, o, n ) ->
167
        setFont( mTextArea, getFontName(), getFontSize() )
168
    );
169
170
    fontSizeProperty().addListener(
171
      ( c, o, n ) ->
172
        setFont( mTextArea, getFontName(), getFontSize() )
173
    );
174
175
    setFont( mTextArea, getFontName(), getFontSize() );
176
  }
177
178
  private void initScrollPane(
179
    final VirtualizedScrollPane<StyleClassedTextArea> scrollpane ) {
180
    scrollpane.setVbarPolicy( ALWAYS );
181
    setCenter( scrollpane );
182
  }
183
184
  private void initSpellchecker( final StyleClassedTextArea textarea ) {
185
    final var speller = new TextEditorSpeller();
186
    speller.checkDocument( textarea );
187
    speller.checkParagraphs( textarea );
188
  }
189
190
  private void initHotKeys() {
191
    addEventListener( keyPressed( ENTER ), this::onEnterPressed );
192
    addEventListener( keyPressed( X, CONTROL_DOWN ), this::cut );
193
    addEventListener( keyPressed( TAB ), this::tab );
194
    addEventListener( keyPressed( TAB, SHIFT_DOWN ), this::untab );
195
    addEventListener( keyPressed( INSERT ), this::onInsertPressed );
196
  }
197
198
  private void initUndoManager() {
199
    final var undoManager = getUndoManager();
200
    final var markedPosition = undoManager.atMarkedPositionProperty();
201
202
    undoManager.forgetHistory();
203
    undoManager.mark();
204
    mModified.bind( Bindings.not( markedPosition ) );
205
  }
206
207
  @Override
208
  public void moveTo( final int offset ) {
209
    assert 0 <= offset && offset <= mTextArea.getLength();
210
    mTextArea.moveTo( offset );
211
    mTextArea.requestFollowCaret();
212
  }
213
214
  /**
215
   * Delegate the focus request to the text area itself.
216
   */
217
  @Override
218
  public void requestFocus() {
219
    mTextArea.requestFocus();
220
  }
221
222
  @Override
223
  public void setText( final String text ) {
224
    mTextArea.clear();
225
    mTextArea.appendText( text );
226
    mTextArea.getUndoManager().mark();
227
  }
228
229
  @Override
230
  public String getText() {
231
    return mTextArea.getText();
232
  }
233
234
  @Override
235
  public Charset getEncoding() {
236
    return mEncoding;
237
  }
238
239
  @Override
240
  public File getFile() {
241
    return mFile;
242
  }
243
244
  @Override
245
  public void rename( final File file ) {
246
    mFile = file;
247
  }
248
249
  @Override
250
  public void undo() {
251
    final var manager = getUndoManager();
252
    xxdo( manager::isUndoAvailable, manager::undo, "Main.status.error.undo" );
253
  }
254
255
  @Override
256
  public void redo() {
257
    final var manager = getUndoManager();
258
    xxdo( manager::isRedoAvailable, manager::redo, "Main.status.error.redo" );
259
  }
260
261
  /**
262
   * Performs an undo or redo action, if possible, otherwise displays an error
263
   * message to the user.
264
   *
265
   * @param ready  Answers whether the action can be executed.
266
   * @param action The action to execute.
267
   * @param key    The informational message key having a value to display if
268
   *               the {@link Supplier} is not ready.
269
   */
270
  private void xxdo(
271
    final Supplier<Boolean> ready, final Runnable action, final String key ) {
272
    if( ready.get() ) {
273
      action.run();
274
    }
275
    else {
276
      clue( key );
277
    }
278
  }
279
280
  @Override
281
  public void cut() {
282
    final var selected = mTextArea.getSelectedText();
283
284
    // Emulate selecting the current line by firing Home then Shift+Down Arrow.
285
    if( selected == null || selected.isEmpty() ) {
286
      // Note: mTextArea.selectLine() does not select empty lines.
287
      mTextArea.fireEvent( keyDown( HOME, false ) );
288
      mTextArea.fireEvent( keyDown( DOWN, true ) );
289
    }
290
291
    mTextArea.cut();
292
  }
293
294
  @Override
295
  public void copy() {
296
    mTextArea.copy();
297
  }
298
299
  @Override
300
  public void paste() {
301
    mTextArea.paste();
302
  }
303
304
  @Override
305
  public void selectAll() {
306
    mTextArea.selectAll();
307
  }
308
309
  @Override
310
  public void bold() {
311
    enwrap( "**" );
312
  }
313
314
  @Override
315
  public void italic() {
316
    enwrap( "*" );
317
  }
318
319
  @Override
320
  public void monospace() {
321
    enwrap( "`" );
322
  }
323
324
  @Override
325
  public void superscript() {
326
    enwrap( "^" );
327
  }
328
329
  @Override
330
  public void subscript() {
331
    enwrap( "~" );
332
  }
333
334
  @Override
335
  public void strikethrough() {
336
    enwrap( "~~" );
337
  }
338
339
  @Override
340
  public void blockquote() {
341
    block( "> " );
342
  }
343
344
  @Override
345
  public void code() {
346
    enwrap( "`" );
347
  }
348
349
  @Override
350
  public void fencedCodeBlock() {
351
    enwrap( "\n\n```\n", "\n```\n\n" );
352
  }
353
354
  @Override
355
  public void heading( final int level ) {
356
    final var hashes = new String( new char[ level ] ).replace( "\0", "#" );
357
    block( format( "%s ", hashes ) );
358
  }
359
360
  @Override
361
  public void unorderedList() {
362
    block( "* " );
363
  }
364
365
  @Override
366
  public void orderedList() {
367
    block( "1. " );
368
  }
369
370
  @Override
371
  public void horizontalRule() {
372
    block( format( "---%n%n" ) );
373
  }
374
375
  @Override
376
  public Node getNode() {
377
    return this;
378
  }
379
380
  @Override
381
  public ReadOnlyBooleanProperty modifiedProperty() {
382
    return mModified;
383
  }
384
385
  @Override
386
  public void clearModifiedProperty() {
387
    getUndoManager().mark();
388
  }
389
390
  @Override
391
  public VirtualizedScrollPane<StyleClassedTextArea> getScrollPane() {
392
    return mScrollPane;
393
  }
394
395
  @Override
396
  public StyleClassedTextArea getTextArea() {
397
    return mTextArea;
398
  }
399
400
  private final Map<String, IndexRange> mStyles = new HashMap<>();
401
402
  @Override
403
  public void stylize( final IndexRange range, final String style ) {
404
    final var began = range.getStart();
405
    final var ended = range.getEnd() + 1;
406
407
    assert 0 <= began && began <= ended;
408
    assert style != null;
409
410
    // TODO: Ensure spell check and find highlights can coexist.
411
//    final var spans = mTextArea.getStyleSpans( range );
412
//    System.out.println( "SPANS: " + spans );
413
414
//    final var spans = mTextArea.getStyleSpans( range );
415
//    mTextArea.setStyleSpans( began, merge( spans, range.getLength(), style
416
//    ) );
417
418
//    final var builder = new StyleSpansBuilder<Collection<String>>();
419
//    builder.add( singleton( style ), range.getLength() + 1 );
420
//    mTextArea.setStyleSpans( began, builder.create() );
421
422
//    final var s = mTextArea.getStyleSpans( began, ended );
423
//    System.out.println( "STYLES: " +s );
424
425
    mStyles.put( style, range );
426
    mTextArea.setStyleClass( began, ended, style );
427
428
    // Ensure that whenever the user interacts with the text that the found
429
    // word will have its highlighting removed. The handler removes itself.
430
    // This won't remove the highlighting if the caret position moves by mouse.
431
    final var handler = mTextArea.getOnKeyPressed();
432
    mTextArea.setOnKeyPressed( ( event ) -> {
433
      mTextArea.setOnKeyPressed( handler );
434
      unstylize( style );
435
    } );
436
437
    //mTextArea.setStyleSpans(began, ended, s);
438
  }
439
440
  private static StyleSpans<Collection<String>> merge(
441
    StyleSpans<Collection<String>> spans, int len, String style ) {
442
    spans = spans.overlay(
443
      singleton( singletonList( style ), len ),
444
      ( bottomSpan, list ) -> {
445
        final List<String> l =
446
          new ArrayList<>( bottomSpan.size() + list.size() );
447
        l.addAll( bottomSpan );
448
        l.addAll( list );
449
        return l;
450
      } );
451
452
    return spans;
453
  }
454
455
  @Override
456
  public void unstylize( final String style ) {
457
    final var indexes = mStyles.remove( style );
458
    if( indexes != null ) {
459
      mTextArea.clearStyle( indexes.getStart(), indexes.getEnd() + 1 );
460
    }
461
  }
462
463
  @Override
464
  public Caret getCaret() {
465
    return mCaret;
466
  }
467
468
  private Caret createCaret( final StyleClassedTextArea editor ) {
469
    return Caret
470
      .builder()
471
      .with( Caret.Mutator::setEditor, editor )
472
      .build();
473
  }
474
475
  /**
476
   * This method adds listeners to editor events.
477
   *
478
   * @param <T>      The event type.
479
   * @param <U>      The consumer type for the given event type.
480
   * @param event    The event of interest.
481
   * @param consumer The method to call when the event happens.
482
   */
483
  public <T extends Event, U extends T> void addEventListener(
484
    final EventPattern<? super T, ? extends U> event,
485
    final Consumer<? super U> consumer ) {
486
    Nodes.addInputMap( mTextArea, consume( event, consumer ) );
487
  }
488
489
  private void onEnterPressed( final KeyEvent ignored ) {
490
    final var currentLine = getCaretParagraph();
491
    final var matcher = PATTERN_AUTO_INDENT.matcher( currentLine );
492
493
    // By default, insert a new line by itself.
494
    String newText = NEWLINE;
495
496
    // If the pattern was matched then determine what block type to continue.
497
    if( matcher.matches() ) {
498
      if( matcher.group( 2 ).isEmpty() ) {
499
        final var pos = mTextArea.getCaretPosition();
500
        mTextArea.selectRange( pos - currentLine.length(), pos );
501
      }
502
      else {
503
        // Indent the new line with the same whitespace characters and
504
        // list markers as current line. This ensures that the indentation
505
        // is propagated.
506
        newText = newText.concat( matcher.group( 1 ) );
507
      }
508
    }
509
510
    mTextArea.replaceSelection( newText );
511
  }
512
513
  /**
514
   * TODO: 105 - Insert key toggle overwrite (typeover) mode
515
   *
516
   * @param ignored Unused.
517
   */
518
  private void onInsertPressed( final KeyEvent ignored ) {
519
  }
520
521
  private void cut( final KeyEvent event ) {
522
    cut();
523
  }
524
525
  private void tab( final KeyEvent event ) {
526
    final var range = mTextArea.selectionProperty().getValue();
527
    final var sb = new StringBuilder( 1024 );
528
529
    if( range.getLength() > 0 ) {
530
      final var selection = mTextArea.getSelectedText();
531
532
      selection.lines().forEach(
533
        ( l ) -> sb.append( "\t" ).append( l ).append( NEWLINE )
534
      );
535
    }
536
    else {
537
      sb.append( "\t" );
538
    }
539
540
    mTextArea.replaceSelection( sb.toString() );
541
  }
542
543
  private void untab( final KeyEvent event ) {
544
    final var range = mTextArea.selectionProperty().getValue();
545
546
    if( range.getLength() > 0 ) {
547
      final var selection = mTextArea.getSelectedText();
548
      final var sb = new StringBuilder( selection.length() );
549
550
      selection.lines().forEach(
551
        ( l ) -> sb.append( l.startsWith( "\t" ) ? l.substring( 1 ) : l )
552
                   .append( NEWLINE )
553
      );
554
555
      mTextArea.replaceSelection( sb.toString() );
556
    }
557
    else {
558
      final var p = getCaretParagraph();
559
560
      if( p.startsWith( "\t" ) ) {
561
        mTextArea.selectParagraph();
562
        mTextArea.replaceSelection( p.substring( 1 ) );
563
      }
564
    }
565
  }
566
567
  /**
568
   * Observers may listen for changes to the property returned from this method
569
   * to receive notifications when either the text or caret have changed. This
570
   * should not be used to track whether the text has been modified.
571
   */
572
  public void addDirtyListener( ChangeListener<Boolean> listener ) {
573
    mDirty.addListener( listener );
574
  }
575
576
  /**
577
   * Surrounds the selected text or word under the caret in Markdown markup.
578
   *
579
   * @param token The beginning and ending token for enclosing the text.
580
   */
581
  private void enwrap( final String token ) {
582
    enwrap( token, token );
583
  }
584
585
  /**
586
   * Surrounds the selected text or word under the caret in Markdown markup.
587
   *
588
   * @param began The beginning token for enclosing the text.
589
   * @param ended The ending token for enclosing the text.
590
   */
591
  private void enwrap( final String began, String ended ) {
592
    // Ensure selected text takes precedence over the word at caret position.
593
    final var selected = mTextArea.selectionProperty().getValue();
594
    final var range = selected.getLength() == 0
595
      ? getCaretWord()
596
      : selected;
597
    String text = mTextArea.getText( range );
598
599
    int length = range.getLength();
600
    text = stripStart( text, null );
601
    final int beganIndex = range.getStart() + (length - text.length());
602
603
    length = text.length();
604
    text = stripEnd( text, null );
605
    final int endedIndex = range.getEnd() - (length - text.length());
606
607
    mTextArea.replaceText( beganIndex, endedIndex, began + text + ended );
608
  }
609
610
  /**
611
   * Inserts the given block-level markup at the current caret position
612
   * within the document. This will prepend two blank lines to ensure that
613
   * the block element begins at the start of a new line.
614
   *
615
   * @param markup The text to insert at the caret.
616
   */
617
  private void block( final String markup ) {
618
    final int pos = mTextArea.getCaretPosition();
619
    mTextArea.insertText( pos, format( "%n%n%s", markup ) );
620
  }
621
622
  /**
623
   * Returns the caret position within the current paragraph.
624
   *
625
   * @return A value from 0 to the length of the current paragraph.
626
   */
627
  private int getCaretColumn() {
628
    return mTextArea.getCaretColumn();
629
  }
630
631
  @Override
632
  public IndexRange getCaretWord() {
633
    final var paragraph = getCaretParagraph();
16
import javafx.scene.control.ContextMenu;
17
import javafx.scene.control.IndexRange;
18
import javafx.scene.control.MenuItem;
19
import javafx.scene.input.KeyEvent;
20
import javafx.scene.layout.BorderPane;
21
import org.fxmisc.flowless.VirtualizedScrollPane;
22
import org.fxmisc.richtext.StyleClassedTextArea;
23
import org.fxmisc.richtext.model.StyleSpans;
24
import org.fxmisc.undo.UndoManager;
25
import org.fxmisc.wellbehaved.event.EventPattern;
26
import org.fxmisc.wellbehaved.event.Nodes;
27
28
import java.io.File;
29
import java.nio.charset.Charset;
30
import java.text.BreakIterator;
31
import java.util.*;
32
import java.util.function.Consumer;
33
import java.util.function.Supplier;
34
import java.util.regex.Pattern;
35
36
import static com.keenwrite.MainApp.keyDown;
37
import static com.keenwrite.Messages.get;
38
import static com.keenwrite.constants.Constants.*;
39
import static com.keenwrite.events.StatusEvent.clue;
40
import static com.keenwrite.events.TextEditorFocusEvent.fireTextEditorFocus;
41
import static com.keenwrite.io.MediaType.TEXT_MARKDOWN;
42
import static com.keenwrite.io.MediaType.TEXT_R_MARKDOWN;
43
import static com.keenwrite.preferences.WorkspaceKeys.*;
44
import static java.lang.Character.isWhitespace;
45
import static java.lang.String.format;
46
import static java.util.Collections.singletonList;
47
import static javafx.application.Platform.runLater;
48
import static javafx.scene.control.ScrollPane.ScrollBarPolicy.ALWAYS;
49
import static javafx.scene.input.KeyCode.*;
50
import static javafx.scene.input.KeyCombination.*;
51
import static org.apache.commons.lang3.StringUtils.stripEnd;
52
import static org.apache.commons.lang3.StringUtils.stripStart;
53
import static org.fxmisc.richtext.model.StyleSpans.singleton;
54
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
55
import static org.fxmisc.wellbehaved.event.InputMap.consume;
56
57
/**
58
 * Responsible for editing Markdown documents.
59
 */
60
public final class MarkdownEditor extends BorderPane implements TextEditor {
61
  /**
62
   * Regular expression that matches the type of markup block. This is used
63
   * when Enter is pressed to continue the block environment.
64
   */
65
  private static final Pattern PATTERN_AUTO_INDENT = Pattern.compile(
66
    "(\\s*[*+-]\\s+|\\s*[0-9]+\\.\\s+|\\s+)(.*)" );
67
68
  /**
69
   * The text editor.
70
   */
71
  private final StyleClassedTextArea mTextArea =
72
    new StyleClassedTextArea( false );
73
74
  /**
75
   * Wraps the text editor in scrollbars.
76
   */
77
  private final VirtualizedScrollPane<StyleClassedTextArea> mScrollPane =
78
    new VirtualizedScrollPane<>( mTextArea );
79
80
  /**
81
   *
82
   */
83
  private final TextEditorSpeller mSpeller = new TextEditorSpeller();
84
85
  private final Workspace mWorkspace;
86
87
  /**
88
   * Tracks where the caret is located in this document. This offers observable
89
   * properties for caret position changes.
90
   */
91
  private final Caret mCaret = createCaret( mTextArea );
92
93
  /**
94
   * File being edited by this editor instance.
95
   */
96
  private File mFile;
97
98
  /**
99
   * Set to {@code true} upon text or caret position changes. Value is {@code
100
   * false} by default.
101
   */
102
  private final BooleanProperty mDirty = new SimpleBooleanProperty();
103
104
  /**
105
   * Opened file's character encoding, or {@link Constants#DEFAULT_CHARSET} if
106
   * either no encoding could be determined or this is a new (empty) file.
107
   */
108
  private final Charset mEncoding;
109
110
  /**
111
   * Tracks whether the in-memory definitions have changed with respect to the
112
   * persisted definitions.
113
   */
114
  private final BooleanProperty mModified = new SimpleBooleanProperty();
115
116
  public MarkdownEditor( final Workspace workspace ) {
117
    this( DOCUMENT_DEFAULT, workspace );
118
  }
119
120
  public MarkdownEditor( final File file, final Workspace workspace ) {
121
    mEncoding = open( mFile = file );
122
    mWorkspace = workspace;
123
124
    initTextArea( mTextArea );
125
    initStyle( mTextArea );
126
    initScrollPane( mScrollPane );
127
    initSpellchecker( mTextArea );
128
    initHotKeys();
129
    initUndoManager();
130
  }
131
132
  private void initTextArea( final StyleClassedTextArea textArea ) {
133
    textArea.setWrapText( true );
134
    textArea.requestFollowCaret();
135
    textArea.moveTo( 0 );
136
137
    textArea.textProperty().addListener( ( c, o, n ) -> {
138
      // Fire, regardless of whether the caret position has changed.
139
      mDirty.set( false );
140
141
      // Prevent a caret position change from raising the dirty bits.
142
      mDirty.set( true );
143
    } );
144
145
    textArea.caretPositionProperty().addListener( ( c, o, n ) -> {
146
      // Fire when the caret position has changed and the text has not.
147
      mDirty.set( true );
148
      mDirty.set( false );
149
    } );
150
151
    textArea.focusedProperty().addListener( ( c, o, n ) -> {
152
      if( n != null && n ) {
153
        fireTextEditorFocus( this );
154
      }
155
    } );
156
  }
157
158
  private void initStyle( final StyleClassedTextArea textArea ) {
159
    textArea.getStyleClass().add( "markdown" );
160
161
    final var stylesheets = textArea.getStylesheets();
162
    stylesheets.add( getStylesheetPath( getLocale() ) );
163
164
    localeProperty().addListener( ( c, o, n ) -> {
165
      if( n != null ) {
166
        stylesheets.clear();
167
        stylesheets.add( getStylesheetPath( getLocale() ) );
168
      }
169
    } );
170
171
    fontNameProperty().addListener(
172
      ( c, o, n ) ->
173
        setFont( mTextArea, getFontName(), getFontSize() )
174
    );
175
176
    fontSizeProperty().addListener(
177
      ( c, o, n ) ->
178
        setFont( mTextArea, getFontName(), getFontSize() )
179
    );
180
181
    setFont( mTextArea, getFontName(), getFontSize() );
182
  }
183
184
  private void initScrollPane(
185
    final VirtualizedScrollPane<StyleClassedTextArea> scrollpane ) {
186
    scrollpane.setVbarPolicy( ALWAYS );
187
    setCenter( scrollpane );
188
  }
189
190
  private void initSpellchecker( final StyleClassedTextArea textarea ) {
191
    mSpeller.checkDocument( textarea );
192
    mSpeller.checkParagraphs( textarea );
193
  }
194
195
  private void initHotKeys() {
196
    addEventListener( keyPressed( ENTER ), this::onEnterPressed );
197
    addEventListener( keyPressed( X, CONTROL_DOWN ), this::cut );
198
    addEventListener( keyPressed( TAB ), this::tab );
199
    addEventListener( keyPressed( TAB, SHIFT_DOWN ), this::untab );
200
    addEventListener( keyPressed( ENTER, ALT_DOWN ), this::autofix );
201
  }
202
203
  private void initUndoManager() {
204
    final var undoManager = getUndoManager();
205
    final var markedPosition = undoManager.atMarkedPositionProperty();
206
207
    undoManager.forgetHistory();
208
    undoManager.mark();
209
    mModified.bind( Bindings.not( markedPosition ) );
210
  }
211
212
  @Override
213
  public void moveTo( final int offset ) {
214
    assert 0 <= offset && offset <= mTextArea.getLength();
215
216
    mTextArea.moveTo( offset );
217
    mTextArea.requestFollowCaret();
218
  }
219
220
  /**
221
   * Delegate the focus request to the text area itself.
222
   */
223
  @Override
224
  public void requestFocus() {
225
    mTextArea.requestFocus();
226
  }
227
228
  @Override
229
  public void setText( final String text ) {
230
    mTextArea.clear();
231
    mTextArea.appendText( text );
232
    mTextArea.getUndoManager().mark();
233
  }
234
235
  @Override
236
  public String getText() {
237
    return mTextArea.getText();
238
  }
239
240
  @Override
241
  public Charset getEncoding() {
242
    return mEncoding;
243
  }
244
245
  @Override
246
  public File getFile() {
247
    return mFile;
248
  }
249
250
  @Override
251
  public void rename( final File file ) {
252
    mFile = file;
253
  }
254
255
  @Override
256
  public void undo() {
257
    final var manager = getUndoManager();
258
    xxdo( manager::isUndoAvailable, manager::undo, "Main.status.error.undo" );
259
  }
260
261
  @Override
262
  public void redo() {
263
    final var manager = getUndoManager();
264
    xxdo( manager::isRedoAvailable, manager::redo, "Main.status.error.redo" );
265
  }
266
267
  /**
268
   * Performs an undo or redo action, if possible, otherwise displays an error
269
   * message to the user.
270
   *
271
   * @param ready  Answers whether the action can be executed.
272
   * @param action The action to execute.
273
   * @param key    The informational message key having a value to display if
274
   *               the {@link Supplier} is not ready.
275
   */
276
  private void xxdo(
277
    final Supplier<Boolean> ready, final Runnable action, final String key ) {
278
    if( ready.get() ) {
279
      action.run();
280
    }
281
    else {
282
      clue( key );
283
    }
284
  }
285
286
  @Override
287
  public void cut() {
288
    final var selected = mTextArea.getSelectedText();
289
290
    // Emulate selecting the current line by firing Home then Shift+Down Arrow.
291
    if( selected == null || selected.isEmpty() ) {
292
      // Note: mTextArea.selectLine() does not select empty lines.
293
      mTextArea.fireEvent( keyDown( HOME, false ) );
294
      mTextArea.fireEvent( keyDown( DOWN, true ) );
295
    }
296
297
    mTextArea.cut();
298
  }
299
300
  @Override
301
  public void copy() {
302
    mTextArea.copy();
303
  }
304
305
  @Override
306
  public void paste() {
307
    mTextArea.paste();
308
  }
309
310
  @Override
311
  public void selectAll() {
312
    mTextArea.selectAll();
313
  }
314
315
  @Override
316
  public void bold() {
317
    enwrap( "**" );
318
  }
319
320
  @Override
321
  public void italic() {
322
    enwrap( "*" );
323
  }
324
325
  @Override
326
  public void monospace() {
327
    enwrap( "`" );
328
  }
329
330
  @Override
331
  public void superscript() {
332
    enwrap( "^" );
333
  }
334
335
  @Override
336
  public void subscript() {
337
    enwrap( "~" );
338
  }
339
340
  @Override
341
  public void strikethrough() {
342
    enwrap( "~~" );
343
  }
344
345
  @Override
346
  public void blockquote() {
347
    block( "> " );
348
  }
349
350
  @Override
351
  public void code() {
352
    enwrap( "`" );
353
  }
354
355
  @Override
356
  public void fencedCodeBlock() {
357
    enwrap( "\n\n```\n", "\n```\n\n" );
358
  }
359
360
  @Override
361
  public void heading( final int level ) {
362
    final var hashes = new String( new char[ level ] ).replace( "\0", "#" );
363
    block( format( "%s ", hashes ) );
364
  }
365
366
  @Override
367
  public void unorderedList() {
368
    block( "* " );
369
  }
370
371
  @Override
372
  public void orderedList() {
373
    block( "1. " );
374
  }
375
376
  @Override
377
  public void horizontalRule() {
378
    block( format( "---%n%n" ) );
379
  }
380
381
  @Override
382
  public Node getNode() {
383
    return this;
384
  }
385
386
  @Override
387
  public ReadOnlyBooleanProperty modifiedProperty() {
388
    return mModified;
389
  }
390
391
  @Override
392
  public void clearModifiedProperty() {
393
    getUndoManager().mark();
394
  }
395
396
  @Override
397
  public VirtualizedScrollPane<StyleClassedTextArea> getScrollPane() {
398
    return mScrollPane;
399
  }
400
401
  @Override
402
  public StyleClassedTextArea getTextArea() {
403
    return mTextArea;
404
  }
405
406
  private final Map<String, IndexRange> mStyles = new HashMap<>();
407
408
  @Override
409
  public void stylize( final IndexRange range, final String style ) {
410
    final var began = range.getStart();
411
    final var ended = range.getEnd() + 1;
412
413
    assert 0 <= began && began <= ended;
414
    assert style != null;
415
416
    // TODO: Ensure spell check and find highlights can coexist.
417
//    final var spans = mTextArea.getStyleSpans( range );
418
//    System.out.println( "SPANS: " + spans );
419
420
//    final var spans = mTextArea.getStyleSpans( range );
421
//    mTextArea.setStyleSpans( began, merge( spans, range.getLength(), style
422
//    ) );
423
424
//    final var builder = new StyleSpansBuilder<Collection<String>>();
425
//    builder.add( singleton( style ), range.getLength() + 1 );
426
//    mTextArea.setStyleSpans( began, builder.create() );
427
428
//    final var s = mTextArea.getStyleSpans( began, ended );
429
//    System.out.println( "STYLES: " +s );
430
431
    mStyles.put( style, range );
432
    mTextArea.setStyleClass( began, ended, style );
433
434
    // Ensure that whenever the user interacts with the text that the found
435
    // word will have its highlighting removed. The handler removes itself.
436
    // This won't remove the highlighting if the caret position moves by mouse.
437
    final var handler = mTextArea.getOnKeyPressed();
438
    mTextArea.setOnKeyPressed( ( event ) -> {
439
      mTextArea.setOnKeyPressed( handler );
440
      unstylize( style );
441
    } );
442
443
    //mTextArea.setStyleSpans(began, ended, s);
444
  }
445
446
  private static StyleSpans<Collection<String>> merge(
447
    StyleSpans<Collection<String>> spans, int len, String style ) {
448
    spans = spans.overlay(
449
      singleton( singletonList( style ), len ),
450
      ( bottomSpan, list ) -> {
451
        final List<String> l =
452
          new ArrayList<>( bottomSpan.size() + list.size() );
453
        l.addAll( bottomSpan );
454
        l.addAll( list );
455
        return l;
456
      } );
457
458
    return spans;
459
  }
460
461
  @Override
462
  public void unstylize( final String style ) {
463
    final var indexes = mStyles.remove( style );
464
    if( indexes != null ) {
465
      mTextArea.clearStyle( indexes.getStart(), indexes.getEnd() + 1 );
466
    }
467
  }
468
469
  @Override
470
  public Caret getCaret() {
471
    return mCaret;
472
  }
473
474
  private Caret createCaret( final StyleClassedTextArea editor ) {
475
    return Caret
476
      .builder()
477
      .with( Caret.Mutator::setEditor, editor )
478
      .build();
479
  }
480
481
  /**
482
   * This method adds listeners to editor events.
483
   *
484
   * @param <T>      The event type.
485
   * @param <U>      The consumer type for the given event type.
486
   * @param event    The event of interest.
487
   * @param consumer The method to call when the event happens.
488
   */
489
  public <T extends Event, U extends T> void addEventListener(
490
    final EventPattern<? super T, ? extends U> event,
491
    final Consumer<? super U> consumer ) {
492
    Nodes.addInputMap( mTextArea, consume( event, consumer ) );
493
  }
494
495
  private void onEnterPressed( final KeyEvent ignored ) {
496
    final var currentLine = getCaretParagraph();
497
    final var matcher = PATTERN_AUTO_INDENT.matcher( currentLine );
498
499
    // By default, insert a new line by itself.
500
    String newText = NEWLINE;
501
502
    // If the pattern was matched then determine what block type to continue.
503
    if( matcher.matches() ) {
504
      if( matcher.group( 2 ).isEmpty() ) {
505
        final var pos = mTextArea.getCaretPosition();
506
        mTextArea.selectRange( pos - currentLine.length(), pos );
507
      }
508
      else {
509
        // Indent the new line with the same whitespace characters and
510
        // list markers as current line. This ensures that the indentation
511
        // is propagated.
512
        newText = newText.concat( matcher.group( 1 ) );
513
      }
514
    }
515
516
    mTextArea.replaceSelection( newText );
517
  }
518
519
  /**
520
   * Delegates to {@link #autofix()}.
521
   *
522
   * @param event Ignored.
523
   */
524
  private void autofix( final KeyEvent event ) {
525
    autofix();
526
  }
527
528
  public void autofix() {
529
    final var caretWord = getCaretWord();
530
    final var textArea = getTextArea();
531
    final var word = textArea.getText( caretWord );
532
    final var suggestions = mSpeller.checkWord( word, 10 );
533
534
    if( suggestions.isEmpty() ) {
535
      clue( "Editor.spelling.check.matches.none", word );
536
    }
537
    else if( !suggestions.contains( word ) ) {
538
      final var menu = createSuggestionsPopup();
539
      final var items = menu.getItems();
540
      textArea.setContextMenu( menu );
541
542
      for( final var correction : suggestions ) {
543
        items.add( createSuggestedItem( caretWord, correction ) );
544
      }
545
546
      textArea.getCaretBounds().ifPresent(
547
        bounds -> menu.show(
548
          textArea, bounds.getCenterX(), bounds.getCenterY()
549
        )
550
      );
551
    }
552
    else {
553
      clue( "Editor.spelling.check.matches.okay", word );
554
    }
555
  }
556
557
  private ContextMenu createSuggestionsPopup() {
558
    final var menu = new ContextMenu();
559
560
    menu.setAutoHide( true );
561
    menu.setHideOnEscape( true );
562
    menu.setOnHidden( event -> getTextArea().setContextMenu( null ) );
563
564
    return menu;
565
  }
566
567
  /**
568
   * Creates a menu item capable of replacing a word under the cursor.
569
   *
570
   * @param i The beginning and ending text offset to replace.
571
   * @param s The text to replace at the given offset.
572
   * @return The menu item that, if actioned, will replace the text.
573
   */
574
  private MenuItem createSuggestedItem( final IndexRange i, final String s ) {
575
    final var menuItem = new MenuItem( s );
576
577
    menuItem.setOnAction( event -> getTextArea().replaceText( i, s ) );
578
579
    return menuItem;
580
  }
581
582
  private void cut( final KeyEvent event ) {
583
    cut();
584
  }
585
586
  private void tab( final KeyEvent event ) {
587
    final var range = mTextArea.selectionProperty().getValue();
588
    final var sb = new StringBuilder( 1024 );
589
590
    if( range.getLength() > 0 ) {
591
      final var selection = mTextArea.getSelectedText();
592
593
      selection.lines().forEach(
594
        ( l ) -> sb.append( "\t" ).append( l ).append( NEWLINE )
595
      );
596
    }
597
    else {
598
      sb.append( "\t" );
599
    }
600
601
    mTextArea.replaceSelection( sb.toString() );
602
  }
603
604
  private void untab( final KeyEvent event ) {
605
    final var range = mTextArea.selectionProperty().getValue();
606
607
    if( range.getLength() > 0 ) {
608
      final var selection = mTextArea.getSelectedText();
609
      final var sb = new StringBuilder( selection.length() );
610
611
      selection.lines().forEach(
612
        ( l ) -> sb.append( l.startsWith( "\t" ) ? l.substring( 1 ) : l )
613
                   .append( NEWLINE )
614
      );
615
616
      mTextArea.replaceSelection( sb.toString() );
617
    }
618
    else {
619
      final var p = getCaretParagraph();
620
621
      if( p.startsWith( "\t" ) ) {
622
        mTextArea.selectParagraph();
623
        mTextArea.replaceSelection( p.substring( 1 ) );
624
      }
625
    }
626
  }
627
628
  /**
629
   * Observers may listen for changes to the property returned from this method
630
   * to receive notifications when either the text or caret have changed. This
631
   * should not be used to track whether the text has been modified.
632
   */
633
  public void addDirtyListener( ChangeListener<Boolean> listener ) {
634
    mDirty.addListener( listener );
635
  }
636
637
  /**
638
   * Surrounds the selected text or word under the caret in Markdown markup.
639
   *
640
   * @param token The beginning and ending token for enclosing the text.
641
   */
642
  private void enwrap( final String token ) {
643
    enwrap( token, token );
644
  }
645
646
  /**
647
   * Surrounds the selected text or word under the caret in Markdown markup.
648
   *
649
   * @param began The beginning token for enclosing the text.
650
   * @param ended The ending token for enclosing the text.
651
   */
652
  private void enwrap( final String began, String ended ) {
653
    // Ensure selected text takes precedence over the word at caret position.
654
    final var selected = mTextArea.selectionProperty().getValue();
655
    final var range = selected.getLength() == 0
656
      ? getCaretWord()
657
      : selected;
658
    String text = mTextArea.getText( range );
659
660
    int length = range.getLength();
661
    text = stripStart( text, null );
662
    final int beganIndex = range.getStart() + (length - text.length());
663
664
    length = text.length();
665
    text = stripEnd( text, null );
666
    final int endedIndex = range.getEnd() - (length - text.length());
667
668
    mTextArea.replaceText( beganIndex, endedIndex, began + text + ended );
669
  }
670
671
  /**
672
   * Inserts the given block-level markup at the current caret position
673
   * within the document. This will prepend two blank lines to ensure that
674
   * the block element begins at the start of a new line.
675
   *
676
   * @param markup The text to insert at the caret.
677
   */
678
  private void block( final String markup ) {
679
    final int pos = mTextArea.getCaretPosition();
680
    mTextArea.insertText( pos, format( "%n%n%s", markup ) );
681
  }
682
683
  /**
684
   * Returns the caret position within the current paragraph.
685
   *
686
   * @return A value from 0 to the length of the current paragraph.
687
   */
688
  private int getCaretColumn() {
689
    return mTextArea.getCaretColumn();
690
  }
691
692
  @Override
693
  public IndexRange getCaretWord() {
694
    final var paragraph = getCaretParagraph()
695
      .replaceAll( "---", "   " )
696
      .replaceAll( "--", "  " )
697
      .replaceAll( "[\\[\\]{}()]", " " );
634698
    final var length = paragraph.length();
635699
    final var column = getCaretColumn();
M src/main/java/com/keenwrite/preview/FlyingSaucerPanel.java
3131
 * Responsible for configuring FlyingSaucer's {@link XHTMLPanel}.
3232
 */
33
public final class FlyingSaucerPanel extends XHTMLPanel implements
34
  HtmlRenderer {
33
public final class FlyingSaucerPanel extends XHTMLPanel
34
  implements HtmlRenderer {
3535
3636
  /**
...
126126
127127
  @Override
128
  public void scrollTo(final String id, final JScrollPane scrollPane) {
128
  public void scrollTo( final String id, final JScrollPane scrollPane ) {
129129
    int iter = 0;
130130
    Box box = null;
...
175175
    // area within the view port. Otherwise the view port will have jumped too
176176
    // high up and the most recently typed letters won't be visible.
177
    int y = max( box.getAbsY() - scrollPane.getVerticalScrollBar().getHeight() / 2, 0 );
177
    int y = max( box.getAbsY() - scrollPane.getVerticalScrollBar()
178
                                           .getHeight() / 2, 0 );
178179
    int x = box.getAbsX();
179180
M src/main/java/com/keenwrite/spelling/impl/SymSpellSpeller.java
55
import com.keenwrite.spelling.api.SpellCheckListener;
66
import com.keenwrite.spelling.api.SpellChecker;
7
import io.gitlab.rxp90.jsymspell.SuggestItem;
87
import io.gitlab.rxp90.jsymspell.SymSpell;
98
import io.gitlab.rxp90.jsymspell.SymSpellBuilder;
9
import io.gitlab.rxp90.jsymspell.Verbosity;
10
import io.gitlab.rxp90.jsymspell.api.SuggestItem;
1011
1112
import java.io.BufferedReader;
1213
import java.io.InputStreamReader;
1314
import java.text.BreakIterator;
1415
import java.util.ArrayList;
15
import java.util.Collection;
16
import java.util.HashMap;
1617
import java.util.List;
17
import java.util.stream.Collectors;
18
import java.util.Map;
1819
1920
import static com.keenwrite.constants.Constants.LEXICONS_DIRECTORY;
2021
import static com.keenwrite.events.StatusEvent.clue;
21
import static io.gitlab.rxp90.jsymspell.SymSpell.Verbosity;
22
import static io.gitlab.rxp90.jsymspell.SymSpell.Verbosity.ALL;
23
import static io.gitlab.rxp90.jsymspell.SymSpell.Verbosity.CLOSEST;
22
import static io.gitlab.rxp90.jsymspell.Verbosity.ALL;
23
import static io.gitlab.rxp90.jsymspell.Verbosity.CLOSEST;
2424
import static java.lang.Character.isLetter;
25
import static java.lang.Long.parseLong;
2526
import static java.nio.charset.StandardCharsets.UTF_8;
2627
...
3334
3435
  /**
35
   * Creates a new spellchecker for a lexicon of words found in the specified
36
   * file.
36
   * Creates a new spellchecker for a lexicon of words in the specified file.
3737
   *
3838
   * @param filename Lexicon language file (e.g., "en.txt").
...
5050
    }
5151
  }
52
53
  private static SpellChecker forLexicon(
54
    final Collection<String> lexiconWords ) {
55
    assert lexiconWords != null && !lexiconWords.isEmpty();
5652
57
    final var builder = new SymSpellBuilder()
58
      .setLexiconWords( lexiconWords );
53
  private static SpellChecker forLexicon( final Map<String, Long> lexicon ) {
54
    assert lexicon != null && !lexicon.isEmpty();
5955
60
    return new SymSpellSpeller( builder.build() );
56
    try {
57
      return new SymSpellSpeller(
58
        new SymSpellBuilder()
59
          .setUnigramLexicon( lexicon )
60
          .build()
61
      );
62
    } catch( final Exception ex ) {
63
      clue( ex );
64
      return new PermissiveSpeller();
65
    }
6166
  }
6267
6368
  /**
6469
   * Prevent direct instantiation so that only the {@link SpellChecker}
65
   * interface
66
   * is available.
70
   * interface is available.
6771
   *
6872
   * @param symSpell The implementation-specific spell checker.
6973
   */
7074
  private SymSpellSpeller( final SymSpell symSpell ) {
7175
    mSymSpell = symSpell;
7276
  }
7377
78
  /**
79
   * This expensive operation is only called for viable words, not for
80
   * single punctuation characters or whitespace.
81
   *
82
   * @param lexeme The word to check for correctness.
83
   * @return {@code false} if the word is not in the lexicon.
84
   */
7485
  @Override
7586
  public boolean inLexicon( final String lexeme ) {
76
    return lookup( lexeme, CLOSEST ).size() == 1;
87
    assert lexeme != null;
88
    assert !lexeme.isBlank();
89
90
    final var words = lookup( lexeme, CLOSEST );
91
    return !words.isEmpty() && lexeme.equals( words.get( 0 ).getSuggestion() );
7792
  }
7893
...
95110
  @Override
96111
  public void proofread(
97
    final String text, final SpellCheckListener consumer ) {
112
    final String text,
113
    final SpellCheckListener consumer ) {
98114
    assert text != null;
99115
    assert consumer != null;
...
122138
123139
  @SuppressWarnings( "SameParameterValue" )
124
  private static Collection<String> readLexicon( final String filename )
140
  private static Map<String, Long> readLexicon( final String filename )
125141
    throws Exception {
126142
    final var path = '/' + LEXICONS_DIRECTORY + '/' + filename;
143
    final var map = new HashMap<String, Long>();
127144
128145
    try( final var resource =
129146
           SymSpellSpeller.class.getResourceAsStream( path ) ) {
130147
      if( resource == null ) {
131148
        throw new MissingFileException( path );
132149
      }
133150
134151
      try( final var isr = new InputStreamReader( resource, UTF_8 );
135152
           final var reader = new BufferedReader( isr ) ) {
136
        return reader.lines().collect( Collectors.toList() );
153
        String line;
154
155
        while( (line = reader.readLine()) != null ) {
156
          final String[] tokens = line.split( "\\t" );
157
          map.put( tokens[ 0 ], parseLong( tokens[ 1 ] ) );
158
        }
137159
      }
138160
    }
161
162
    return map;
139163
  }
140164
...
147171
   */
148172
  private boolean isWord( final String word ) {
149
    return !word.isEmpty() && isLetter( word.charAt( 0 ) );
173
    return !word.isBlank() && isLetter( word.charAt( 0 ) );
150174
  }
151175
M src/main/java/com/keenwrite/spelling/impl/TextEditorSpeller.java
1111
1212
import java.util.Collection;
13
import java.util.List;
1314
import java.util.concurrent.atomic.AtomicInteger;
1415
...
3031
3132
  public TextEditorSpeller() {
32
     mParser = Parser.builder().build();
33
    mParser = Parser.builder().build();
3334
  }
3435
...
5758
          .filter( p -> !p.isIdentity() ).subscribe( change -> {
5859
59
      // Check current paragraph; the whole document was checked upon opening.
60
      final var offset = change.getPosition();
61
      final var position = editor.offsetToPosition( offset, Forward );
62
      final var paraId = position.getMajor();
63
      final var paragraph = editor.getParagraph( paraId );
64
      final var text = paragraph.getText();
60
            // Check current paragraph; the whole document was checked upon
61
            // opening.
62
            final var offset = change.getPosition();
63
            final var position = editor.offsetToPosition( offset, Forward );
64
            final var paraId = position.getMajor();
65
            final var paragraph = editor.getParagraph( paraId );
66
            final var text = paragraph.getText();
6567
66
      // Prevent doubling-up styles.
67
      editor.clearStyle( paraId );
68
            // Prevent doubling-up styles.
69
            editor.clearStyle( paraId );
6870
69
      spellcheck( editor, text, paraId );
70
    } );
71
            spellcheck( editor, text, paraId );
72
          } );
7173
  }
7274
...
120122
      }
121123
    }
124
  }
125
126
  /**
127
   * Returns a list of suggests for the given word. This is typically used to
128
   * check for suitable replacements of the word at the caret position.
129
   *
130
   * @param word  The word to spellcheck.
131
   * @param count The maximum number of suggested alternatives to return.
132
   * @return A list of recommended spellings for the given word.
133
   */
134
  public List<String> checkWord( final String word, final int count ) {
135
    return sSpellChecker.suggestions( word, count );
122136
  }
123137
M src/main/resources/com/keenwrite/messages.properties
135135
136136
# ########################################################################
137
# Editor actions
138
# ########################################################################
139
140
Editor.spelling.check.matches.none=No suggestions for ''{0}'' found.
141
Editor.spelling.check.matches.okay=The spelling for ''{0}'' appears to be correct.
142
143
# ########################################################################
137144
# Menu Bar
138145
# ########################################################################
...
268275
TextResource.load.error.unsaved=The file ''{0}'' is unsaved or does not exist.
269276
TextResource.load.error.permissions=The file ''{0}'' must be readable and writable.
270
271
# ########################################################################
272
# Text Resources
273
# ########################################################################
274277
275278
TextResource.saveFailed.message=Failed to save ''{0}''.\n\nReason: {1}
M src/main/resources/lexicons/README.md
11
# Building
22
3
The lexicon files are retrieved from SymSpell in the parent directory:
3
The lexicon files are retrieved from:
44
5
svn export \
6
  https://github.com/wolfgarbe/SymSpell/trunk/SymSpell.FrequencyDictionary/ lexicons
5
https://github.com/wolfgarbe/SymSpell/tree/master/SymSpell
76
8
The lexicons and bigrams are both space-separated, but parsing a
7
The lexicons and bigrams are space-separated by default, but parsing a
98
tab-delimited file is easier, so change them to tab-separated files.
9
1010
M src/main/resources/lexicons/en.txt
Binary file
M src/main/resources/lexicons/ext/contractions.txt
1
21
'aight
32
ain't