| | 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() ); |
| | + } |
| | ); |
| | } |