Dave Jarvis' Repositories

git clone https://repo.autonoma.ca/repo/keenwrite.git
M BUILD.md
77
Download and install the following software packages:
88
9
* [JDK 18](https://bell-sw.com/pages/downloads/?version=java-18) (Full JDK + JavaFX)
10
* [Gradle 7.3](https://gradle.org/releases) (build fails on 7.5.1)
11
* [Git 2.37.1](https://git-scm.com/downloads)
9
* [JDK 19](https://bell-sw.com/pages/downloads) (Full JDK + JavaFX)
10
* [Gradle 7.6-rc-1](https://services.gradle.org/distributions/gradle-7.6-rc-1-bin.zip)
11
* [Git 2.38.1](https://git-scm.com/downloads)
1212
1313
## Repository
...
3434
# Integrated development environments
3535
36
This section describes setup instructions to import and run the application using an integrated development environment (IDE). Running the application should trigger a build.
36
This section describes setup instructions to import and run the application
37
using an integrated development environment (IDE). Running the application
38
should trigger a build.
3739
3840
## IntelliJ IDEA
3941
40
This section describes how to build and run the application using IntellIJ's IDEA.
42
This section describes how to build and run the application using
43
IntellIJ's IDEA.
4144
4245
### Import
...
6467
# Installers
6568
66
This section describes how to set up the development environment and build native executables for supported operating systems.
69
This section describes how to set up the development environment and build
70
native executables for supported operating systems.
6771
6872
## Setup
...
102106
# Versioning
103107
104
Version numbers are read directly from Git using a plugin. The version number is written to `app.properties` in the `resources` directory. The application reads that file to display version information upon start.
108
Version numbers are read directly from Git using a plugin. The version
109
number is written to `app.properties` in the `resources` directory. The
110
application reads that file to display version information upon start.
105111
106112
M README.md
3838
On other platforms, start the application as follows:
3939
40
1. Download the *full version* of the Java Runtime Environment, [JRE 18](https://bell-sw.com/pages/downloads/#/java-18).
40
1. Download the *Full version* of the Java Runtime Environment, [JRE 19](https://bell-sw.com/pages/downloads).
4141
1. Install the JRE.
4242
1. Open a terminal window.
M README.zh-CN.md
3434
### Other
3535
36
Download and install a full version of [OpenJDK 18](https://bell-sw.com/pages/downloads/#/java-18) that includes JavaFX module support, then run:
36
Download and install a full version of [OpenJDK 19](https://bell-sw.com/pages/downloads) that includes JavaFX module support, then run:
3737
3838
``` bash
M build.gradle
5151
5252
javafx {
53
  version = '18'
53
  version = '19'
5454
  modules = ['javafx.controls', 'javafx.swing']
5555
  configuration = 'compileOnly'
5656
}
5757
5858
dependencies {
59
  def v_junit = '5.9.0'
59
  def v_junit = '5.9.1'
6060
  def v_flexmark = '0.64.0'
6161
  def v_jackson = '2.13.4'
M installer.sh
3131
ARG_JAVA_OS="linux"
3232
ARG_JAVA_ARCH="amd64"
33
ARG_JAVA_VERSION="18.0.2"
34
ARG_JAVA_UPDATE="10"
33
ARG_JAVA_VERSION="19.0.1"
34
ARG_JAVA_UPDATE="11"
3535
ARG_JAVA_DIR="java"
3636
M libs/keenquotes.jar
Binary file
M src/main/java/com/keenwrite/editors/markdown/MarkdownEditor.java
5252
import static org.apache.commons.lang3.StringUtils.stripEnd;
5353
import static org.apache.commons.lang3.StringUtils.stripStart;
54
import static org.fxmisc.richtext.Caret.CaretVisibility.*;
55
import static org.fxmisc.richtext.model.StyleSpans.singleton;
56
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
57
import static org.fxmisc.wellbehaved.event.InputMap.consume;
58
59
/**
60
 * Responsible for editing Markdown documents.
61
 */
62
public final class MarkdownEditor extends BorderPane implements TextEditor {
63
  /**
64
   * Regular expression that matches the type of markup block. This is used
65
   * when Enter is pressed to continue the block environment.
66
   */
67
  private static final Pattern PATTERN_AUTO_INDENT = Pattern.compile(
68
    "(\\s*[*+-]\\s+|\\s*[0-9]+\\.\\s+|\\s+)(.*)" );
69
70
  private final Workspace mWorkspace;
71
72
  /**
73
   * The text editor.
74
   */
75
  private final StyleClassedTextArea mTextArea =
76
    new StyleClassedTextArea( false );
77
78
  /**
79
   * Wraps the text editor in scrollbars.
80
   */
81
  private final VirtualizedScrollPane<StyleClassedTextArea> mScrollPane =
82
    new VirtualizedScrollPane<>( mTextArea );
83
84
  /**
85
   * Tracks where the caret is located in this document. This offers observable
86
   * properties for caret position changes.
87
   */
88
  private final Caret mCaret = createCaret( mTextArea );
89
90
  /**
91
   * For spell checking the document upon load and whenever it changes.
92
   */
93
  private final TextEditorSpeller mSpeller = new TextEditorSpeller();
94
95
  /**
96
   * File being edited by this editor instance.
97
   */
98
  private File mFile;
99
100
  /**
101
   * Set to {@code true} upon text or caret position changes. Value is {@code
102
   * false} by default.
103
   */
104
  private final BooleanProperty mDirty = new SimpleBooleanProperty();
105
106
  /**
107
   * Opened file's character encoding, or {@link Constants#DEFAULT_CHARSET} if
108
   * either no encoding could be determined or this is a new (empty) file.
109
   */
110
  private final Charset mEncoding;
111
112
  /**
113
   * Tracks whether the in-memory definitions have changed with respect to the
114
   * persisted definitions.
115
   */
116
  private final BooleanProperty mModified = new SimpleBooleanProperty();
117
118
  public MarkdownEditor( final Workspace workspace ) {
119
    this( DOCUMENT_DEFAULT, workspace );
120
  }
121
122
  public MarkdownEditor( final File file, final Workspace workspace ) {
123
    mEncoding = open( mFile = file );
124
    mWorkspace = workspace;
125
126
    initTextArea( mTextArea );
127
    initStyle( mTextArea );
128
    initScrollPane( mScrollPane );
129
    initSpellchecker( mTextArea );
130
    initHotKeys();
131
    initUndoManager();
132
  }
133
134
  private void initTextArea( final StyleClassedTextArea textArea ) {
135
    textArea.setShowCaret( ON );
136
    textArea.setWrapText( true );
137
    textArea.requestFollowCaret();
138
    textArea.moveTo( 0 );
139
140
    textArea.textProperty().addListener( ( c, o, n ) -> {
141
      // Fire, regardless of whether the caret position has changed.
142
      mDirty.set( false );
143
144
      // Prevent the subsequent caret position change from raising dirty bits.
145
      mDirty.set( true );
146
    } );
147
148
    textArea.caretPositionProperty().addListener( ( c, o, n ) -> {
149
      // Fire when the caret position has changed and the text has not.
150
      mDirty.set( true );
151
      mDirty.set( false );
152
    } );
153
154
    textArea.focusedProperty().addListener( ( c, o, n ) -> {
155
      if( n != null && n ) {
156
        TextEditorFocusEvent.fire( this );
157
      }
158
    } );
159
  }
160
161
  private void initStyle( final StyleClassedTextArea textArea ) {
162
    textArea.getStyleClass().add( "markdown" );
163
164
    final var stylesheets = textArea.getStylesheets();
165
    stylesheets.add( getStylesheetPath( getLocale() ) );
166
167
    localeProperty().addListener( ( c, o, n ) -> {
168
      if( n != null ) {
169
        stylesheets.clear();
170
        stylesheets.add( getStylesheetPath( getLocale() ) );
171
      }
172
    } );
173
174
    fontNameProperty().addListener(
175
      ( c, o, n ) ->
176
        setFont( mTextArea, getFontName(), getFontSize() )
177
    );
178
179
    fontSizeProperty().addListener(
180
      ( c, o, n ) ->
181
        setFont( mTextArea, getFontName(), getFontSize() )
182
    );
183
184
    setFont( mTextArea, getFontName(), getFontSize() );
185
  }
186
187
  private void initScrollPane(
188
    final VirtualizedScrollPane<StyleClassedTextArea> scrollpane ) {
189
    scrollpane.setVbarPolicy( ALWAYS );
190
    setCenter( scrollpane );
191
  }
192
193
  private void initSpellchecker( final StyleClassedTextArea textarea ) {
194
    mSpeller.checkDocument( textarea );
195
    mSpeller.checkParagraphs( textarea );
196
  }
197
198
  private void initHotKeys() {
199
    addEventListener( keyPressed( ENTER ), this::onEnterPressed );
200
    addEventListener( keyPressed( X, CONTROL_DOWN ), this::cut );
201
    addEventListener( keyPressed( TAB ), this::tab );
202
    addEventListener( keyPressed( TAB, SHIFT_DOWN ), this::untab );
203
    addEventListener( keyPressed( ENTER, ALT_DOWN ), this::autofix );
204
  }
205
206
  private void initUndoManager() {
207
    final var undoManager = getUndoManager();
208
    final var markedPosition = undoManager.atMarkedPositionProperty();
209
210
    undoManager.forgetHistory();
211
    undoManager.mark();
212
    mModified.bind( Bindings.not( markedPosition ) );
213
  }
214
215
  @Override
216
  public void moveTo( final int offset ) {
217
    assert 0 <= offset && offset <= mTextArea.getLength();
218
219
    mTextArea.moveTo( offset );
220
    mTextArea.requestFollowCaret();
221
  }
222
223
  /**
224
   * Delegate the focus request to the text area itself.
225
   */
226
  @Override
227
  public void requestFocus() {
228
    mTextArea.requestFocus();
229
  }
230
231
  @Override
232
  public void setText( final String text ) {
233
    mTextArea.clear();
234
    mTextArea.appendText( text );
235
    mTextArea.getUndoManager().mark();
236
  }
237
238
  @Override
239
  public String getText() {
240
    return mTextArea.getText();
241
  }
242
243
  @Override
244
  public Charset getEncoding() {
245
    return mEncoding;
246
  }
247
248
  @Override
249
  public File getFile() {
250
    return mFile;
251
  }
252
253
  @Override
254
  public void rename( final File file ) {
255
    mFile = file;
256
  }
257
258
  @Override
259
  public void undo() {
260
    final var manager = getUndoManager();
261
    xxdo( manager::isUndoAvailable, manager::undo, "Main.status.error.undo" );
262
  }
263
264
  @Override
265
  public void redo() {
266
    final var manager = getUndoManager();
267
    xxdo( manager::isRedoAvailable, manager::redo, "Main.status.error.redo" );
268
  }
269
270
  /**
271
   * Performs an undo or redo action, if possible, otherwise displays an error
272
   * message to the user.
273
   *
274
   * @param ready  Answers whether the action can be executed.
275
   * @param action The action to execute.
276
   * @param key    The informational message key having a value to display if
277
   *               the {@link Supplier} is not ready.
278
   */
279
  private void xxdo(
280
    final Supplier<Boolean> ready, final Runnable action, final String key ) {
281
    if( ready.get() ) {
282
      action.run();
283
    }
284
    else {
285
      clue( key );
286
    }
287
  }
288
289
  @Override
290
  public void cut() {
291
    final var selected = mTextArea.getSelectedText();
292
293
    // Emulate selecting the current line by firing Home then Shift+Down Arrow.
294
    if( selected == null || selected.isEmpty() ) {
295
      // Note: mTextArea.selectLine() does not select empty lines.
296
      mTextArea.fireEvent( keyDown( HOME, false ) );
297
      mTextArea.fireEvent( keyDown( DOWN, true ) );
298
    }
299
300
    mTextArea.cut();
301
  }
302
303
  @Override
304
  public void copy() {
305
    mTextArea.copy();
306
  }
307
308
  @Override
309
  public void paste() {
310
    mTextArea.paste();
311
  }
312
313
  @Override
314
  public void selectAll() {
315
    mTextArea.selectAll();
316
  }
317
318
  @Override
319
  public void bold() {
320
    enwrap( "**" );
321
  }
322
323
  @Override
324
  public void italic() {
325
    enwrap( "*" );
326
  }
327
328
  @Override
329
  public void monospace() {
330
    enwrap( "`" );
331
  }
332
333
  @Override
334
  public void superscript() {
335
    enwrap( "^" );
336
  }
337
338
  @Override
339
  public void subscript() {
340
    enwrap( "~" );
341
  }
342
343
  @Override
344
  public void strikethrough() {
345
    enwrap( "~~" );
346
  }
347
348
  @Override
349
  public void blockquote() {
350
    block( "> " );
351
  }
352
353
  @Override
354
  public void code() {
355
    enwrap( "`" );
356
  }
357
358
  @Override
359
  public void fencedCodeBlock() {
360
    enwrap( "\n\n```\n", "\n```\n\n" );
361
  }
362
363
  @Override
364
  public void heading( final int level ) {
365
    final var hashes = new String( new char[ level ] ).replace( "\0", "#" );
366
    block( format( "%s ", hashes ) );
367
  }
368
369
  @Override
370
  public void unorderedList() {
371
    block( "* " );
372
  }
373
374
  @Override
375
  public void orderedList() {
376
    block( "1. " );
377
  }
378
379
  @Override
380
  public void horizontalRule() {
381
    block( format( "---%n%n" ) );
382
  }
383
384
  @Override
385
  public Node getNode() {
386
    return this;
387
  }
388
389
  @Override
390
  public ReadOnlyBooleanProperty modifiedProperty() {
391
    return mModified;
392
  }
393
394
  @Override
395
  public void clearModifiedProperty() {
396
    getUndoManager().mark();
397
  }
398
399
  @Override
400
  public VirtualizedScrollPane<StyleClassedTextArea> getScrollPane() {
401
    return mScrollPane;
402
  }
403
404
  @Override
405
  public StyleClassedTextArea getTextArea() {
406
    return mTextArea;
407
  }
408
409
  private final Map<String, IndexRange> mStyles = new HashMap<>();
410
411
  @Override
412
  public void stylize( final IndexRange range, final String style ) {
413
    final var began = range.getStart();
414
    final var ended = range.getEnd() + 1;
415
416
    assert 0 <= began && began <= ended;
417
    assert style != null;
418
419
    // TODO: Ensure spell check and find highlights can coexist.
420
//    final var spans = mTextArea.getStyleSpans( range );
421
//    System.out.println( "SPANS: " + spans );
422
423
//    final var spans = mTextArea.getStyleSpans( range );
424
//    mTextArea.setStyleSpans( began, merge( spans, range.getLength(), style
425
//    ) );
426
427
//    final var builder = new StyleSpansBuilder<Collection<String>>();
428
//    builder.add( singleton( style ), range.getLength() + 1 );
429
//    mTextArea.setStyleSpans( began, builder.create() );
430
431
//    final var s = mTextArea.getStyleSpans( began, ended );
432
//    System.out.println( "STYLES: " +s );
433
434
    mStyles.put( style, range );
435
    mTextArea.setStyleClass( began, ended, style );
436
437
    // Ensure that whenever the user interacts with the text that the found
438
    // word will have its highlighting removed. The handler removes itself.
439
    // This won't remove the highlighting if the caret position moves by mouse.
440
    final var handler = mTextArea.getOnKeyPressed();
441
    mTextArea.setOnKeyPressed( event -> {
442
      mTextArea.setOnKeyPressed( handler );
443
      unstylize( style );
444
    } );
445
446
    //mTextArea.setStyleSpans(began, ended, s);
447
  }
448
449
  private static StyleSpans<Collection<String>> merge(
450
    StyleSpans<Collection<String>> spans, int len, String style ) {
451
    spans = spans.overlay(
452
      singleton( singletonList( style ), len ),
453
      ( bottomSpan, list ) -> {
454
        final List<String> l =
455
          new ArrayList<>( bottomSpan.size() + list.size() );
456
        l.addAll( bottomSpan );
457
        l.addAll( list );
458
        return l;
459
      } );
460
461
    return spans;
462
  }
463
464
  @Override
465
  public void unstylize( final String style ) {
466
    final var indexes = mStyles.remove( style );
467
    if( indexes != null ) {
468
      mTextArea.clearStyle( indexes.getStart(), indexes.getEnd() + 1 );
469
    }
470
  }
471
472
  @Override
473
  public Caret getCaret() {
474
    return mCaret;
475
  }
476
477
  /**
478
   * A {@link Caret} instance is not directly coupled ot the GUI because
479
   * document processing does not always require interactive status bar
480
   * updates. This can happen when processing from the command-line. However,
481
   * the processors need the {@link Caret} instance to inject the caret
482
   * position into the document. Making the {@link CaretExtension} optional
483
   * would require more effort than using a {@link Caret} model that is
484
   * decoupled from GUI widgets.
485
   *
486
   * @param editor The text editor containing caret position information.
487
   * @return An instance of {@link Caret} that tracks the GUI caret position.
488
   */
489
  private Caret createCaret( final StyleClassedTextArea editor ) {
490
    return Caret
491
      .builder()
492
      .with( Caret.Mutator::setParagraph,
493
             () -> editor.currentParagraphProperty().getValue() )
494
      .with( Caret.Mutator::setParagraphs,
495
             () -> editor.getParagraphs().size() )
496
      .with( Caret.Mutator::setParaOffset,
497
             () -> editor.caretColumnProperty().getValue() )
498
      .with( Caret.Mutator::setTextOffset,
499
             () -> editor.caretPositionProperty().getValue() )
500
      .with( Caret.Mutator::setTextLength,
501
             () -> editor.lengthProperty().getValue() )
502
      .build();
503
  }
504
505
  /**
506
   * This method adds listeners to editor events.
507
   *
508
   * @param <T>      The event type.
509
   * @param <U>      The consumer type for the given event type.
510
   * @param event    The event of interest.
511
   * @param consumer The method to call when the event happens.
512
   */
513
  public <T extends Event, U extends T> void addEventListener(
514
    final EventPattern<? super T, ? extends U> event,
515
    final Consumer<? super U> consumer ) {
516
    Nodes.addInputMap( mTextArea, consume( event, consumer ) );
517
  }
518
519
  private void onEnterPressed( final KeyEvent ignored ) {
520
    final var currentLine = getCaretParagraph();
521
    final var matcher = PATTERN_AUTO_INDENT.matcher( currentLine );
522
523
    // By default, insert a new line by itself.
524
    String newText = NEWLINE;
525
526
    // If the pattern was matched then determine what block type to continue.
527
    if( matcher.matches() ) {
528
      if( matcher.group( 2 ).isEmpty() ) {
529
        final var pos = mTextArea.getCaretPosition();
530
        mTextArea.selectRange( pos - currentLine.length(), pos );
531
      }
532
      else {
533
        // Indent the new line with the same whitespace characters and
534
        // list markers as current line. This ensures that the indentation
535
        // is propagated.
536
        newText = newText.concat( matcher.group( 1 ) );
537
      }
538
    }
539
540
    mTextArea.replaceSelection( newText );
541
  }
542
543
  /**
544
   * Delegates to {@link #autofix()}.
545
   *
546
   * @param event Ignored.
547
   */
548
  private void autofix( final KeyEvent event ) {
549
    autofix();
550
  }
551
552
  public void autofix() {
553
    final var caretWord = getCaretWord();
554
    final var textArea = getTextArea();
555
    final var word = textArea.getText( caretWord );
556
    final var suggestions = mSpeller.checkWord( word, 10 );
557
558
    if( suggestions.isEmpty() ) {
559
      clue( "Editor.spelling.check.matches.none", word );
560
    }
561
    else if( !suggestions.contains( word ) ) {
562
      final var menu = createSuggestionsPopup();
563
      final var items = menu.getItems();
564
      textArea.setContextMenu( menu );
565
566
      for( final var correction : suggestions ) {
567
        items.add( createSuggestedItem( caretWord, correction ) );
568
      }
569
570
      textArea.getCaretBounds().ifPresent(
571
        bounds -> menu.show(
572
          textArea, bounds.getCenterX(), bounds.getCenterY()
573
        )
54
import static org.fxmisc.richtext.Caret.CaretVisibility.ON;
55
import static org.fxmisc.richtext.model.StyleSpans.singleton;
56
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
57
import static org.fxmisc.wellbehaved.event.InputMap.consume;
58
59
/**
60
 * Responsible for editing Markdown documents.
61
 */
62
public final class MarkdownEditor extends BorderPane implements TextEditor {
63
  /**
64
   * Regular expression that matches the type of markup block. This is used
65
   * when Enter is pressed to continue the block environment.
66
   */
67
  private static final Pattern PATTERN_AUTO_INDENT = Pattern.compile(
68
    "(\\s*[*+-]\\s+|\\s*[0-9]+\\.\\s+|\\s+)(.*)" );
69
70
  private final Workspace mWorkspace;
71
72
  /**
73
   * The text editor.
74
   */
75
  private final StyleClassedTextArea mTextArea =
76
    new StyleClassedTextArea( false );
77
78
  /**
79
   * Wraps the text editor in scrollbars.
80
   */
81
  private final VirtualizedScrollPane<StyleClassedTextArea> mScrollPane =
82
    new VirtualizedScrollPane<>( mTextArea );
83
84
  /**
85
   * Tracks where the caret is located in this document. This offers observable
86
   * properties for caret position changes.
87
   */
88
  private final Caret mCaret = createCaret( mTextArea );
89
90
  /**
91
   * For spell checking the document upon load and whenever it changes.
92
   */
93
  private final TextEditorSpeller mSpeller = new TextEditorSpeller();
94
95
  /**
96
   * File being edited by this editor instance.
97
   */
98
  private File mFile;
99
100
  /**
101
   * Set to {@code true} upon text or caret position changes. Value is {@code
102
   * false} by default.
103
   */
104
  private final BooleanProperty mDirty = new SimpleBooleanProperty();
105
106
  /**
107
   * Opened file's character encoding, or {@link Constants#DEFAULT_CHARSET} if
108
   * either no encoding could be determined or this is a new (empty) file.
109
   */
110
  private final Charset mEncoding;
111
112
  /**
113
   * Tracks whether the in-memory definitions have changed with respect to the
114
   * persisted definitions.
115
   */
116
  private final BooleanProperty mModified = new SimpleBooleanProperty();
117
118
  public MarkdownEditor( final Workspace workspace ) {
119
    this( DOCUMENT_DEFAULT, workspace );
120
  }
121
122
  public MarkdownEditor( final File file, final Workspace workspace ) {
123
    mEncoding = open( mFile = file );
124
    mWorkspace = workspace;
125
126
    initTextArea( mTextArea );
127
    initStyle( mTextArea );
128
    initScrollPane( mScrollPane );
129
    initSpellchecker( mTextArea );
130
    initHotKeys();
131
    initUndoManager();
132
  }
133
134
  private void initTextArea( final StyleClassedTextArea textArea ) {
135
    textArea.setShowCaret( ON );
136
    textArea.setWrapText( true );
137
    textArea.requestFollowCaret();
138
    textArea.moveTo( 0 );
139
140
    textArea.textProperty().addListener( ( c, o, n ) -> {
141
      // Fire, regardless of whether the caret position has changed.
142
      mDirty.set( false );
143
144
      // Prevent the subsequent caret position change from raising dirty bits.
145
      mDirty.set( true );
146
    } );
147
148
    textArea.caretPositionProperty().addListener( ( c, o, n ) -> {
149
      // Fire when the caret position has changed and the text has not.
150
      mDirty.set( true );
151
      mDirty.set( false );
152
    } );
153
154
    textArea.focusedProperty().addListener( ( c, o, n ) -> {
155
      if( n != null && n ) {
156
        TextEditorFocusEvent.fire( this );
157
      }
158
    } );
159
  }
160
161
  private void initStyle( final StyleClassedTextArea textArea ) {
162
    textArea.getStyleClass().add( "markdown" );
163
164
    final var stylesheets = textArea.getStylesheets();
165
    stylesheets.add( getStylesheetPath( getLocale() ) );
166
167
    localeProperty().addListener( ( c, o, n ) -> {
168
      if( n != null ) {
169
        stylesheets.clear();
170
        stylesheets.add( getStylesheetPath( getLocale() ) );
171
      }
172
    } );
173
174
    fontNameProperty().addListener(
175
      ( c, o, n ) ->
176
        setFont( mTextArea, getFontName(), getFontSize() )
177
    );
178
179
    fontSizeProperty().addListener(
180
      ( c, o, n ) ->
181
        setFont( mTextArea, getFontName(), getFontSize() )
182
    );
183
184
    setFont( mTextArea, getFontName(), getFontSize() );
185
  }
186
187
  private void initScrollPane(
188
    final VirtualizedScrollPane<StyleClassedTextArea> scrollpane ) {
189
    scrollpane.setVbarPolicy( ALWAYS );
190
    setCenter( scrollpane );
191
  }
192
193
  private void initSpellchecker( final StyleClassedTextArea textarea ) {
194
    mSpeller.checkDocument( textarea );
195
    mSpeller.checkParagraphs( textarea );
196
  }
197
198
  private void initHotKeys() {
199
    addEventListener( keyPressed( ENTER ), this::onEnterPressed );
200
    addEventListener( keyPressed( X, CONTROL_DOWN ), this::cut );
201
    addEventListener( keyPressed( TAB ), this::tab );
202
    addEventListener( keyPressed( TAB, SHIFT_DOWN ), this::untab );
203
    addEventListener( keyPressed( ENTER, ALT_DOWN ), this::autofix );
204
  }
205
206
  private void initUndoManager() {
207
    final var undoManager = getUndoManager();
208
    final var markedPosition = undoManager.atMarkedPositionProperty();
209
210
    undoManager.forgetHistory();
211
    undoManager.mark();
212
    mModified.bind( Bindings.not( markedPosition ) );
213
  }
214
215
  @Override
216
  public void moveTo( final int offset ) {
217
    assert 0 <= offset && offset <= mTextArea.getLength();
218
219
    mTextArea.moveTo( offset );
220
    mTextArea.requestFollowCaret();
221
  }
222
223
  /**
224
   * Delegate the focus request to the text area itself.
225
   */
226
  @Override
227
  public void requestFocus() {
228
    mTextArea.requestFocus();
229
  }
230
231
  @Override
232
  public void setText( final String text ) {
233
    mTextArea.clear();
234
    mTextArea.appendText( text );
235
    mTextArea.getUndoManager().mark();
236
  }
237
238
  @Override
239
  public String getText() {
240
    return mTextArea.getText();
241
  }
242
243
  @Override
244
  public Charset getEncoding() {
245
    return mEncoding;
246
  }
247
248
  @Override
249
  public File getFile() {
250
    return mFile;
251
  }
252
253
  @Override
254
  public void rename( final File file ) {
255
    mFile = file;
256
  }
257
258
  @Override
259
  public void undo() {
260
    final var manager = getUndoManager();
261
    xxdo( manager::isUndoAvailable, manager::undo, "Main.status.error.undo" );
262
  }
263
264
  @Override
265
  public void redo() {
266
    final var manager = getUndoManager();
267
    xxdo( manager::isRedoAvailable, manager::redo, "Main.status.error.redo" );
268
  }
269
270
  /**
271
   * Performs an undo or redo action, if possible, otherwise displays an error
272
   * message to the user.
273
   *
274
   * @param ready  Answers whether the action can be executed.
275
   * @param action The action to execute.
276
   * @param key    The informational message key having a value to display if
277
   *               the {@link Supplier} is not ready.
278
   */
279
  private void xxdo(
280
    final Supplier<Boolean> ready, final Runnable action, final String key ) {
281
    if( ready.get() ) {
282
      action.run();
283
    }
284
    else {
285
      clue( key );
286
    }
287
  }
288
289
  @Override
290
  public void cut() {
291
    final var selected = mTextArea.getSelectedText();
292
293
    // Emulate selecting the current line by firing Home then Shift+Down Arrow.
294
    if( selected == null || selected.isEmpty() ) {
295
      // Note: mTextArea.selectLine() does not select empty lines.
296
      mTextArea.fireEvent( keyDown( HOME, false ) );
297
      mTextArea.fireEvent( keyDown( DOWN, true ) );
298
    }
299
300
    mTextArea.cut();
301
  }
302
303
  @Override
304
  public void copy() {
305
    mTextArea.copy();
306
  }
307
308
  @Override
309
  public void paste() {
310
    mTextArea.paste();
311
  }
312
313
  @Override
314
  public void selectAll() {
315
    mTextArea.selectAll();
316
  }
317
318
  @Override
319
  public void bold() {
320
    enwrap( "**" );
321
  }
322
323
  @Override
324
  public void italic() {
325
    enwrap( "*" );
326
  }
327
328
  @Override
329
  public void monospace() {
330
    enwrap( "`" );
331
  }
332
333
  @Override
334
  public void superscript() {
335
    enwrap( "^" );
336
  }
337
338
  @Override
339
  public void subscript() {
340
    enwrap( "~" );
341
  }
342
343
  @Override
344
  public void strikethrough() {
345
    enwrap( "~~" );
346
  }
347
348
  @Override
349
  public void blockquote() {
350
    block( "> " );
351
  }
352
353
  @Override
354
  public void code() {
355
    enwrap( "`" );
356
  }
357
358
  @Override
359
  public void fencedCodeBlock() {
360
    enwrap( "\n\n```\n", "\n```\n\n" );
361
  }
362
363
  @Override
364
  public void heading( final int level ) {
365
    final var hashes = new String( new char[ level ] ).replace( "\0", "#" );
366
    block( format( "%s ", hashes ) );
367
  }
368
369
  @Override
370
  public void unorderedList() {
371
    block( "* " );
372
  }
373
374
  @Override
375
  public void orderedList() {
376
    block( "1. " );
377
  }
378
379
  @Override
380
  public void horizontalRule() {
381
    block( format( "---%n%n" ) );
382
  }
383
384
  @Override
385
  public Node getNode() {
386
    return this;
387
  }
388
389
  @Override
390
  public ReadOnlyBooleanProperty modifiedProperty() {
391
    return mModified;
392
  }
393
394
  @Override
395
  public void clearModifiedProperty() {
396
    getUndoManager().mark();
397
  }
398
399
  @Override
400
  public VirtualizedScrollPane<StyleClassedTextArea> getScrollPane() {
401
    return mScrollPane;
402
  }
403
404
  @Override
405
  public StyleClassedTextArea getTextArea() {
406
    return mTextArea;
407
  }
408
409
  private final Map<String, IndexRange> mStyles = new HashMap<>();
410
411
  @Override
412
  public void stylize( final IndexRange range, final String style ) {
413
    final var began = range.getStart();
414
    final var ended = range.getEnd() + 1;
415
416
    assert 0 <= began && began <= ended;
417
    assert style != null;
418
419
    // TODO: Ensure spell check and find highlights can coexist.
420
//    final var spans = mTextArea.getStyleSpans( range );
421
//    System.out.println( "SPANS: " + spans );
422
423
//    final var spans = mTextArea.getStyleSpans( range );
424
//    mTextArea.setStyleSpans( began, merge( spans, range.getLength(), style
425
//    ) );
426
427
//    final var builder = new StyleSpansBuilder<Collection<String>>();
428
//    builder.add( singleton( style ), range.getLength() + 1 );
429
//    mTextArea.setStyleSpans( began, builder.create() );
430
431
//    final var s = mTextArea.getStyleSpans( began, ended );
432
//    System.out.println( "STYLES: " +s );
433
434
    mStyles.put( style, range );
435
    mTextArea.setStyleClass( began, ended, style );
436
437
    // Ensure that whenever the user interacts with the text that the found
438
    // word will have its highlighting removed. The handler removes itself.
439
    // This won't remove the highlighting if the caret position moves by mouse.
440
    final var handler = mTextArea.getOnKeyPressed();
441
    mTextArea.setOnKeyPressed( event -> {
442
      mTextArea.setOnKeyPressed( handler );
443
      unstylize( style );
444
    } );
445
446
    //mTextArea.setStyleSpans(began, ended, s);
447
  }
448
449
  private static StyleSpans<Collection<String>> merge(
450
    StyleSpans<Collection<String>> spans, int len, String style ) {
451
    spans = spans.overlay(
452
      singleton( singletonList( style ), len ),
453
      ( bottomSpan, list ) -> {
454
        final List<String> l =
455
          new ArrayList<>( bottomSpan.size() + list.size() );
456
        l.addAll( bottomSpan );
457
        l.addAll( list );
458
        return l;
459
      } );
460
461
    return spans;
462
  }
463
464
  @Override
465
  public void unstylize( final String style ) {
466
    final var indexes = mStyles.remove( style );
467
    if( indexes != null ) {
468
      mTextArea.clearStyle( indexes.getStart(), indexes.getEnd() + 1 );
469
    }
470
  }
471
472
  @Override
473
  public Caret getCaret() {
474
    return mCaret;
475
  }
476
477
  /**
478
   * A {@link Caret} instance is not directly coupled ot the GUI because
479
   * document processing does not always require interactive status bar
480
   * updates. This can happen when processing from the command-line. However,
481
   * the processors need the {@link Caret} instance to inject the caret
482
   * position into the document. Making the {@link CaretExtension} optional
483
   * would require more effort than using a {@link Caret} model that is
484
   * decoupled from GUI widgets.
485
   *
486
   * @param editor The text editor containing caret position information.
487
   * @return An instance of {@link Caret} that tracks the GUI caret position.
488
   */
489
  private Caret createCaret( final StyleClassedTextArea editor ) {
490
    return Caret
491
      .builder()
492
      .with( Caret.Mutator::setParagraph,
493
             () -> editor.currentParagraphProperty().getValue() )
494
      .with( Caret.Mutator::setParagraphs,
495
             () -> editor.getParagraphs().size() )
496
      .with( Caret.Mutator::setParaOffset,
497
             () -> editor.caretColumnProperty().getValue() )
498
      .with( Caret.Mutator::setTextOffset,
499
             () -> editor.caretPositionProperty().getValue() )
500
      .with( Caret.Mutator::setTextLength,
501
             () -> editor.lengthProperty().getValue() )
502
      .build();
503
  }
504
505
  /**
506
   * This method adds listeners to editor events.
507
   *
508
   * @param <T>      The event type.
509
   * @param <U>      The consumer type for the given event type.
510
   * @param event    The event of interest.
511
   * @param consumer The method to call when the event happens.
512
   */
513
  public <T extends Event, U extends T> void addEventListener(
514
    final EventPattern<? super T, ? extends U> event,
515
    final Consumer<? super U> consumer ) {
516
    Nodes.addInputMap( mTextArea, consume( event, consumer ) );
517
  }
518
519
  private void onEnterPressed( final KeyEvent ignored ) {
520
    final var currentLine = getCaretParagraph();
521
    final var matcher = PATTERN_AUTO_INDENT.matcher( currentLine );
522
523
    // By default, insert a new line by itself.
524
    String newText = NEWLINE;
525
526
    // If the pattern was matched then determine what block type to continue.
527
    if( matcher.matches() ) {
528
      if( matcher.group( 2 ).isEmpty() ) {
529
        final var pos = mTextArea.getCaretPosition();
530
        mTextArea.selectRange( pos - currentLine.length(), pos );
531
      }
532
      else {
533
        // Indent the new line with the same whitespace characters and
534
        // list markers as current line. This ensures that the indentation
535
        // is propagated.
536
        newText = newText.concat( matcher.group( 1 ) );
537
      }
538
    }
539
540
    mTextArea.replaceSelection( newText );
541
  }
542
543
  /**
544
   * Delegates to {@link #autofix()}.
545
   *
546
   * @param event Ignored.
547
   */
548
  private void autofix( final KeyEvent event ) {
549
    autofix();
550
  }
551
552
  public void autofix() {
553
    final var caretWord = getCaretWord();
554
    final var textArea = getTextArea();
555
    final var word = textArea.getText( caretWord );
556
    final var suggestions = mSpeller.checkWord( word, 10 );
557
558
    if( suggestions.isEmpty() ) {
559
      clue( "Editor.spelling.check.matches.none", word );
560
    }
561
    else if( !suggestions.contains( word ) ) {
562
      final var menu = createSuggestionsPopup();
563
      final var items = menu.getItems();
564
      textArea.setContextMenu( menu );
565
566
      for( final var correction : suggestions ) {
567
        items.add( createSuggestedItem( caretWord, correction ) );
568
      }
569
570
      textArea.getCaretBounds().ifPresent(
571
        bounds -> {
572
          menu.setOnShown( event -> menu.requestFocus() );
573
          menu.show( textArea, bounds.getCenterX(), bounds.getCenterY() );
574
        }
574575
      );
575576
    }