Dave Jarvis' Repositories

git clone https://repo.autonoma.ca/repo/keenwrite.git

Upgrade to KeenQuotes 4.2.1, try to force context menu focus

AuthorDaveJarvis <email>
Date2022-10-29 17:28:05 GMT-0700
Commitdaf3453873f155afb47feca989e451ae80ab55b8
Parent2a4444a
Delta521 lines added, 520 lines removed, 1-line increase
src/main/java/com/keenwrite/editors/markdown/MarkdownEditor.java
import static org.apache.commons.lang3.StringUtils.stripEnd;
import static org.apache.commons.lang3.StringUtils.stripStart;
-import static org.fxmisc.richtext.Caret.CaretVisibility.*;
-import static org.fxmisc.richtext.model.StyleSpans.singleton;
-import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
-import static org.fxmisc.wellbehaved.event.InputMap.consume;
-
-/**
- * Responsible for editing Markdown documents.
- */
-public final class MarkdownEditor extends BorderPane implements TextEditor {
- /**
- * Regular expression that matches the type of markup block. This is used
- * when Enter is pressed to continue the block environment.
- */
- private static final Pattern PATTERN_AUTO_INDENT = Pattern.compile(
- "(\\s*[*+-]\\s+|\\s*[0-9]+\\.\\s+|\\s+)(.*)" );
-
- private final Workspace mWorkspace;
-
- /**
- * The text editor.
- */
- private final StyleClassedTextArea mTextArea =
- new StyleClassedTextArea( false );
-
- /**
- * Wraps the text editor in scrollbars.
- */
- private final VirtualizedScrollPane<StyleClassedTextArea> mScrollPane =
- new VirtualizedScrollPane<>( mTextArea );
-
- /**
- * Tracks where the caret is located in this document. This offers observable
- * properties for caret position changes.
- */
- private final Caret mCaret = createCaret( mTextArea );
-
- /**
- * For spell checking the document upon load and whenever it changes.
- */
- private final TextEditorSpeller mSpeller = new TextEditorSpeller();
-
- /**
- * File being edited by this editor instance.
- */
- private File mFile;
-
- /**
- * Set to {@code true} upon text or caret position changes. Value is {@code
- * false} by default.
- */
- private final BooleanProperty mDirty = new SimpleBooleanProperty();
-
- /**
- * Opened file's character encoding, or {@link Constants#DEFAULT_CHARSET} if
- * either no encoding could be determined or this is a new (empty) file.
- */
- private final Charset mEncoding;
-
- /**
- * Tracks whether the in-memory definitions have changed with respect to the
- * persisted definitions.
- */
- private final BooleanProperty mModified = new SimpleBooleanProperty();
-
- public MarkdownEditor( final Workspace workspace ) {
- this( DOCUMENT_DEFAULT, workspace );
- }
-
- public MarkdownEditor( final File file, final Workspace workspace ) {
- mEncoding = open( mFile = file );
- mWorkspace = workspace;
-
- initTextArea( mTextArea );
- initStyle( mTextArea );
- initScrollPane( mScrollPane );
- initSpellchecker( mTextArea );
- initHotKeys();
- initUndoManager();
- }
-
- private void initTextArea( final StyleClassedTextArea textArea ) {
- textArea.setShowCaret( ON );
- textArea.setWrapText( true );
- textArea.requestFollowCaret();
- textArea.moveTo( 0 );
-
- textArea.textProperty().addListener( ( c, o, n ) -> {
- // Fire, regardless of whether the caret position has changed.
- mDirty.set( false );
-
- // Prevent the subsequent caret position change from raising dirty bits.
- mDirty.set( true );
- } );
-
- textArea.caretPositionProperty().addListener( ( c, o, n ) -> {
- // Fire when the caret position has changed and the text has not.
- mDirty.set( true );
- mDirty.set( false );
- } );
-
- textArea.focusedProperty().addListener( ( c, o, n ) -> {
- if( n != null && n ) {
- TextEditorFocusEvent.fire( this );
- }
- } );
- }
-
- private void initStyle( final StyleClassedTextArea textArea ) {
- textArea.getStyleClass().add( "markdown" );
-
- final var stylesheets = textArea.getStylesheets();
- stylesheets.add( getStylesheetPath( getLocale() ) );
-
- localeProperty().addListener( ( c, o, n ) -> {
- if( n != null ) {
- stylesheets.clear();
- stylesheets.add( getStylesheetPath( getLocale() ) );
- }
- } );
-
- fontNameProperty().addListener(
- ( c, o, n ) ->
- setFont( mTextArea, getFontName(), getFontSize() )
- );
-
- fontSizeProperty().addListener(
- ( c, o, n ) ->
- setFont( mTextArea, getFontName(), getFontSize() )
- );
-
- setFont( mTextArea, getFontName(), getFontSize() );
- }
-
- private void initScrollPane(
- final VirtualizedScrollPane<StyleClassedTextArea> scrollpane ) {
- scrollpane.setVbarPolicy( ALWAYS );
- setCenter( scrollpane );
- }
-
- private void initSpellchecker( final StyleClassedTextArea textarea ) {
- mSpeller.checkDocument( textarea );
- mSpeller.checkParagraphs( textarea );
- }
-
- private void initHotKeys() {
- addEventListener( keyPressed( ENTER ), this::onEnterPressed );
- addEventListener( keyPressed( X, CONTROL_DOWN ), this::cut );
- addEventListener( keyPressed( TAB ), this::tab );
- addEventListener( keyPressed( TAB, SHIFT_DOWN ), this::untab );
- addEventListener( keyPressed( ENTER, ALT_DOWN ), this::autofix );
- }
-
- private void initUndoManager() {
- final var undoManager = getUndoManager();
- final var markedPosition = undoManager.atMarkedPositionProperty();
-
- undoManager.forgetHistory();
- undoManager.mark();
- mModified.bind( Bindings.not( markedPosition ) );
- }
-
- @Override
- public void moveTo( final int offset ) {
- assert 0 <= offset && offset <= mTextArea.getLength();
-
- mTextArea.moveTo( offset );
- mTextArea.requestFollowCaret();
- }
-
- /**
- * Delegate the focus request to the text area itself.
- */
- @Override
- public void requestFocus() {
- mTextArea.requestFocus();
- }
-
- @Override
- public void setText( final String text ) {
- mTextArea.clear();
- mTextArea.appendText( text );
- mTextArea.getUndoManager().mark();
- }
-
- @Override
- public String getText() {
- return mTextArea.getText();
- }
-
- @Override
- public Charset getEncoding() {
- return mEncoding;
- }
-
- @Override
- public File getFile() {
- return mFile;
- }
-
- @Override
- public void rename( final File file ) {
- mFile = file;
- }
-
- @Override
- public void undo() {
- final var manager = getUndoManager();
- xxdo( manager::isUndoAvailable, manager::undo, "Main.status.error.undo" );
- }
-
- @Override
- public void redo() {
- final var manager = getUndoManager();
- xxdo( manager::isRedoAvailable, manager::redo, "Main.status.error.redo" );
- }
-
- /**
- * Performs an undo or redo action, if possible, otherwise displays an error
- * message to the user.
- *
- * @param ready Answers whether the action can be executed.
- * @param action The action to execute.
- * @param key The informational message key having a value to display if
- * the {@link Supplier} is not ready.
- */
- private void xxdo(
- final Supplier<Boolean> ready, final Runnable action, final String key ) {
- if( ready.get() ) {
- action.run();
- }
- else {
- clue( key );
- }
- }
-
- @Override
- public void cut() {
- final var selected = mTextArea.getSelectedText();
-
- // Emulate selecting the current line by firing Home then Shift+Down Arrow.
- if( selected == null || selected.isEmpty() ) {
- // Note: mTextArea.selectLine() does not select empty lines.
- mTextArea.fireEvent( keyDown( HOME, false ) );
- mTextArea.fireEvent( keyDown( DOWN, true ) );
- }
-
- mTextArea.cut();
- }
-
- @Override
- public void copy() {
- mTextArea.copy();
- }
-
- @Override
- public void paste() {
- mTextArea.paste();
- }
-
- @Override
- public void selectAll() {
- mTextArea.selectAll();
- }
-
- @Override
- public void bold() {
- enwrap( "**" );
- }
-
- @Override
- public void italic() {
- enwrap( "*" );
- }
-
- @Override
- public void monospace() {
- enwrap( "`" );
- }
-
- @Override
- public void superscript() {
- enwrap( "^" );
- }
-
- @Override
- public void subscript() {
- enwrap( "~" );
- }
-
- @Override
- public void strikethrough() {
- enwrap( "~~" );
- }
-
- @Override
- public void blockquote() {
- block( "> " );
- }
-
- @Override
- public void code() {
- enwrap( "`" );
- }
-
- @Override
- public void fencedCodeBlock() {
- enwrap( "\n\n```\n", "\n```\n\n" );
- }
-
- @Override
- public void heading( final int level ) {
- final var hashes = new String( new char[ level ] ).replace( "\0", "#" );
- block( format( "%s ", hashes ) );
- }
-
- @Override
- public void unorderedList() {
- block( "* " );
- }
-
- @Override
- public void orderedList() {
- block( "1. " );
- }
-
- @Override
- public void horizontalRule() {
- block( format( "---%n%n" ) );
- }
-
- @Override
- public Node getNode() {
- return this;
- }
-
- @Override
- public ReadOnlyBooleanProperty modifiedProperty() {
- return mModified;
- }
-
- @Override
- public void clearModifiedProperty() {
- getUndoManager().mark();
- }
-
- @Override
- public VirtualizedScrollPane<StyleClassedTextArea> getScrollPane() {
- return mScrollPane;
- }
-
- @Override
- public StyleClassedTextArea getTextArea() {
- return mTextArea;
- }
-
- private final Map<String, IndexRange> mStyles = new HashMap<>();
-
- @Override
- public void stylize( final IndexRange range, final String style ) {
- final var began = range.getStart();
- final var ended = range.getEnd() + 1;
-
- assert 0 <= began && began <= ended;
- assert style != null;
-
- // TODO: Ensure spell check and find highlights can coexist.
-// final var spans = mTextArea.getStyleSpans( range );
-// System.out.println( "SPANS: " + spans );
-
-// final var spans = mTextArea.getStyleSpans( range );
-// mTextArea.setStyleSpans( began, merge( spans, range.getLength(), style
-// ) );
-
-// final var builder = new StyleSpansBuilder<Collection<String>>();
-// builder.add( singleton( style ), range.getLength() + 1 );
-// mTextArea.setStyleSpans( began, builder.create() );
-
-// final var s = mTextArea.getStyleSpans( began, ended );
-// System.out.println( "STYLES: " +s );
-
- mStyles.put( style, range );
- mTextArea.setStyleClass( began, ended, style );
-
- // Ensure that whenever the user interacts with the text that the found
- // word will have its highlighting removed. The handler removes itself.
- // This won't remove the highlighting if the caret position moves by mouse.
- final var handler = mTextArea.getOnKeyPressed();
- mTextArea.setOnKeyPressed( event -> {
- mTextArea.setOnKeyPressed( handler );
- unstylize( style );
- } );
-
- //mTextArea.setStyleSpans(began, ended, s);
- }
-
- private static StyleSpans<Collection<String>> merge(
- StyleSpans<Collection<String>> spans, int len, String style ) {
- spans = spans.overlay(
- singleton( singletonList( style ), len ),
- ( bottomSpan, list ) -> {
- final List<String> l =
- new ArrayList<>( bottomSpan.size() + list.size() );
- l.addAll( bottomSpan );
- l.addAll( list );
- return l;
- } );
-
- return spans;
- }
-
- @Override
- public void unstylize( final String style ) {
- final var indexes = mStyles.remove( style );
- if( indexes != null ) {
- mTextArea.clearStyle( indexes.getStart(), indexes.getEnd() + 1 );
- }
- }
-
- @Override
- public Caret getCaret() {
- return mCaret;
- }
-
- /**
- * A {@link Caret} instance is not directly coupled ot the GUI because
- * document processing does not always require interactive status bar
- * updates. This can happen when processing from the command-line. However,
- * the processors need the {@link Caret} instance to inject the caret
- * position into the document. Making the {@link CaretExtension} optional
- * would require more effort than using a {@link Caret} model that is
- * decoupled from GUI widgets.
- *
- * @param editor The text editor containing caret position information.
- * @return An instance of {@link Caret} that tracks the GUI caret position.
- */
- private Caret createCaret( final StyleClassedTextArea editor ) {
- return Caret
- .builder()
- .with( Caret.Mutator::setParagraph,
- () -> editor.currentParagraphProperty().getValue() )
- .with( Caret.Mutator::setParagraphs,
- () -> editor.getParagraphs().size() )
- .with( Caret.Mutator::setParaOffset,
- () -> editor.caretColumnProperty().getValue() )
- .with( Caret.Mutator::setTextOffset,
- () -> editor.caretPositionProperty().getValue() )
- .with( Caret.Mutator::setTextLength,
- () -> editor.lengthProperty().getValue() )
- .build();
- }
-
- /**
- * This method adds listeners to editor events.
- *
- * @param <T> The event type.
- * @param <U> The consumer type for the given event type.
- * @param event The event of interest.
- * @param consumer The method to call when the event happens.
- */
- public <T extends Event, U extends T> void addEventListener(
- final EventPattern<? super T, ? extends U> event,
- final Consumer<? super U> consumer ) {
- Nodes.addInputMap( mTextArea, consume( event, consumer ) );
- }
-
- private void onEnterPressed( final KeyEvent ignored ) {
- final var currentLine = getCaretParagraph();
- final var matcher = PATTERN_AUTO_INDENT.matcher( currentLine );
-
- // By default, insert a new line by itself.
- String newText = NEWLINE;
-
- // If the pattern was matched then determine what block type to continue.
- if( matcher.matches() ) {
- if( matcher.group( 2 ).isEmpty() ) {
- final var pos = mTextArea.getCaretPosition();
- mTextArea.selectRange( pos - currentLine.length(), pos );
- }
- else {
- // Indent the new line with the same whitespace characters and
- // list markers as current line. This ensures that the indentation
- // is propagated.
- newText = newText.concat( matcher.group( 1 ) );
- }
- }
-
- mTextArea.replaceSelection( newText );
- }
-
- /**
- * Delegates to {@link #autofix()}.
- *
- * @param event Ignored.
- */
- private void autofix( final KeyEvent event ) {
- autofix();
- }
-
- public void autofix() {
- final var caretWord = getCaretWord();
- final var textArea = getTextArea();
- final var word = textArea.getText( caretWord );
- final var suggestions = mSpeller.checkWord( word, 10 );
-
- if( suggestions.isEmpty() ) {
- clue( "Editor.spelling.check.matches.none", word );
- }
- else if( !suggestions.contains( word ) ) {
- final var menu = createSuggestionsPopup();
- final var items = menu.getItems();
- textArea.setContextMenu( menu );
-
- for( final var correction : suggestions ) {
- items.add( createSuggestedItem( caretWord, correction ) );
- }
-
- textArea.getCaretBounds().ifPresent(
- bounds -> menu.show(
- textArea, bounds.getCenterX(), bounds.getCenterY()
- )
+import static org.fxmisc.richtext.Caret.CaretVisibility.ON;
+import static org.fxmisc.richtext.model.StyleSpans.singleton;
+import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
+import static org.fxmisc.wellbehaved.event.InputMap.consume;
+
+/**
+ * Responsible for editing Markdown documents.
+ */
+public final class MarkdownEditor extends BorderPane implements TextEditor {
+ /**
+ * Regular expression that matches the type of markup block. This is used
+ * when Enter is pressed to continue the block environment.
+ */
+ private static final Pattern PATTERN_AUTO_INDENT = Pattern.compile(
+ "(\\s*[*+-]\\s+|\\s*[0-9]+\\.\\s+|\\s+)(.*)" );
+
+ private final Workspace mWorkspace;
+
+ /**
+ * The text editor.
+ */
+ private final StyleClassedTextArea mTextArea =
+ new StyleClassedTextArea( false );
+
+ /**
+ * Wraps the text editor in scrollbars.
+ */
+ private final VirtualizedScrollPane<StyleClassedTextArea> mScrollPane =
+ new VirtualizedScrollPane<>( mTextArea );
+
+ /**
+ * Tracks where the caret is located in this document. This offers observable
+ * properties for caret position changes.
+ */
+ private final Caret mCaret = createCaret( mTextArea );
+
+ /**
+ * For spell checking the document upon load and whenever it changes.
+ */
+ private final TextEditorSpeller mSpeller = new TextEditorSpeller();
+
+ /**
+ * File being edited by this editor instance.
+ */
+ private File mFile;
+
+ /**
+ * Set to {@code true} upon text or caret position changes. Value is {@code
+ * false} by default.
+ */
+ private final BooleanProperty mDirty = new SimpleBooleanProperty();
+
+ /**
+ * Opened file's character encoding, or {@link Constants#DEFAULT_CHARSET} if
+ * either no encoding could be determined or this is a new (empty) file.
+ */
+ private final Charset mEncoding;
+
+ /**
+ * Tracks whether the in-memory definitions have changed with respect to the
+ * persisted definitions.
+ */
+ private final BooleanProperty mModified = new SimpleBooleanProperty();
+
+ public MarkdownEditor( final Workspace workspace ) {
+ this( DOCUMENT_DEFAULT, workspace );
+ }
+
+ public MarkdownEditor( final File file, final Workspace workspace ) {
+ mEncoding = open( mFile = file );
+ mWorkspace = workspace;
+
+ initTextArea( mTextArea );
+ initStyle( mTextArea );
+ initScrollPane( mScrollPane );
+ initSpellchecker( mTextArea );
+ initHotKeys();
+ initUndoManager();
+ }
+
+ private void initTextArea( final StyleClassedTextArea textArea ) {
+ textArea.setShowCaret( ON );
+ textArea.setWrapText( true );
+ textArea.requestFollowCaret();
+ textArea.moveTo( 0 );
+
+ textArea.textProperty().addListener( ( c, o, n ) -> {
+ // Fire, regardless of whether the caret position has changed.
+ mDirty.set( false );
+
+ // Prevent the subsequent caret position change from raising dirty bits.
+ mDirty.set( true );
+ } );
+
+ textArea.caretPositionProperty().addListener( ( c, o, n ) -> {
+ // Fire when the caret position has changed and the text has not.
+ mDirty.set( true );
+ mDirty.set( false );
+ } );
+
+ textArea.focusedProperty().addListener( ( c, o, n ) -> {
+ if( n != null && n ) {
+ TextEditorFocusEvent.fire( this );
+ }
+ } );
+ }
+
+ private void initStyle( final StyleClassedTextArea textArea ) {
+ textArea.getStyleClass().add( "markdown" );
+
+ final var stylesheets = textArea.getStylesheets();
+ stylesheets.add( getStylesheetPath( getLocale() ) );
+
+ localeProperty().addListener( ( c, o, n ) -> {
+ if( n != null ) {
+ stylesheets.clear();
+ stylesheets.add( getStylesheetPath( getLocale() ) );
+ }
+ } );
+
+ fontNameProperty().addListener(
+ ( c, o, n ) ->
+ setFont( mTextArea, getFontName(), getFontSize() )
+ );
+
+ fontSizeProperty().addListener(
+ ( c, o, n ) ->
+ setFont( mTextArea, getFontName(), getFontSize() )
+ );
+
+ setFont( mTextArea, getFontName(), getFontSize() );
+ }
+
+ private void initScrollPane(
+ final VirtualizedScrollPane<StyleClassedTextArea> scrollpane ) {
+ scrollpane.setVbarPolicy( ALWAYS );
+ setCenter( scrollpane );
+ }
+
+ private void initSpellchecker( final StyleClassedTextArea textarea ) {
+ mSpeller.checkDocument( textarea );
+ mSpeller.checkParagraphs( textarea );
+ }
+
+ private void initHotKeys() {
+ addEventListener( keyPressed( ENTER ), this::onEnterPressed );
+ addEventListener( keyPressed( X, CONTROL_DOWN ), this::cut );
+ addEventListener( keyPressed( TAB ), this::tab );
+ addEventListener( keyPressed( TAB, SHIFT_DOWN ), this::untab );
+ addEventListener( keyPressed( ENTER, ALT_DOWN ), this::autofix );
+ }
+
+ private void initUndoManager() {
+ final var undoManager = getUndoManager();
+ final var markedPosition = undoManager.atMarkedPositionProperty();
+
+ undoManager.forgetHistory();
+ undoManager.mark();
+ mModified.bind( Bindings.not( markedPosition ) );
+ }
+
+ @Override
+ public void moveTo( final int offset ) {
+ assert 0 <= offset && offset <= mTextArea.getLength();
+
+ mTextArea.moveTo( offset );
+ mTextArea.requestFollowCaret();
+ }
+
+ /**
+ * Delegate the focus request to the text area itself.
+ */
+ @Override
+ public void requestFocus() {
+ mTextArea.requestFocus();
+ }
+
+ @Override
+ public void setText( final String text ) {
+ mTextArea.clear();
+ mTextArea.appendText( text );
+ mTextArea.getUndoManager().mark();
+ }
+
+ @Override
+ public String getText() {
+ return mTextArea.getText();
+ }
+
+ @Override
+ public Charset getEncoding() {
+ return mEncoding;
+ }
+
+ @Override
+ public File getFile() {
+ return mFile;
+ }
+
+ @Override
+ public void rename( final File file ) {
+ mFile = file;
+ }
+
+ @Override
+ public void undo() {
+ final var manager = getUndoManager();
+ xxdo( manager::isUndoAvailable, manager::undo, "Main.status.error.undo" );
+ }
+
+ @Override
+ public void redo() {
+ final var manager = getUndoManager();
+ xxdo( manager::isRedoAvailable, manager::redo, "Main.status.error.redo" );
+ }
+
+ /**
+ * Performs an undo or redo action, if possible, otherwise displays an error
+ * message to the user.
+ *
+ * @param ready Answers whether the action can be executed.
+ * @param action The action to execute.
+ * @param key The informational message key having a value to display if
+ * the {@link Supplier} is not ready.
+ */
+ private void xxdo(
+ final Supplier<Boolean> ready, final Runnable action, final String key ) {
+ if( ready.get() ) {
+ action.run();
+ }
+ else {
+ clue( key );
+ }
+ }
+
+ @Override
+ public void cut() {
+ final var selected = mTextArea.getSelectedText();
+
+ // Emulate selecting the current line by firing Home then Shift+Down Arrow.
+ if( selected == null || selected.isEmpty() ) {
+ // Note: mTextArea.selectLine() does not select empty lines.
+ mTextArea.fireEvent( keyDown( HOME, false ) );
+ mTextArea.fireEvent( keyDown( DOWN, true ) );
+ }
+
+ mTextArea.cut();
+ }
+
+ @Override
+ public void copy() {
+ mTextArea.copy();
+ }
+
+ @Override
+ public void paste() {
+ mTextArea.paste();
+ }
+
+ @Override
+ public void selectAll() {
+ mTextArea.selectAll();
+ }
+
+ @Override
+ public void bold() {
+ enwrap( "**" );
+ }
+
+ @Override
+ public void italic() {
+ enwrap( "*" );
+ }
+
+ @Override
+ public void monospace() {
+ enwrap( "`" );
+ }
+
+ @Override
+ public void superscript() {
+ enwrap( "^" );
+ }
+
+ @Override
+ public void subscript() {
+ enwrap( "~" );
+ }
+
+ @Override
+ public void strikethrough() {
+ enwrap( "~~" );
+ }
+
+ @Override
+ public void blockquote() {
+ block( "> " );
+ }
+
+ @Override
+ public void code() {
+ enwrap( "`" );
+ }
+
+ @Override
+ public void fencedCodeBlock() {
+ enwrap( "\n\n```\n", "\n```\n\n" );
+ }
+
+ @Override
+ public void heading( final int level ) {
+ final var hashes = new String( new char[ level ] ).replace( "\0", "#" );
+ block( format( "%s ", hashes ) );
+ }
+
+ @Override
+ public void unorderedList() {
+ block( "* " );
+ }
+
+ @Override
+ public void orderedList() {
+ block( "1. " );
+ }
+
+ @Override
+ public void horizontalRule() {
+ block( format( "---%n%n" ) );
+ }
+
+ @Override
+ public Node getNode() {
+ return this;
+ }
+
+ @Override
+ public ReadOnlyBooleanProperty modifiedProperty() {
+ return mModified;
+ }
+
+ @Override
+ public void clearModifiedProperty() {
+ getUndoManager().mark();
+ }
+
+ @Override
+ public VirtualizedScrollPane<StyleClassedTextArea> getScrollPane() {
+ return mScrollPane;
+ }
+
+ @Override
+ public StyleClassedTextArea getTextArea() {
+ return mTextArea;
+ }
+
+ private final Map<String, IndexRange> mStyles = new HashMap<>();
+
+ @Override
+ public void stylize( final IndexRange range, final String style ) {
+ final var began = range.getStart();
+ final var ended = range.getEnd() + 1;
+
+ assert 0 <= began && began <= ended;
+ assert style != null;
+
+ // TODO: Ensure spell check and find highlights can coexist.
+// final var spans = mTextArea.getStyleSpans( range );
+// System.out.println( "SPANS: " + spans );
+
+// final var spans = mTextArea.getStyleSpans( range );
+// mTextArea.setStyleSpans( began, merge( spans, range.getLength(), style
+// ) );
+
+// final var builder = new StyleSpansBuilder<Collection<String>>();
+// builder.add( singleton( style ), range.getLength() + 1 );
+// mTextArea.setStyleSpans( began, builder.create() );
+
+// final var s = mTextArea.getStyleSpans( began, ended );
+// System.out.println( "STYLES: " +s );
+
+ mStyles.put( style, range );
+ mTextArea.setStyleClass( began, ended, style );
+
+ // Ensure that whenever the user interacts with the text that the found
+ // word will have its highlighting removed. The handler removes itself.
+ // This won't remove the highlighting if the caret position moves by mouse.
+ final var handler = mTextArea.getOnKeyPressed();
+ mTextArea.setOnKeyPressed( event -> {
+ mTextArea.setOnKeyPressed( handler );
+ unstylize( style );
+ } );
+
+ //mTextArea.setStyleSpans(began, ended, s);
+ }
+
+ private static StyleSpans<Collection<String>> merge(
+ StyleSpans<Collection<String>> spans, int len, String style ) {
+ spans = spans.overlay(
+ singleton( singletonList( style ), len ),
+ ( bottomSpan, list ) -> {
+ final List<String> l =
+ new ArrayList<>( bottomSpan.size() + list.size() );
+ l.addAll( bottomSpan );
+ l.addAll( list );
+ return l;
+ } );
+
+ return spans;
+ }
+
+ @Override
+ public void unstylize( final String style ) {
+ final var indexes = mStyles.remove( style );
+ if( indexes != null ) {
+ mTextArea.clearStyle( indexes.getStart(), indexes.getEnd() + 1 );
+ }
+ }
+
+ @Override
+ public Caret getCaret() {
+ return mCaret;
+ }
+
+ /**
+ * A {@link Caret} instance is not directly coupled ot the GUI because
+ * document processing does not always require interactive status bar
+ * updates. This can happen when processing from the command-line. However,
+ * the processors need the {@link Caret} instance to inject the caret
+ * position into the document. Making the {@link CaretExtension} optional
+ * would require more effort than using a {@link Caret} model that is
+ * decoupled from GUI widgets.
+ *
+ * @param editor The text editor containing caret position information.
+ * @return An instance of {@link Caret} that tracks the GUI caret position.
+ */
+ private Caret createCaret( final StyleClassedTextArea editor ) {
+ return Caret
+ .builder()
+ .with( Caret.Mutator::setParagraph,
+ () -> editor.currentParagraphProperty().getValue() )
+ .with( Caret.Mutator::setParagraphs,
+ () -> editor.getParagraphs().size() )
+ .with( Caret.Mutator::setParaOffset,
+ () -> editor.caretColumnProperty().getValue() )
+ .with( Caret.Mutator::setTextOffset,
+ () -> editor.caretPositionProperty().getValue() )
+ .with( Caret.Mutator::setTextLength,
+ () -> editor.lengthProperty().getValue() )
+ .build();
+ }
+
+ /**
+ * This method adds listeners to editor events.
+ *
+ * @param <T> The event type.
+ * @param <U> The consumer type for the given event type.
+ * @param event The event of interest.
+ * @param consumer The method to call when the event happens.
+ */
+ public <T extends Event, U extends T> void addEventListener(
+ final EventPattern<? super T, ? extends U> event,
+ final Consumer<? super U> consumer ) {
+ Nodes.addInputMap( mTextArea, consume( event, consumer ) );
+ }
+
+ private void onEnterPressed( final KeyEvent ignored ) {
+ final var currentLine = getCaretParagraph();
+ final var matcher = PATTERN_AUTO_INDENT.matcher( currentLine );
+
+ // By default, insert a new line by itself.
+ String newText = NEWLINE;
+
+ // If the pattern was matched then determine what block type to continue.
+ if( matcher.matches() ) {
+ if( matcher.group( 2 ).isEmpty() ) {
+ final var pos = mTextArea.getCaretPosition();
+ mTextArea.selectRange( pos - currentLine.length(), pos );
+ }
+ else {
+ // Indent the new line with the same whitespace characters and
+ // list markers as current line. This ensures that the indentation
+ // is propagated.
+ newText = newText.concat( matcher.group( 1 ) );
+ }
+ }
+
+ mTextArea.replaceSelection( newText );
+ }
+
+ /**
+ * Delegates to {@link #autofix()}.
+ *
+ * @param event Ignored.
+ */
+ private void autofix( final KeyEvent event ) {
+ autofix();
+ }
+
+ public void autofix() {
+ final var caretWord = getCaretWord();
+ final var textArea = getTextArea();
+ final var word = textArea.getText( caretWord );
+ final var suggestions = mSpeller.checkWord( word, 10 );
+
+ if( suggestions.isEmpty() ) {
+ clue( "Editor.spelling.check.matches.none", word );
+ }
+ else if( !suggestions.contains( word ) ) {
+ final var menu = createSuggestionsPopup();
+ final var items = menu.getItems();
+ textArea.setContextMenu( menu );
+
+ for( final var correction : suggestions ) {
+ items.add( createSuggestedItem( caretWord, correction ) );
+ }
+
+ textArea.getCaretBounds().ifPresent(
+ bounds -> {
+ menu.setOnShown( ( event ) -> menu.requestFocus() );
+ menu.show( textArea, bounds.getCenterX(), bounds.getCenterY() );
+ }
);
}