Dave Jarvis' Repositories

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

Add words to English lexicon, integrate i18n spellcheck, refactor spelling functionality

AuthorDaveJarvis <email>
Date2022-12-04 18:06:19 GMT-0800
Commit2e0e4dc71975a815f33b56a74cd6794ecdf15cf6
Parent911d85b
src/main/java/com/keenwrite/MainApp.java
import com.keenwrite.events.HyperlinkOpenEvent;
import com.keenwrite.preferences.Workspace;
+import com.keenwrite.spelling.impl.Lexicon;
import javafx.application.Application;
import javafx.event.Event;
import static javafx.scene.input.KeyEvent.KEY_PRESSED;
import static javafx.scene.input.KeyEvent.KEY_RELEASED;
+import static javafx.stage.WindowEvent.WINDOW_SHOWN;
/**
// because it interacts with GUI properties.
mWorkspace = new Workspace();
+
+ // The locale was already loaded when the workspace was created. This
+ // ensures that when the locale preference changes, a new spellchecker
+ // instance will be loaded and applied.
+ final var property = mWorkspace.localeProperty( KEY_LANGUAGE_LOCALE );
+ property.addListener( ( c, o, n ) -> readLexicon() );
initFonts();
initState( stage );
initStage( stage );
initIcons( stage );
initScene( stage );
+ // Load the lexicon and check all the documents after all files are open.
+ stage.addEventFilter( WINDOW_SHOWN, event -> readLexicon() );
stage.show();
public void handle( final HyperlinkOpenEvent event ) {
getHostServices().showDocument( event.getUri().toString() );
+ }
+
+ /**
+ * This will load the lexicon for the user's preferred locale and fire
+ * an event when the all entries in the lexicon have been loaded.
+ */
+ private void readLexicon() {
+ Lexicon.read( mWorkspace.getLocale() );
}
src/main/java/com/keenwrite/MainPane.java
import com.keenwrite.editors.markdown.MarkdownEditor;
import com.keenwrite.events.*;
-import com.keenwrite.io.MediaType;
-import com.keenwrite.preferences.Workspace;
-import com.keenwrite.preview.HtmlPreview;
-import com.keenwrite.processors.HtmlPreviewProcessor;
-import com.keenwrite.processors.Processor;
-import com.keenwrite.processors.ProcessorContext;
-import com.keenwrite.processors.ProcessorFactory;
-import com.keenwrite.processors.r.Engine;
-import com.keenwrite.processors.r.RBootstrapController;
-import com.keenwrite.service.events.Notifier;
-import com.keenwrite.ui.explorer.FilePickerFactory;
-import com.keenwrite.ui.heuristics.DocumentStatistics;
-import com.keenwrite.ui.outline.DocumentOutline;
-import com.keenwrite.util.GenericBuilder;
-import com.panemu.tiwulfx.control.dock.DetachableTab;
-import com.panemu.tiwulfx.control.dock.DetachableTabPane;
-import javafx.application.Platform;
-import javafx.beans.property.*;
-import javafx.collections.ListChangeListener;
-import javafx.concurrent.Task;
-import javafx.event.ActionEvent;
-import javafx.event.Event;
-import javafx.event.EventHandler;
-import javafx.scene.Node;
-import javafx.scene.Scene;
-import javafx.scene.control.*;
-import javafx.scene.control.TreeItem.TreeModificationEvent;
-import javafx.scene.input.KeyEvent;
-import javafx.scene.layout.FlowPane;
-import javafx.stage.Stage;
-import javafx.stage.Window;
-import org.greenrobot.eventbus.Subscribe;
-
-import java.io.File;
-import java.io.FileNotFoundException;
-import java.nio.file.Path;
-import java.util.*;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.ScheduledExecutorService;
-import java.util.concurrent.ScheduledFuture;
-import java.util.concurrent.atomic.AtomicBoolean;
-import java.util.concurrent.atomic.AtomicReference;
-import java.util.function.Function;
-import java.util.stream.Collectors;
-
-import static com.keenwrite.ExportFormat.NONE;
-import static com.keenwrite.Launcher.terminate;
-import static com.keenwrite.Messages.get;
-import static com.keenwrite.constants.Constants.*;
-import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG_NODE;
-import static com.keenwrite.events.Bus.register;
-import static com.keenwrite.events.StatusEvent.clue;
-import static com.keenwrite.io.MediaType.*;
-import static com.keenwrite.preferences.AppKeys.*;
-import static com.keenwrite.processors.IdentityProcessor.IDENTITY;
-import static com.keenwrite.processors.ProcessorContext.Mutator;
-import static com.keenwrite.processors.ProcessorContext.builder;
-import static com.keenwrite.processors.ProcessorFactory.createProcessors;
-import static java.lang.String.format;
-import static java.lang.System.getProperty;
-import static java.util.concurrent.Executors.newFixedThreadPool;
-import static java.util.concurrent.Executors.newScheduledThreadPool;
-import static java.util.concurrent.TimeUnit.SECONDS;
-import static java.util.stream.Collectors.groupingBy;
-import static javafx.application.Platform.runLater;
-import static javafx.scene.control.Alert.AlertType.ERROR;
-import static javafx.scene.control.ButtonType.*;
-import static javafx.scene.control.TabPane.TabClosingPolicy.ALL_TABS;
-import static javafx.scene.input.KeyCode.SPACE;
-import static javafx.scene.input.KeyCombination.CONTROL_DOWN;
-import static javafx.util.Duration.millis;
-import static javax.swing.SwingUtilities.invokeLater;
-import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
-
-/**
- * Responsible for wiring together the main application components for a
- * particular {@link Workspace} (project). These include the definition views,
- * text editors, and preview pane along with any corresponding controllers.
- */
-public final class MainPane extends SplitPane {
-
- private static final ExecutorService sExecutor = newFixedThreadPool( 1 );
- private static final Notifier sNotifier = Services.load( Notifier.class );
-
- /**
- * Used when opening files to determine how each file should be binned and
- * therefore what tab pane to be opened within.
- */
- private static final Set<MediaType> PLAIN_TEXT_FORMAT = Set.of(
- TEXT_MARKDOWN, TEXT_R_MARKDOWN, UNDEFINED
- );
-
- private final ScheduledExecutorService mSaver = newScheduledThreadPool( 1 );
- private final AtomicReference<ScheduledFuture<?>> mSaveTask =
- new AtomicReference<>();
-
- /**
- * Prevents re-instantiation of processing classes.
- */
- private final Map<TextResource, Processor<String>> mProcessors =
- new HashMap<>();
-
- private final Workspace mWorkspace;
-
- /**
- * Groups similar file type tabs together.
- */
- private final List<TabPane> mTabPanes = new ArrayList<>();
-
- /**
- * Renders the actively selected plain text editor tab.
- */
- private final HtmlPreview mPreview;
-
- /**
- * Provides an interactive document outline.
- */
- private final DocumentOutline mOutline = new DocumentOutline();
-
- /**
- * Changing the active editor fires the value changed event. This allows
- * refreshes to happen when external definitions are modified and need to
- * trigger the processing chain.
- */
- private final ObjectProperty<TextEditor> mTextEditor =
- createActiveTextEditor();
-
- /**
- * Changing the active definition editor fires the value changed event. This
- * allows refreshes to happen when external definitions are modified and need
- * to trigger the processing chain.
- */
- private final ObjectProperty<TextDefinition> mDefinitionEditor;
-
- /**
- * Called when the definition data is changed.
- */
- private final EventHandler<TreeModificationEvent<Event>> mTreeHandler =
- event -> {
- process( getTextEditor() );
- save( getTextDefinition() );
- };
-
- /**
- * Tracks the number of detached tab panels opened into their own windows,
- * which allows unique identification of subordinate windows by their title.
- * It is doubtful more than 128 windows, much less 256, will be created.
- */
- private byte mWindowCount;
-
- private final VariableNameInjector mVariableNameInjector;
-
- private final RBootstrapController mRBootstrapController;
-
- private final DocumentStatistics mStatistics;
-
- /**
- * Adds all content panels to the main user interface. This will load the
- * configuration settings from the workspace to reproduce the settings from
- * a previous session.
- */
- public MainPane( final Workspace workspace ) {
- mWorkspace = workspace;
- mPreview = new HtmlPreview( workspace );
- mStatistics = new DocumentStatistics( workspace );
- mTextEditor.set( new MarkdownEditor( workspace ) );
- mDefinitionEditor = createActiveDefinitionEditor( mTextEditor );
- mVariableNameInjector = new VariableNameInjector( mWorkspace );
- mRBootstrapController = new RBootstrapController(
- mWorkspace, this::getDefinitions );
-
- open( collect( getRecentFiles() ) );
- viewPreview();
- setDividerPositions( calculateDividerPositions() );
-
- // Once the main scene's window regains focus, update the active definition
- // editor to the currently selected tab.
- runLater( () -> getWindow().setOnCloseRequest( event -> {
- // Order matters: Open file names must be persisted before closing all.
- mWorkspace.save();
-
- if( closeAll() ) {
- Platform.exit();
- terminate( 0 );
- }
-
- event.consume();
- } ) );
-
- register( this );
- initAutosave( workspace );
-
- restoreSession();
- runLater( this::restoreFocus );
- }
-
- @Subscribe
- public void handle( final TextEditorFocusEvent event ) {
- mTextEditor.set( event.get() );
- }
-
- @Subscribe
- public void handle( final TextDefinitionFocusEvent event ) {
- mDefinitionEditor.set( event.get() );
- }
-
- /**
- * Typically called when a file name is clicked in the preview panel.
- *
- * @param event The event to process, must contain a valid file reference.
- */
- @Subscribe
- public void handle( final FileOpenEvent event ) {
- final File eventFile;
- final var eventUri = event.getUri();
-
- if( eventUri.isAbsolute() ) {
- eventFile = new File( eventUri.getPath() );
- }
- else {
- final var activeFile = getTextEditor().getFile();
- final var parent = activeFile.getParentFile();
-
- if( parent == null ) {
- clue( new FileNotFoundException( eventUri.getPath() ) );
- return;
- }
- else {
- final var parentPath = parent.getAbsolutePath();
- eventFile = Path.of( parentPath, eventUri.getPath() ).toFile();
- }
- }
-
- runLater( () -> open( eventFile ) );
- }
-
- @Subscribe
- public void handle( final CaretNavigationEvent event ) {
- runLater( () -> {
- final var textArea = getTextEditor();
- textArea.moveTo( event.getOffset() );
- textArea.requestFocus();
- } );
- }
-
- @Subscribe
- @SuppressWarnings( "unused" )
- public void handle( final ExportFailedEvent event ) {
- final var os = getProperty( "os.name" );
- final var arch = getProperty( "os.arch" ).toLowerCase();
- final var bits = getProperty( "sun.arch.data.model" );
-
- final var title = Messages.get( "Alert.typesetter.missing.title" );
- final var header = Messages.get( "Alert.typesetter.missing.header" );
- final var version = Messages.get(
- "Alert.typesetter.missing.version",
- os,
- arch
- .replaceAll( "amd.*|i.*|x86.*", "X86" )
- .replaceAll( "mips.*", "MIPS" )
- .replaceAll( "armv.*", "ARM" ),
- bits );
- final var text = Messages.get( "Alert.typesetter.missing.installer.text" );
-
- // Download and install ConTeXt for {0} {1} {2}-bit
- final var content = format( "%s %s", text, version );
- final var flowPane = new FlowPane();
- final var link = new Hyperlink( text );
- final var label = new Label( version );
- flowPane.getChildren().addAll( link, label );
-
- final var alert = new Alert( ERROR, content, OK );
- alert.setTitle( title );
- alert.setHeaderText( header );
- alert.getDialogPane().contentProperty().set( flowPane );
- alert.setGraphic( ICON_DIALOG_NODE );
-
- link.setOnAction( e -> {
- alert.close();
- final var url = Messages.get( "Alert.typesetter.missing.installer.url" );
- runLater( () -> HyperlinkOpenEvent.fire( url ) );
- } );
-
- alert.showAndWait();
- }
-
- @Subscribe
- public void handle( final InsertDefinitionEvent<String> event ) {
- final var leaf = event.getLeaf();
- final var editor = mTextEditor.get();
-
- mVariableNameInjector.insert( editor, leaf );
- }
-
- private void initAutosave( final Workspace workspace ) {
- final var rate = workspace.integerProperty( KEY_EDITOR_AUTOSAVE );
-
- rate.addListener(
- ( c, o, n ) -> {
- final var taskRef = mSaveTask.get();
-
- // Prevent multiple autosaves from running.
- if( taskRef != null ) {
- taskRef.cancel( false );
- }
-
- initAutosave( rate );
- }
- );
-
- // Start the save listener (avoids duplicating some code).
- initAutosave( rate );
- }
-
- private void initAutosave( final IntegerProperty rate ) {
- mSaveTask.set(
- mSaver.scheduleAtFixedRate(
- () -> {
- if( getTextEditor().isModified() ) {
- // Ensure the modified indicator is cleared by running on EDT.
- runLater( this::save );
- }
- }, 0, rate.intValue(), SECONDS
- )
- );
- }
-
- /**
- * TODO: Load divider positions from exported settings, see
- * {@link #collect(SetProperty)} comment.
- */
- private double[] calculateDividerPositions() {
- final var ratio = 100f / getItems().size() / 100;
- final var positions = getDividerPositions();
-
- for( int i = 0; i < positions.length; i++ ) {
- positions[ i ] = ratio * i;
- }
-
- return positions;
- }
-
- /**
- * Opens all the files into the application, provided the paths are unique.
- * This may only be called for any type of files that a user can edit
- * (i.e., update and persist), such as definitions and text files.
- *
- * @param files The list of files to open.
- */
- public void open( final List<File> files ) {
- files.forEach( this::open );
- }
-
- /**
- * This opens the given file. Since the preview pane is not a file that
- * can be opened, it is safe to add a listener to the detachable pane.
- * This will exit early if the given file is not a regular file (i.e., a
- * directory).
- *
- * @param inputFile The file to open.
- */
- private void open( final File inputFile ) {
- // Prevent opening directories (a non-existent "untitled.md" is fine).
- if( !inputFile.isFile() && inputFile.exists() ) {
- return;
- }
-
- final var tab = createTab( inputFile );
- final var node = tab.getContent();
- final var mediaType = MediaType.valueFrom( inputFile );
- final var tabPane = obtainTabPane( mediaType );
-
- tab.setTooltip( createTooltip( inputFile ) );
- tabPane.setFocusTraversable( false );
- tabPane.setTabClosingPolicy( ALL_TABS );
- tabPane.getTabs().add( tab );
-
- // Attach the tab scene factory for new tab panes.
- if( !getItems().contains( tabPane ) ) {
- addTabPane(
- node instanceof TextDefinition ? 0 : getItems().size(), tabPane
- );
- }
-
- if( inputFile.isFile() ) {
- getRecentFiles().add( inputFile.getAbsolutePath() );
- }
- }
-
- /**
- * Gives focus to the most recently edited document and attempts to move
- * the caret to the most recently known offset into said document.
- */
- private void restoreSession() {
- final var workspace = getWorkspace();
- final var file = workspace.fileProperty( KEY_UI_RECENT_DOCUMENT );
- final var offset = workspace.integerProperty( KEY_UI_RECENT_OFFSET );
-
- for( final var pane : mTabPanes ) {
- for( final var tab : pane.getTabs() ) {
- final var tooltip = tab.getTooltip();
-
- if( tooltip != null ) {
- final var tabName = tooltip.getText();
- final var fileName = file.getValue().toString();
-
- if( tabName.equalsIgnoreCase( fileName ) ) {
- final var node = tab.getContent();
-
- pane.getSelectionModel().select( tab );
- node.requestFocus();
-
- if( node instanceof TextEditor editor ) {
- editor.moveTo( offset.getValue() );
- }
-
- break;
- }
- }
- }
- }
- }
-
- /**
- * Sets the focus to the middle pane, which contains the text editor tabs.
- */
- private void restoreFocus() {
- // Work around a bug where focusing directly on the middle pane results
- // in the R engine not loading variables properly.
- mTabPanes.get( 0 ).requestFocus();
-
- // This is the only line that should be required.
- mTabPanes.get( 1 ).requestFocus();
- }
-
- /**
- * Opens a new text editor document using the default document file name.
- */
- public void newTextEditor() {
- open( DOCUMENT_DEFAULT );
- }
-
- /**
- * Opens a new definition editor document using the default definition
- * file name.
- */
- public void newDefinitionEditor() {
- open( DEFINITION_DEFAULT );
- }
-
- /**
- * Iterates over all tab panes to find all {@link TextEditor}s and request
- * that they save themselves.
- */
- public void saveAll() {
- mTabPanes.forEach(
- tp -> tp.getTabs().forEach( tab -> {
- final var node = tab.getContent();
-
- if( node instanceof final TextEditor editor ) {
- save( editor );
- }
- } )
- );
- }
-
- /**
- * Requests that the active {@link TextEditor} saves itself. Don't bother
- * checking if modified first because if the user swaps external media from
- * an external source (e.g., USB thumb drive), save should not second-guess
- * the user: save always re-saves. Also, it's less code.
- */
- public void save() {
- save( getTextEditor() );
- }
-
- /**
- * Saves the active {@link TextEditor} under a new name.
- *
- * @param files The new active editor {@link File} reference, must contain
- * at least one element.
- */
- public void saveAs( final List<File> files ) {
- assert files != null;
- assert !files.isEmpty();
- final var editor = getTextEditor();
- final var tab = getTab( editor );
- final var file = files.get( 0 );
-
- editor.rename( file );
- tab.ifPresent( t -> {
- t.setText( editor.getFilename() );
- t.setTooltip( createTooltip( file ) );
- } );
-
- save();
- }
-
- /**
- * Saves the given {@link TextResource} to a file. This is typically used
- * to save either an instance of {@link TextEditor} or {@link TextDefinition}.
- *
- * @param resource The resource to export.
- */
- private void save( final TextResource resource ) {
- try {
- resource.save();
- } catch( final Exception ex ) {
- clue( ex );
- sNotifier.alert(
- getWindow(), resource.getPath(), "TextResource.saveFailed", ex
- );
- }
- }
-
- /**
- * Closes all open {@link TextEditor}s; all {@link TextDefinition}s stay open.
- *
- * @return {@code true} when all editors, modified or otherwise, were
- * permitted to close; {@code false} when one or more editors were modified
- * and the user requested no closing.
- */
- public boolean closeAll() {
- var closable = true;
-
- for( final var tabPane : mTabPanes ) {
- final var tabIterator = tabPane.getTabs().iterator();
-
- while( tabIterator.hasNext() ) {
- final var tab = tabIterator.next();
- final var resource = tab.getContent();
-
- // The definition panes auto-save, so being specific here prevents
- // closing the definitions in the situation where the user wants to
- // continue editing (i.e., possibly save unsaved work).
- if( !(resource instanceof TextEditor) ) {
- continue;
- }
-
- if( canClose( (TextEditor) resource ) ) {
- tabIterator.remove();
- close( tab );
- }
- else {
- closable = false;
- }
- }
- }
-
- return closable;
- }
-
- /**
- * Calls the tab's {@link Tab#getOnClosed()} handler to carry out a close
- * event.
- *
- * @param tab The {@link Tab} that was closed.
- */
- private void close( final Tab tab ) {
- assert tab != null;
-
- final var handler = tab.getOnClosed();
-
- if( handler != null ) {
- handler.handle( new ActionEvent() );
- }
- }
-
- /**
- * Closes the active tab; delegates to {@link #canClose(TextResource)}.
- */
- public void close() {
- final var editor = getTextEditor();
-
- if( canClose( editor ) ) {
- close( editor );
- }
- }
-
- /**
- * Closes the given {@link TextResource}. This must not be called from within
- * a loop that iterates over the tab panes using {@code forEach}, lest a
- * concurrent modification exception be thrown.
- *
- * @param resource The {@link TextResource} to close, without confirming with
- * the user.
- */
- private void close( final TextResource resource ) {
- getTab( resource ).ifPresent(
- tab -> {
- close( tab );
- tab.getTabPane().getTabs().remove( tab );
- }
- );
- }
-
- /**
- * Answers whether the given {@link TextResource} may be closed.
- *
- * @param editor The {@link TextResource} to try closing.
- * @return {@code true} when the editor may be closed; {@code false} when
- * the user has requested to keep the editor open.
- */
- private boolean canClose( final TextResource editor ) {
- final var editorTab = getTab( editor );
- final var canClose = new AtomicBoolean( true );
-
- if( editor.isModified() ) {
- final var filename = new StringBuilder();
- editorTab.ifPresent( tab -> filename.append( tab.getText() ) );
-
- final var message = sNotifier.createNotification(
- Messages.get( "Alert.file.close.title" ),
- Messages.get( "Alert.file.close.text" ),
- filename.toString()
- );
-
- final var dialog = sNotifier.createConfirmation( getWindow(), message );
-
- dialog.showAndWait().ifPresent(
- save -> canClose.set( save == YES ? editor.save() : save == NO )
- );
- }
-
- return canClose.get();
- }
-
- private ObjectProperty<TextEditor> createActiveTextEditor() {
- final var editor = new SimpleObjectProperty<TextEditor>();
-
- editor.addListener( ( c, o, n ) -> {
- if( n != null ) {
- mPreview.setBaseUri( n.getPath() );
- process( n );
- }
- } );
-
- return editor;
- }
-
- /**
- * Adds the HTML preview tab to its own, singular tab pane.
- */
- public void viewPreview() {
- viewTab( mPreview, TEXT_HTML, "Pane.preview.title" );
- }
-
- /**
- * Adds the document outline tab to its own, singular tab pane.
- */
- public void viewOutline() {
- viewTab( mOutline, APP_DOCUMENT_OUTLINE, "Pane.outline.title" );
- }
-
- public void viewStatistics() {
- viewTab( mStatistics, APP_DOCUMENT_STATISTICS, "Pane.statistics.title" );
- }
-
- public void viewFiles() {
- try {
- final var factory = new FilePickerFactory( getWorkspace() );
- final var fileManager = factory.createModeless();
- viewTab( fileManager, APP_FILE_MANAGER, "Pane.files.title" );
- } catch( final Exception ex ) {
- clue( ex );
- }
- }
-
- private void viewTab(
- final Node node, final MediaType mediaType, final String key ) {
- final var tabPane = obtainTabPane( mediaType );
-
- for( final var tab : tabPane.getTabs() ) {
- if( tab.getContent() == node ) {
- return;
- }
- }
-
- tabPane.getTabs().add( createTab( get( key ), node ) );
- addTabPane( tabPane );
- }
-
- public void viewRefresh() {
- mPreview.refresh();
- Engine.clear();
- mRBootstrapController.update();
- }
-
- /**
- * Returns the tab that contains the given {@link TextEditor}.
- *
- * @param editor The {@link TextEditor} instance to find amongst the tabs.
- * @return The first tab having content that matches the given tab.
- */
- private Optional<Tab> getTab( final TextResource editor ) {
- return mTabPanes.stream()
- .flatMap( pane -> pane.getTabs().stream() )
- .filter( tab -> editor.equals( tab.getContent() ) )
- .findFirst();
- }
-
- /**
- * Creates a new {@link DefinitionEditor} wrapped in a listener that
- * is used to detect when the active {@link DefinitionEditor} has changed.
- * Upon changing, the variables are interpolated and the active text editor
- * is refreshed.
- *
- * @param textEditor Text editor to update with the revised resolved map.
- * @return A newly configured property that represents the active
- * {@link DefinitionEditor}, never null.
- */
- private ObjectProperty<TextDefinition> createActiveDefinitionEditor(
- final ObjectProperty<TextEditor> textEditor ) {
- final var defEditor = new SimpleObjectProperty<>(
- createDefinitionEditor()
- );
-
- defEditor.addListener( ( c, o, n ) -> {
- final var editor = textEditor.get();
-
- if( editor.isMediaType( TEXT_R_MARKDOWN ) ) {
- // Initialize R before the editor is added.
- mRBootstrapController.update();
- }
-
- process( editor );
- } );
-
- return defEditor;
- }
-
- private Tab createTab( final String filename, final Node node ) {
- return new DetachableTab( filename, node );
- }
-
- private Tab createTab( final File file ) {
- final var r = createTextResource( file );
- final var tab = createTab( r.getFilename(), r.getNode() );
-
- r.modifiedProperty().addListener(
- ( c, o, n ) -> tab.setText( r.getFilename() + (n ? "*" : "") )
- );
-
- // This is called when either the tab is closed by the user clicking on
- // the tab's close icon or when closing (all) from the file menu.
- tab.setOnClosed(
- __ -> getRecentFiles().remove( file.getAbsolutePath() )
- );
-
- // When closing a tab, give focus to the newly revealed tab.
- tab.selectedProperty().addListener( ( c, o, n ) -> {
- if( n != null && n ) {
- final var pane = tab.getTabPane();
-
- if( pane != null ) {
- pane.requestFocus();
- }
- }
- } );
-
- tab.tabPaneProperty().addListener( ( cPane, oPane, nPane ) -> {
- if( nPane != null ) {
- nPane.focusedProperty().addListener( ( c, o, n ) -> {
- if( n != null && n ) {
- final var selected = nPane.getSelectionModel().getSelectedItem();
- final var node = selected.getContent();
- node.requestFocus();
- }
- } );
- }
- } );
-
- return tab;
- }
-
- /**
- * Creates bins for the different {@link MediaType}s, which eventually are
- * added to the UI as separate tab panes. If ever a general-purpose scene
- * exporter is developed to serialize a scene to an FXML file, this could
- * be replaced by such a class.
- * <p>
- * When binning the files, this makes sure that at least one file exists
- * for every type. If the user has opted to close a particular type (such
- * as the definition pane), the view will suppressed elsewhere.
- * </p>
- * <p>
- * The order that the binned files are returned will be reflected in the
- * order that the corresponding panes are rendered in the UI.
- * </p>
- *
- * @param paths The file paths to bin according to their type.
- * @return An in-order list of files, first by structured definition files,
- * then by plain text documents.
- */
- private List<File> collect( final SetProperty<String> paths ) {
- // Treat all files destined for the text editor as plain text documents
- // so that they are added to the same pane. Grouping by TEXT_PLAIN is a
- // bit arbitrary, but means explicitly capturing TEXT_PLAIN isn't needed.
- final Function<MediaType, MediaType> bin =
- m -> PLAIN_TEXT_FORMAT.contains( m ) ? TEXT_PLAIN : m;
-
- // Create two groups: YAML files and plain text files. The order that
- // the elements are listed in the enumeration for media types determines
- // what files are loaded first. Variable definitions come before all other
- // plain text documents.
- final var bins = paths
- .stream()
- .collect(
- groupingBy(
- path -> bin.apply( MediaType.fromFilename( path ) ),
- () -> new TreeMap<>( Enum::compareTo ),
- Collectors.toList()
- )
- );
-
- bins.putIfAbsent( TEXT_YAML, List.of( DEFINITION_DEFAULT.toString() ) );
- bins.putIfAbsent( TEXT_PLAIN, List.of( DOCUMENT_DEFAULT.toString() ) );
-
- final var result = new LinkedList<File>();
-
- // Ensure that the same types are listed together (keep insertion order).
- bins.forEach( ( mediaType, files ) -> result.addAll(
- files.stream().map( File::new ).toList() )
- );
-
- return result;
- }
-
- /**
- * Force the active editor to update, which will cause the processor
- * to re-evaluate the interpolated definition map thereby updating the
- * preview pane.
- *
- * @param editor Contains the source document to update in the preview pane.
- */
- private void process( final TextEditor editor ) {
- // Ensure processing does not run on the JavaFX thread, which frees the
- // text editor immediately for caret movement. The preview will have a
- // slight delay when catching up to the caret position.
- final var task = new Task<Void>() {
- @Override
- public Void call() {
- try {
- final var p = mProcessors.getOrDefault( editor, IDENTITY );
- p.apply( editor == null ? "" : editor.getText() );
- } catch( final Exception ex ) {
- clue( ex );
- }
-
- return null;
- }
- };
-
- // TODO: Each time the editor successfully runs the processor the task is
- // considered successful. Due to the rapid-fire nature of processing
- // (e.g., keyboard navigation, fast typing), it isn't necessary to
- // scroll each time.
- // The algorithm:
- // 1. Peek at the oldest time.
- // 2. If the difference between the oldest time and current time exceeds
- // 250 milliseconds, then invoke the scrolling.
- // 3. Insert the current time into the circular queue.
- task.setOnSucceeded(
- e -> invokeLater( () -> mPreview.scrollTo( CARET_ID ) )
- );
-
- // Prevents multiple process requests from executing simultaneously (due
- // to having a restricted queue size).
- sExecutor.execute( task );
- }
-
- /**
- * Lazily creates a {@link TabPane} configured to listen for tab select
- * events. The tab pane is associated with a given media type so that
- * similar files can be grouped together.
- *
- * @param mediaType The media type to associate with the tab pane.
- * @return An instance of {@link TabPane} that will handle tab docking.
- */
- private TabPane obtainTabPane( final MediaType mediaType ) {
- for( final var pane : mTabPanes ) {
- for( final var tab : pane.getTabs() ) {
- final var node = tab.getContent();
-
- if( node instanceof TextResource r && r.supports( mediaType ) ) {
- return pane;
- }
- }
- }
-
- final var pane = createTabPane();
- mTabPanes.add( pane );
- return pane;
- }
-
- /**
- * Creates an initialized {@link TabPane} instance.
- *
- * @return A new {@link TabPane} with all listeners configured.
- */
- private TabPane createTabPane() {
- final var tabPane = new DetachableTabPane();
-
- initStageOwnerFactory( tabPane );
- initTabListener( tabPane );
-
- return tabPane;
- }
-
- /**
- * When any {@link DetachableTabPane} is detached from the main window,
- * the stage owner factory must be given its parent window, which will
- * own the child window. The parent window is the {@link MainPane}'s
- * {@link Scene}'s {@link Window} instance.
- *
- * <p>
- * This will derives the new title from the main window title, incrementing
- * the window count to help uniquely identify the child windows.
- * </p>
- *
- * @param tabPane A new {@link DetachableTabPane} to configure.
- */
- private void initStageOwnerFactory( final DetachableTabPane tabPane ) {
- tabPane.setStageOwnerFactory( stage -> {
- final var title = get(
- "Detach.tab.title",
- ((Stage) getWindow()).getTitle(), ++mWindowCount
- );
- stage.setTitle( title );
-
- return getScene().getWindow();
- } );
- }
-
- /**
- * Responsible for configuring the content of each {@link DetachableTab} when
- * it is added to the given {@link DetachableTabPane} instance.
- * <p>
- * For {@link TextEditor} contents, an instance of {@link ScrollEventHandler}
- * is initialized to perform synchronized scrolling between the editor and
- * its preview window. Additionally, the last tab in the tab pane's list of
- * tabs is given focus.
- * </p>
- * <p>
- * Note that multiple tabs can be added simultaneously.
- * </p>
- *
- * @param tabPane A new {@link TabPane} to configure.
- */
- private void initTabListener( final TabPane tabPane ) {
- tabPane.getTabs().addListener(
- ( final ListChangeListener.Change<? extends Tab> listener ) -> {
- while( listener.next() ) {
- if( listener.wasAdded() ) {
- final var tabs = listener.getAddedSubList();
-
- tabs.forEach( tab -> {
- final var node = tab.getContent();
-
- if( node instanceof TextEditor ) {
- initScrollEventListener( tab );
- }
- } );
-
- // Select and give focus to the last tab opened.
- final var index = tabs.size() - 1;
- if( index >= 0 ) {
- final var tab = tabs.get( index );
- tabPane.getSelectionModel().select( tab );
- tab.getContent().requestFocus();
- }
- }
- }
- }
- );
- }
-
- /**
- * Synchronizes scrollbar positions between the given {@link Tab} that
- * contains an instance of {@link TextEditor} and {@link HtmlPreview} pane.
- *
- * @param tab The container for an instance of {@link TextEditor}.
- */
- private void initScrollEventListener( final Tab tab ) {
- final var editor = (TextEditor) tab.getContent();
- final var scrollPane = editor.getScrollPane();
- final var scrollBar = mPreview.getVerticalScrollBar();
- final var handler = new ScrollEventHandler( scrollPane, scrollBar );
-
- handler.enabledProperty().bind( tab.selectedProperty() );
- }
-
- private void addTabPane( final int index, final TabPane tabPane ) {
- final var items = getItems();
-
- if( !items.contains( tabPane ) ) {
- items.add( index, tabPane );
- }
- }
-
- private void addTabPane( final TabPane tabPane ) {
- addTabPane( getItems().size(), tabPane );
- }
-
- private GenericBuilder<Mutator, ProcessorContext> processorContextBuilder() {
- final var w = getWorkspace();
-
- return builder()
- .with( Mutator::setDefinitions, this::getDefinitions )
- .with( Mutator::setLocale, w::getLocale )
- .with( Mutator::setMetadata, w::getMetadata )
- .with( Mutator::setThemePath, w::getThemePath )
- .with( Mutator::setCaret,
- () -> getTextEditor().getCaret() )
- .with( Mutator::setImageDir,
- () -> w.getFile( KEY_IMAGES_DIR ) )
- .with( Mutator::setImageOrder,
- () -> w.getString( KEY_IMAGES_ORDER ) )
- .with( Mutator::setImageServer,
- () -> w.getString( KEY_IMAGES_SERVER ) )
- .with( Mutator::setSigilBegan,
- () -> w.getString( KEY_DEF_DELIM_BEGAN ) )
- .with( Mutator::setSigilEnded,
- () -> w.getString( KEY_DEF_DELIM_ENDED ) )
- .with( Mutator::setRScript,
- () -> w.getString( KEY_R_SCRIPT ) )
- .with( Mutator::setRWorkingDir,
- () -> w.getFile( KEY_R_DIR ).toPath() )
- .with( Mutator::setCurlQuotes,
- () -> w.getBoolean( KEY_TYPESET_TYPOGRAPHY_QUOTES ) )
- .with( Mutator::setAutoClean,
- () -> w.getBoolean( KEY_TYPESET_CONTEXT_CLEAN ) );
- }
-
- public ProcessorContext createProcessorContext() {
- return createProcessorContext( null, NONE );
- }
-
- /**
- * @param outputPath Used when exporting to a PDF file (binary).
- * @param format Used when processors export to a new text format.
- * @return A new {@link ProcessorContext} to use when creating an instance of
- * {@link Processor}.
- */
- public ProcessorContext createProcessorContext(
- final Path outputPath, final ExportFormat format ) {
- final var textEditor = getTextEditor();
- final var inputPath = textEditor.getPath();
-
- return processorContextBuilder()
- .with( Mutator::setInputPath, inputPath )
- .with( Mutator::setOutputPath, outputPath )
- .with( Mutator::setExportFormat, format )
- .build();
- }
-
- /**
- * @param inputPath Used by {@link ProcessorFactory} to determine
- * {@link Processor} type to create based on file type.
- * @return A new {@link ProcessorContext} to use when creating an instance of
- * {@link Processor}.
- */
- private ProcessorContext createProcessorContext( final Path inputPath ) {
- return processorContextBuilder()
- .with( Mutator::setInputPath, inputPath )
- .with( Mutator::setExportFormat, NONE )
- .build();
- }
-
- private TextResource createTextResource( final File file ) {
- // TODO: Create PlainTextEditor that's returned by default.
- return MediaType.valueFrom( file ) == TEXT_YAML
- ? createDefinitionEditor( file )
- : createMarkdownEditor( file );
- }
-
- /**
- * Creates an instance of {@link MarkdownEditor} that listens for both
- * caret change events and text change events. Text change events must
- * take priority over caret change events because it's possible to change
- * the text without moving the caret (e.g., delete selected text).
- *
- * @param inputFile The file containing contents for the text editor.
- * @return A non-null text editor.
- */
- private TextResource createMarkdownEditor( final File inputFile ) {
- final var editor = new MarkdownEditor( inputFile, getWorkspace() );
-
- mProcessors.computeIfAbsent(
- editor, p -> createProcessors(
- createProcessorContext( inputFile.toPath() ),
- createHtmlPreviewProcessor()
- )
- );
-
- // Listener for editor modifications or caret position changes.
- editor.addDirtyListener( ( c, o, n ) -> {
- if( n ) {
- // Reset the status bar after changing the text.
- clue();
-
- // Processing the text may update the status bar.
- process( getTextEditor() );
-
- // Update the caret position in the status bar.
- CaretMovedEvent.fire( editor.getCaret() );
- }
- } );
-
- editor.addEventListener(
- keyPressed( SPACE, CONTROL_DOWN ), this::autoinsert
- );
-
- // Track the caret to restore its position later.
- editor.getTextArea().caretPositionProperty().addListener( ( c, o, n ) -> {
- getWorkspace().integerProperty( KEY_UI_RECENT_OFFSET ).setValue( n );
- } );
-
- // Set the active editor, which refreshes the preview panel.
- mTextEditor.set( editor );
-
- return editor;
- }
-
- /**
- * Creates a {@link Processor} capable of rendering an HTML document onto
- * a GUI widget.
- *
- * @return The {@link Processor} for rendering an HTML document.
- */
- private Processor<String> createHtmlPreviewProcessor() {
- return new HtmlPreviewProcessor( getPreview() );
+import com.keenwrite.events.spelling.LexiconLoadedEvent;
+import com.keenwrite.io.MediaType;
+import com.keenwrite.preferences.Workspace;
+import com.keenwrite.preview.HtmlPreview;
+import com.keenwrite.processors.HtmlPreviewProcessor;
+import com.keenwrite.processors.Processor;
+import com.keenwrite.processors.ProcessorContext;
+import com.keenwrite.processors.ProcessorFactory;
+import com.keenwrite.processors.r.Engine;
+import com.keenwrite.processors.r.RBootstrapController;
+import com.keenwrite.service.events.Notifier;
+import com.keenwrite.spelling.api.SpellChecker;
+import com.keenwrite.spelling.impl.PermissiveSpeller;
+import com.keenwrite.spelling.impl.SymSpellSpeller;
+import com.keenwrite.ui.explorer.FilePickerFactory;
+import com.keenwrite.ui.heuristics.DocumentStatistics;
+import com.keenwrite.ui.outline.DocumentOutline;
+import com.keenwrite.ui.spelling.TextEditorSpellChecker;
+import com.keenwrite.util.GenericBuilder;
+import com.panemu.tiwulfx.control.dock.DetachableTab;
+import com.panemu.tiwulfx.control.dock.DetachableTabPane;
+import javafx.application.Platform;
+import javafx.beans.property.*;
+import javafx.collections.ListChangeListener;
+import javafx.concurrent.Task;
+import javafx.event.ActionEvent;
+import javafx.event.Event;
+import javafx.event.EventHandler;
+import javafx.scene.Node;
+import javafx.scene.Scene;
+import javafx.scene.control.*;
+import javafx.scene.control.TreeItem.TreeModificationEvent;
+import javafx.scene.input.KeyEvent;
+import javafx.scene.layout.FlowPane;
+import javafx.stage.Stage;
+import javafx.stage.Window;
+import org.greenrobot.eventbus.Subscribe;
+
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.nio.file.Path;
+import java.util.*;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+import static com.keenwrite.ExportFormat.NONE;
+import static com.keenwrite.Launcher.terminate;
+import static com.keenwrite.Messages.get;
+import static com.keenwrite.constants.Constants.*;
+import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG_NODE;
+import static com.keenwrite.events.Bus.register;
+import static com.keenwrite.events.StatusEvent.clue;
+import static com.keenwrite.io.MediaType.*;
+import static com.keenwrite.preferences.AppKeys.*;
+import static com.keenwrite.processors.IdentityProcessor.IDENTITY;
+import static com.keenwrite.processors.ProcessorContext.Mutator;
+import static com.keenwrite.processors.ProcessorContext.builder;
+import static com.keenwrite.processors.ProcessorFactory.createProcessors;
+import static java.lang.String.format;
+import static java.lang.System.getProperty;
+import static java.util.concurrent.Executors.newFixedThreadPool;
+import static java.util.concurrent.Executors.newScheduledThreadPool;
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static java.util.stream.Collectors.groupingBy;
+import static javafx.application.Platform.runLater;
+import static javafx.scene.control.Alert.AlertType.ERROR;
+import static javafx.scene.control.ButtonType.*;
+import static javafx.scene.control.TabPane.TabClosingPolicy.ALL_TABS;
+import static javafx.scene.input.KeyCode.ENTER;
+import static javafx.scene.input.KeyCode.SPACE;
+import static javafx.scene.input.KeyCombination.ALT_DOWN;
+import static javafx.scene.input.KeyCombination.CONTROL_DOWN;
+import static javafx.util.Duration.millis;
+import static javax.swing.SwingUtilities.invokeLater;
+import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
+
+/**
+ * Responsible for wiring together the main application components for a
+ * particular {@link Workspace} (project). These include the definition views,
+ * text editors, and preview pane along with any corresponding controllers.
+ */
+public final class MainPane extends SplitPane {
+
+ private static final ExecutorService sExecutor = newFixedThreadPool( 1 );
+ private static final Notifier sNotifier = Services.load( Notifier.class );
+
+ /**
+ * Used when opening files to determine how each file should be binned and
+ * therefore what tab pane to be opened within.
+ */
+ private static final Set<MediaType> PLAIN_TEXT_FORMAT = Set.of(
+ TEXT_MARKDOWN, TEXT_R_MARKDOWN, UNDEFINED
+ );
+
+ private final ScheduledExecutorService mSaver = newScheduledThreadPool( 1 );
+ private final AtomicReference<ScheduledFuture<?>> mSaveTask =
+ new AtomicReference<>();
+
+ /**
+ * Prevents re-instantiation of processing classes.
+ */
+ private final Map<TextResource, Processor<String>> mProcessors =
+ new HashMap<>();
+
+ private final Workspace mWorkspace;
+
+ /**
+ * Groups similar file type tabs together.
+ */
+ private final List<TabPane> mTabPanes = new ArrayList<>();
+
+ /**
+ * Renders the actively selected plain text editor tab.
+ */
+ private final HtmlPreview mPreview;
+
+ /**
+ * Provides an interactive document outline.
+ */
+ private final DocumentOutline mOutline = new DocumentOutline();
+
+ /**
+ * Changing the active editor fires the value changed event. This allows
+ * refreshes to happen when external definitions are modified and need to
+ * trigger the processing chain.
+ */
+ private final ObjectProperty<TextEditor> mTextEditor =
+ createActiveTextEditor();
+
+ /**
+ * Changing the active definition editor fires the value changed event. This
+ * allows refreshes to happen when external definitions are modified and need
+ * to trigger the processing chain.
+ */
+ private final ObjectProperty<TextDefinition> mDefinitionEditor;
+
+ private final ObjectProperty<SpellChecker> mSpellChecker;
+
+ private final TextEditorSpellChecker mEditorSpeller;
+
+ /**
+ * Called when the definition data is changed.
+ */
+ private final EventHandler<TreeModificationEvent<Event>> mTreeHandler =
+ event -> {
+ process( getTextEditor() );
+ save( getTextDefinition() );
+ };
+
+ /**
+ * Tracks the number of detached tab panels opened into their own windows,
+ * which allows unique identification of subordinate windows by their title.
+ * It is doubtful more than 128 windows, much less 256, will be created.
+ */
+ private byte mWindowCount;
+
+ private final VariableNameInjector mVariableNameInjector;
+
+ private final RBootstrapController mRBootstrapController;
+
+ private final DocumentStatistics mStatistics;
+
+ /**
+ * Adds all content panels to the main user interface. This will load the
+ * configuration settings from the workspace to reproduce the settings from
+ * a previous session.
+ */
+ public MainPane( final Workspace workspace ) {
+ mWorkspace = workspace;
+ mSpellChecker = createSpellChecker();
+ mEditorSpeller = createTextEditorSpellChecker( mSpellChecker );
+ mPreview = new HtmlPreview( workspace );
+ mStatistics = new DocumentStatistics( workspace );
+ mTextEditor.set( createMarkdownEditor( DOCUMENT_DEFAULT ) );
+ mDefinitionEditor = createActiveDefinitionEditor( mTextEditor );
+ mVariableNameInjector = new VariableNameInjector( mWorkspace );
+ mRBootstrapController = new RBootstrapController(
+ mWorkspace, this::getDefinitions );
+
+ open( collect( getRecentFiles() ) );
+ viewPreview();
+ setDividerPositions( calculateDividerPositions() );
+
+ // Once the main scene's window regains focus, update the active definition
+ // editor to the currently selected tab.
+ runLater( () -> getWindow().setOnCloseRequest( event -> {
+ // Order matters: Open file names must be persisted before closing all.
+ mWorkspace.save();
+
+ if( closeAll() ) {
+ Platform.exit();
+ terminate( 0 );
+ }
+
+ event.consume();
+ } ) );
+
+ register( this );
+ initAutosave( workspace );
+
+ restoreSession();
+ runLater( this::restoreFocus );
+ }
+
+ /**
+ * Called when spellchecking can be run. This will reload the dictionary
+ * into memory once, and then re-use it for all the existing text editors.
+ *
+ * @param event The event to process, having a populated word-frequency map.
+ */
+ @Subscribe
+ public void handle( final LexiconLoadedEvent event ) {
+ final var lexicon = event.getLexicon();
+ final var checker = SymSpellSpeller.forLexicon( lexicon );
+
+ mSpellChecker.set( checker );
+ }
+
+ @Subscribe
+ public void handle( final TextEditorFocusEvent event ) {
+ mTextEditor.set( event.get() );
+ }
+
+ @Subscribe
+ public void handle( final TextDefinitionFocusEvent event ) {
+ mDefinitionEditor.set( event.get() );
+ }
+
+ /**
+ * Typically called when a file name is clicked in the preview panel.
+ *
+ * @param event The event to process, must contain a valid file reference.
+ */
+ @Subscribe
+ public void handle( final FileOpenEvent event ) {
+ final File eventFile;
+ final var eventUri = event.getUri();
+
+ if( eventUri.isAbsolute() ) {
+ eventFile = new File( eventUri.getPath() );
+ }
+ else {
+ final var activeFile = getTextEditor().getFile();
+ final var parent = activeFile.getParentFile();
+
+ if( parent == null ) {
+ clue( new FileNotFoundException( eventUri.getPath() ) );
+ return;
+ }
+ else {
+ final var parentPath = parent.getAbsolutePath();
+ eventFile = Path.of( parentPath, eventUri.getPath() ).toFile();
+ }
+ }
+
+ runLater( () -> open( eventFile ) );
+ }
+
+ @Subscribe
+ public void handle( final CaretNavigationEvent event ) {
+ runLater( () -> {
+ final var textArea = getTextEditor();
+ textArea.moveTo( event.getOffset() );
+ textArea.requestFocus();
+ } );
+ }
+
+ @Subscribe
+ @SuppressWarnings( "unused" )
+ public void handle( final ExportFailedEvent event ) {
+ final var os = getProperty( "os.name" );
+ final var arch = getProperty( "os.arch" ).toLowerCase();
+ final var bits = getProperty( "sun.arch.data.model" );
+
+ final var title = Messages.get( "Alert.typesetter.missing.title" );
+ final var header = Messages.get( "Alert.typesetter.missing.header" );
+ final var version = Messages.get(
+ "Alert.typesetter.missing.version",
+ os,
+ arch
+ .replaceAll( "amd.*|i.*|x86.*", "X86" )
+ .replaceAll( "mips.*", "MIPS" )
+ .replaceAll( "armv.*", "ARM" ),
+ bits );
+ final var text = Messages.get( "Alert.typesetter.missing.installer.text" );
+
+ // Download and install ConTeXt for {0} {1} {2}-bit
+ final var content = format( "%s %s", text, version );
+ final var flowPane = new FlowPane();
+ final var link = new Hyperlink( text );
+ final var label = new Label( version );
+ flowPane.getChildren().addAll( link, label );
+
+ final var alert = new Alert( ERROR, content, OK );
+ alert.setTitle( title );
+ alert.setHeaderText( header );
+ alert.getDialogPane().contentProperty().set( flowPane );
+ alert.setGraphic( ICON_DIALOG_NODE );
+
+ link.setOnAction( e -> {
+ alert.close();
+ final var url = Messages.get( "Alert.typesetter.missing.installer.url" );
+ runLater( () -> HyperlinkOpenEvent.fire( url ) );
+ } );
+
+ alert.showAndWait();
+ }
+
+ @Subscribe
+ public void handle( final InsertDefinitionEvent<String> event ) {
+ final var leaf = event.getLeaf();
+ final var editor = mTextEditor.get();
+
+ mVariableNameInjector.insert( editor, leaf );
+ }
+
+ private void initAutosave( final Workspace workspace ) {
+ final var rate = workspace.integerProperty( KEY_EDITOR_AUTOSAVE );
+
+ rate.addListener(
+ ( c, o, n ) -> {
+ final var taskRef = mSaveTask.get();
+
+ // Prevent multiple autosaves from running.
+ if( taskRef != null ) {
+ taskRef.cancel( false );
+ }
+
+ initAutosave( rate );
+ }
+ );
+
+ // Start the save listener (avoids duplicating some code).
+ initAutosave( rate );
+ }
+
+ private void initAutosave( final IntegerProperty rate ) {
+ mSaveTask.set(
+ mSaver.scheduleAtFixedRate(
+ () -> {
+ if( getTextEditor().isModified() ) {
+ // Ensure the modified indicator is cleared by running on EDT.
+ runLater( this::save );
+ }
+ }, 0, rate.intValue(), SECONDS
+ )
+ );
+ }
+
+ /**
+ * TODO: Load divider positions from exported settings, see
+ * {@link #collect(SetProperty)} comment.
+ */
+ private double[] calculateDividerPositions() {
+ final var ratio = 100f / getItems().size() / 100;
+ final var positions = getDividerPositions();
+
+ for( int i = 0; i < positions.length; i++ ) {
+ positions[ i ] = ratio * i;
+ }
+
+ return positions;
+ }
+
+ /**
+ * Opens all the files into the application, provided the paths are unique.
+ * This may only be called for any type of files that a user can edit
+ * (i.e., update and persist), such as definitions and text files.
+ *
+ * @param files The list of files to open.
+ */
+ public void open( final List<File> files ) {
+ files.forEach( this::open );
+ }
+
+ /**
+ * This opens the given file. Since the preview pane is not a file that
+ * can be opened, it is safe to add a listener to the detachable pane.
+ * This will exit early if the given file is not a regular file (i.e., a
+ * directory).
+ *
+ * @param inputFile The file to open.
+ */
+ private void open( final File inputFile ) {
+ // Prevent opening directories (a non-existent "untitled.md" is fine).
+ if( !inputFile.isFile() && inputFile.exists() ) {
+ return;
+ }
+
+ final var tab = createTab( inputFile );
+ final var node = tab.getContent();
+ final var mediaType = MediaType.valueFrom( inputFile );
+ final var tabPane = obtainTabPane( mediaType );
+
+ tab.setTooltip( createTooltip( inputFile ) );
+ tabPane.setFocusTraversable( false );
+ tabPane.setTabClosingPolicy( ALL_TABS );
+ tabPane.getTabs().add( tab );
+
+ // Attach the tab scene factory for new tab panes.
+ if( !getItems().contains( tabPane ) ) {
+ addTabPane(
+ node instanceof TextDefinition ? 0 : getItems().size(), tabPane
+ );
+ }
+
+ if( inputFile.isFile() ) {
+ getRecentFiles().add( inputFile.getAbsolutePath() );
+ }
+ }
+
+ /**
+ * Gives focus to the most recently edited document and attempts to move
+ * the caret to the most recently known offset into said document.
+ */
+ private void restoreSession() {
+ final var workspace = getWorkspace();
+ final var file = workspace.fileProperty( KEY_UI_RECENT_DOCUMENT );
+ final var offset = workspace.integerProperty( KEY_UI_RECENT_OFFSET );
+
+ for( final var pane : mTabPanes ) {
+ for( final var tab : pane.getTabs() ) {
+ final var tooltip = tab.getTooltip();
+
+ if( tooltip != null ) {
+ final var tabName = tooltip.getText();
+ final var fileName = file.getValue().toString();
+
+ if( tabName.equalsIgnoreCase( fileName ) ) {
+ final var node = tab.getContent();
+
+ pane.getSelectionModel().select( tab );
+ node.requestFocus();
+
+ if( node instanceof TextEditor editor ) {
+ editor.moveTo( offset.getValue() );
+ }
+
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Sets the focus to the middle pane, which contains the text editor tabs.
+ */
+ private void restoreFocus() {
+ // Work around a bug where focusing directly on the middle pane results
+ // in the R engine not loading variables properly.
+ mTabPanes.get( 0 ).requestFocus();
+
+ // This is the only line that should be required.
+ mTabPanes.get( 1 ).requestFocus();
+ }
+
+ /**
+ * Opens a new text editor document using the default document file name.
+ */
+ public void newTextEditor() {
+ open( DOCUMENT_DEFAULT );
+ }
+
+ /**
+ * Opens a new definition editor document using the default definition
+ * file name.
+ */
+ public void newDefinitionEditor() {
+ open( DEFINITION_DEFAULT );
+ }
+
+ /**
+ * Iterates over all tab panes to find all {@link TextEditor}s and request
+ * that they save themselves.
+ */
+ public void saveAll() {
+ mTabPanes.forEach(
+ tp -> tp.getTabs().forEach( tab -> {
+ final var node = tab.getContent();
+
+ if( node instanceof final TextEditor editor ) {
+ save( editor );
+ }
+ } )
+ );
+ }
+
+ /**
+ * Requests that the active {@link TextEditor} saves itself. Don't bother
+ * checking if modified first because if the user swaps external media from
+ * an external source (e.g., USB thumb drive), save should not second-guess
+ * the user: save always re-saves. Also, it's less code.
+ */
+ public void save() {
+ save( getTextEditor() );
+ }
+
+ /**
+ * Saves the active {@link TextEditor} under a new name.
+ *
+ * @param files The new active editor {@link File} reference, must contain
+ * at least one element.
+ */
+ public void saveAs( final List<File> files ) {
+ assert files != null;
+ assert !files.isEmpty();
+ final var editor = getTextEditor();
+ final var tab = getTab( editor );
+ final var file = files.get( 0 );
+
+ editor.rename( file );
+ tab.ifPresent( t -> {
+ t.setText( editor.getFilename() );
+ t.setTooltip( createTooltip( file ) );
+ } );
+
+ save();
+ }
+
+ /**
+ * Saves the given {@link TextResource} to a file. This is typically used
+ * to save either an instance of {@link TextEditor} or {@link TextDefinition}.
+ *
+ * @param resource The resource to export.
+ */
+ private void save( final TextResource resource ) {
+ try {
+ resource.save();
+ } catch( final Exception ex ) {
+ clue( ex );
+ sNotifier.alert(
+ getWindow(), resource.getPath(), "TextResource.saveFailed", ex
+ );
+ }
+ }
+
+ /**
+ * Closes all open {@link TextEditor}s; all {@link TextDefinition}s stay open.
+ *
+ * @return {@code true} when all editors, modified or otherwise, were
+ * permitted to close; {@code false} when one or more editors were modified
+ * and the user requested no closing.
+ */
+ public boolean closeAll() {
+ var closable = true;
+
+ for( final var tabPane : mTabPanes ) {
+ final var tabIterator = tabPane.getTabs().iterator();
+
+ while( tabIterator.hasNext() ) {
+ final var tab = tabIterator.next();
+ final var resource = tab.getContent();
+
+ // The definition panes auto-save, so being specific here prevents
+ // closing the definitions in the situation where the user wants to
+ // continue editing (i.e., possibly save unsaved work).
+ if( !(resource instanceof TextEditor) ) {
+ continue;
+ }
+
+ if( canClose( (TextEditor) resource ) ) {
+ tabIterator.remove();
+ close( tab );
+ }
+ else {
+ closable = false;
+ }
+ }
+ }
+
+ return closable;
+ }
+
+ /**
+ * Calls the tab's {@link Tab#getOnClosed()} handler to carry out a close
+ * event.
+ *
+ * @param tab The {@link Tab} that was closed.
+ */
+ private void close( final Tab tab ) {
+ assert tab != null;
+
+ final var handler = tab.getOnClosed();
+
+ if( handler != null ) {
+ handler.handle( new ActionEvent() );
+ }
+ }
+
+ /**
+ * Closes the active tab; delegates to {@link #canClose(TextResource)}.
+ */
+ public void close() {
+ final var editor = getTextEditor();
+
+ if( canClose( editor ) ) {
+ close( editor );
+ }
+ }
+
+ /**
+ * Closes the given {@link TextResource}. This must not be called from within
+ * a loop that iterates over the tab panes using {@code forEach}, lest a
+ * concurrent modification exception be thrown.
+ *
+ * @param resource The {@link TextResource} to close, without confirming with
+ * the user.
+ */
+ private void close( final TextResource resource ) {
+ getTab( resource ).ifPresent(
+ tab -> {
+ close( tab );
+ tab.getTabPane().getTabs().remove( tab );
+ }
+ );
+ }
+
+ /**
+ * Answers whether the given {@link TextResource} may be closed.
+ *
+ * @param editor The {@link TextResource} to try closing.
+ * @return {@code true} when the editor may be closed; {@code false} when
+ * the user has requested to keep the editor open.
+ */
+ private boolean canClose( final TextResource editor ) {
+ final var editorTab = getTab( editor );
+ final var canClose = new AtomicBoolean( true );
+
+ if( editor.isModified() ) {
+ final var filename = new StringBuilder();
+ editorTab.ifPresent( tab -> filename.append( tab.getText() ) );
+
+ final var message = sNotifier.createNotification(
+ Messages.get( "Alert.file.close.title" ),
+ Messages.get( "Alert.file.close.text" ),
+ filename.toString()
+ );
+
+ final var dialog = sNotifier.createConfirmation( getWindow(), message );
+
+ dialog.showAndWait().ifPresent(
+ save -> canClose.set( save == YES ? editor.save() : save == NO )
+ );
+ }
+
+ return canClose.get();
+ }
+
+ private ObjectProperty<TextEditor> createActiveTextEditor() {
+ final var editor = new SimpleObjectProperty<TextEditor>();
+
+ editor.addListener( ( c, o, n ) -> {
+ if( n != null ) {
+ mPreview.setBaseUri( n.getPath() );
+ process( n );
+ }
+ } );
+
+ return editor;
+ }
+
+ /**
+ * Adds the HTML preview tab to its own, singular tab pane.
+ */
+ public void viewPreview() {
+ viewTab( mPreview, TEXT_HTML, "Pane.preview.title" );
+ }
+
+ /**
+ * Adds the document outline tab to its own, singular tab pane.
+ */
+ public void viewOutline() {
+ viewTab( mOutline, APP_DOCUMENT_OUTLINE, "Pane.outline.title" );
+ }
+
+ public void viewStatistics() {
+ viewTab( mStatistics, APP_DOCUMENT_STATISTICS, "Pane.statistics.title" );
+ }
+
+ public void viewFiles() {
+ try {
+ final var factory = new FilePickerFactory( getWorkspace() );
+ final var fileManager = factory.createModeless();
+ viewTab( fileManager, APP_FILE_MANAGER, "Pane.files.title" );
+ } catch( final Exception ex ) {
+ clue( ex );
+ }
+ }
+
+ private void viewTab(
+ final Node node, final MediaType mediaType, final String key ) {
+ final var tabPane = obtainTabPane( mediaType );
+
+ for( final var tab : tabPane.getTabs() ) {
+ if( tab.getContent() == node ) {
+ return;
+ }
+ }
+
+ tabPane.getTabs().add( createTab( get( key ), node ) );
+ addTabPane( tabPane );
+ }
+
+ public void viewRefresh() {
+ mPreview.refresh();
+ Engine.clear();
+ mRBootstrapController.update();
+ }
+
+ /**
+ * Returns the tab that contains the given {@link TextEditor}.
+ *
+ * @param editor The {@link TextEditor} instance to find amongst the tabs.
+ * @return The first tab having content that matches the given tab.
+ */
+ private Optional<Tab> getTab( final TextResource editor ) {
+ return mTabPanes.stream()
+ .flatMap( pane -> pane.getTabs().stream() )
+ .filter( tab -> editor.equals( tab.getContent() ) )
+ .findFirst();
+ }
+
+ /**
+ * Creates a new {@link DefinitionEditor} wrapped in a listener that
+ * is used to detect when the active {@link DefinitionEditor} has changed.
+ * Upon changing, the variables are interpolated and the active text editor
+ * is refreshed.
+ *
+ * @param textEditor Text editor to update with the revised resolved map.
+ * @return A newly configured property that represents the active
+ * {@link DefinitionEditor}, never null.
+ */
+ private ObjectProperty<TextDefinition> createActiveDefinitionEditor(
+ final ObjectProperty<TextEditor> textEditor ) {
+ final var defEditor = new SimpleObjectProperty<>(
+ createDefinitionEditor()
+ );
+
+ defEditor.addListener( ( c, o, n ) -> {
+ final var editor = textEditor.get();
+
+ if( editor.isMediaType( TEXT_R_MARKDOWN ) ) {
+ // Initialize R before the editor is added.
+ mRBootstrapController.update();
+ }
+
+ process( editor );
+ } );
+
+ return defEditor;
+ }
+
+ private Tab createTab( final String filename, final Node node ) {
+ return new DetachableTab( filename, node );
+ }
+
+ private Tab createTab( final File file ) {
+ final var r = createTextResource( file );
+ final var tab = createTab( r.getFilename(), r.getNode() );
+
+ r.modifiedProperty().addListener(
+ ( c, o, n ) -> tab.setText( r.getFilename() + (n ? "*" : "") )
+ );
+
+ // This is called when either the tab is closed by the user clicking on
+ // the tab's close icon or when closing (all) from the file menu.
+ tab.setOnClosed(
+ __ -> getRecentFiles().remove( file.getAbsolutePath() )
+ );
+
+ // When closing a tab, give focus to the newly revealed tab.
+ tab.selectedProperty().addListener( ( c, o, n ) -> {
+ if( n != null && n ) {
+ final var pane = tab.getTabPane();
+
+ if( pane != null ) {
+ pane.requestFocus();
+ }
+ }
+ } );
+
+ tab.tabPaneProperty().addListener( ( cPane, oPane, nPane ) -> {
+ if( nPane != null ) {
+ nPane.focusedProperty().addListener( ( c, o, n ) -> {
+ if( n != null && n ) {
+ final var selected = nPane.getSelectionModel().getSelectedItem();
+ final var node = selected.getContent();
+ node.requestFocus();
+ }
+ } );
+ }
+ } );
+
+ return tab;
+ }
+
+ /**
+ * Creates bins for the different {@link MediaType}s, which eventually are
+ * added to the UI as separate tab panes. If ever a general-purpose scene
+ * exporter is developed to serialize a scene to an FXML file, this could
+ * be replaced by such a class.
+ * <p>
+ * When binning the files, this makes sure that at least one file exists
+ * for every type. If the user has opted to close a particular type (such
+ * as the definition pane), the view will suppressed elsewhere.
+ * </p>
+ * <p>
+ * The order that the binned files are returned will be reflected in the
+ * order that the corresponding panes are rendered in the UI.
+ * </p>
+ *
+ * @param paths The file paths to bin according to their type.
+ * @return An in-order list of files, first by structured definition files,
+ * then by plain text documents.
+ */
+ private List<File> collect( final SetProperty<String> paths ) {
+ // Treat all files destined for the text editor as plain text documents
+ // so that they are added to the same pane. Grouping by TEXT_PLAIN is a
+ // bit arbitrary, but means explicitly capturing TEXT_PLAIN isn't needed.
+ final Function<MediaType, MediaType> bin =
+ m -> PLAIN_TEXT_FORMAT.contains( m ) ? TEXT_PLAIN : m;
+
+ // Create two groups: YAML files and plain text files. The order that
+ // the elements are listed in the enumeration for media types determines
+ // what files are loaded first. Variable definitions come before all other
+ // plain text documents.
+ final var bins = paths
+ .stream()
+ .collect(
+ groupingBy(
+ path -> bin.apply( MediaType.fromFilename( path ) ),
+ () -> new TreeMap<>( Enum::compareTo ),
+ Collectors.toList()
+ )
+ );
+
+ bins.putIfAbsent( TEXT_YAML, List.of( DEFINITION_DEFAULT.toString() ) );
+ bins.putIfAbsent( TEXT_PLAIN, List.of( DOCUMENT_DEFAULT.toString() ) );
+
+ final var result = new LinkedList<File>();
+
+ // Ensure that the same types are listed together (keep insertion order).
+ bins.forEach( ( mediaType, files ) -> result.addAll(
+ files.stream().map( File::new ).toList() )
+ );
+
+ return result;
+ }
+
+ /**
+ * Force the active editor to update, which will cause the processor
+ * to re-evaluate the interpolated definition map thereby updating the
+ * preview pane.
+ *
+ * @param editor Contains the source document to update in the preview pane.
+ */
+ private void process( final TextEditor editor ) {
+ // Ensure processing does not run on the JavaFX thread, which frees the
+ // text editor immediately for caret movement. The preview will have a
+ // slight delay when catching up to the caret position.
+ final var task = new Task<Void>() {
+ @Override
+ public Void call() {
+ try {
+ final var p = mProcessors.getOrDefault( editor, IDENTITY );
+ p.apply( editor == null ? "" : editor.getText() );
+ } catch( final Exception ex ) {
+ clue( ex );
+ }
+
+ return null;
+ }
+ };
+
+ // TODO: Each time the editor successfully runs the processor the task is
+ // considered successful. Due to the rapid-fire nature of processing
+ // (e.g., keyboard navigation, fast typing), it isn't necessary to
+ // scroll each time.
+ // The algorithm:
+ // 1. Peek at the oldest time.
+ // 2. If the difference between the oldest time and current time exceeds
+ // 250 milliseconds, then invoke the scrolling.
+ // 3. Insert the current time into the circular queue.
+ task.setOnSucceeded(
+ e -> invokeLater( () -> mPreview.scrollTo( CARET_ID ) )
+ );
+
+ // Prevents multiple process requests from executing simultaneously (due
+ // to having a restricted queue size).
+ sExecutor.execute( task );
+ }
+
+ /**
+ * Lazily creates a {@link TabPane} configured to listen for tab select
+ * events. The tab pane is associated with a given media type so that
+ * similar files can be grouped together.
+ *
+ * @param mediaType The media type to associate with the tab pane.
+ * @return An instance of {@link TabPane} that will handle tab docking.
+ */
+ private TabPane obtainTabPane( final MediaType mediaType ) {
+ for( final var pane : mTabPanes ) {
+ for( final var tab : pane.getTabs() ) {
+ final var node = tab.getContent();
+
+ if( node instanceof TextResource r && r.supports( mediaType ) ) {
+ return pane;
+ }
+ }
+ }
+
+ final var pane = createTabPane();
+ mTabPanes.add( pane );
+ return pane;
+ }
+
+ /**
+ * Creates an initialized {@link TabPane} instance.
+ *
+ * @return A new {@link TabPane} with all listeners configured.
+ */
+ private TabPane createTabPane() {
+ final var tabPane = new DetachableTabPane();
+
+ initStageOwnerFactory( tabPane );
+ initTabListener( tabPane );
+
+ return tabPane;
+ }
+
+ /**
+ * When any {@link DetachableTabPane} is detached from the main window,
+ * the stage owner factory must be given its parent window, which will
+ * own the child window. The parent window is the {@link MainPane}'s
+ * {@link Scene}'s {@link Window} instance.
+ *
+ * <p>
+ * This will derives the new title from the main window title, incrementing
+ * the window count to help uniquely identify the child windows.
+ * </p>
+ *
+ * @param tabPane A new {@link DetachableTabPane} to configure.
+ */
+ private void initStageOwnerFactory( final DetachableTabPane tabPane ) {
+ tabPane.setStageOwnerFactory( stage -> {
+ final var title = get(
+ "Detach.tab.title",
+ ((Stage) getWindow()).getTitle(), ++mWindowCount
+ );
+ stage.setTitle( title );
+
+ return getScene().getWindow();
+ } );
+ }
+
+ /**
+ * Responsible for configuring the content of each {@link DetachableTab} when
+ * it is added to the given {@link DetachableTabPane} instance.
+ * <p>
+ * For {@link TextEditor} contents, an instance of {@link ScrollEventHandler}
+ * is initialized to perform synchronized scrolling between the editor and
+ * its preview window. Additionally, the last tab in the tab pane's list of
+ * tabs is given focus.
+ * </p>
+ * <p>
+ * Note that multiple tabs can be added simultaneously.
+ * </p>
+ *
+ * @param tabPane A new {@link TabPane} to configure.
+ */
+ private void initTabListener( final TabPane tabPane ) {
+ tabPane.getTabs().addListener(
+ ( final ListChangeListener.Change<? extends Tab> listener ) -> {
+ while( listener.next() ) {
+ if( listener.wasAdded() ) {
+ final var tabs = listener.getAddedSubList();
+
+ tabs.forEach( tab -> {
+ final var node = tab.getContent();
+
+ if( node instanceof TextEditor ) {
+ initScrollEventListener( tab );
+ }
+ } );
+
+ // Select and give focus to the last tab opened.
+ final var index = tabs.size() - 1;
+ if( index >= 0 ) {
+ final var tab = tabs.get( index );
+ tabPane.getSelectionModel().select( tab );
+ tab.getContent().requestFocus();
+ }
+ }
+ }
+ }
+ );
+ }
+
+ /**
+ * Synchronizes scrollbar positions between the given {@link Tab} that
+ * contains an instance of {@link TextEditor} and {@link HtmlPreview} pane.
+ *
+ * @param tab The container for an instance of {@link TextEditor}.
+ */
+ private void initScrollEventListener( final Tab tab ) {
+ final var editor = (TextEditor) tab.getContent();
+ final var scrollPane = editor.getScrollPane();
+ final var scrollBar = mPreview.getVerticalScrollBar();
+ final var handler = new ScrollEventHandler( scrollPane, scrollBar );
+
+ handler.enabledProperty().bind( tab.selectedProperty() );
+ }
+
+ private void addTabPane( final int index, final TabPane tabPane ) {
+ final var items = getItems();
+
+ if( !items.contains( tabPane ) ) {
+ items.add( index, tabPane );
+ }
+ }
+
+ private void addTabPane( final TabPane tabPane ) {
+ addTabPane( getItems().size(), tabPane );
+ }
+
+ private GenericBuilder<Mutator, ProcessorContext> processorContextBuilder() {
+ final var w = getWorkspace();
+
+ return builder()
+ .with( Mutator::setDefinitions, this::getDefinitions )
+ .with( Mutator::setLocale, w::getLocale )
+ .with( Mutator::setMetadata, w::getMetadata )
+ .with( Mutator::setThemePath, w::getThemePath )
+ .with( Mutator::setCaret,
+ () -> getTextEditor().getCaret() )
+ .with( Mutator::setImageDir,
+ () -> w.getFile( KEY_IMAGES_DIR ) )
+ .with( Mutator::setImageOrder,
+ () -> w.getString( KEY_IMAGES_ORDER ) )
+ .with( Mutator::setImageServer,
+ () -> w.getString( KEY_IMAGES_SERVER ) )
+ .with( Mutator::setSigilBegan,
+ () -> w.getString( KEY_DEF_DELIM_BEGAN ) )
+ .with( Mutator::setSigilEnded,
+ () -> w.getString( KEY_DEF_DELIM_ENDED ) )
+ .with( Mutator::setRScript,
+ () -> w.getString( KEY_R_SCRIPT ) )
+ .with( Mutator::setRWorkingDir,
+ () -> w.getFile( KEY_R_DIR ).toPath() )
+ .with( Mutator::setCurlQuotes,
+ () -> w.getBoolean( KEY_TYPESET_TYPOGRAPHY_QUOTES ) )
+ .with( Mutator::setAutoClean,
+ () -> w.getBoolean( KEY_TYPESET_CONTEXT_CLEAN ) );
+ }
+
+ public ProcessorContext createProcessorContext() {
+ return createProcessorContext( null, NONE );
+ }
+
+ /**
+ * @param outputPath Used when exporting to a PDF file (binary).
+ * @param format Used when processors export to a new text format.
+ * @return A new {@link ProcessorContext} to use when creating an instance of
+ * {@link Processor}.
+ */
+ public ProcessorContext createProcessorContext(
+ final Path outputPath, final ExportFormat format ) {
+ final var textEditor = getTextEditor();
+ final var inputPath = textEditor.getPath();
+
+ return processorContextBuilder()
+ .with( Mutator::setInputPath, inputPath )
+ .with( Mutator::setOutputPath, outputPath )
+ .with( Mutator::setExportFormat, format )
+ .build();
+ }
+
+ /**
+ * @param inputPath Used by {@link ProcessorFactory} to determine
+ * {@link Processor} type to create based on file type.
+ * @return A new {@link ProcessorContext} to use when creating an instance of
+ * {@link Processor}.
+ */
+ private ProcessorContext createProcessorContext( final Path inputPath ) {
+ return processorContextBuilder()
+ .with( Mutator::setInputPath, inputPath )
+ .with( Mutator::setExportFormat, NONE )
+ .build();
+ }
+
+ private TextResource createTextResource( final File file ) {
+ // TODO: Create PlainTextEditor that's returned by default.
+ return MediaType.valueFrom( file ) == TEXT_YAML
+ ? createDefinitionEditor( file )
+ : createMarkdownEditor( file );
+ }
+
+ /**
+ * Creates an instance of {@link MarkdownEditor} that listens for both
+ * caret change events and text change events. Text change events must
+ * take priority over caret change events because it's possible to change
+ * the text without moving the caret (e.g., delete selected text).
+ *
+ * @param inputFile The file containing contents for the text editor.
+ * @return A non-null text editor.
+ */
+ private MarkdownEditor createMarkdownEditor( final File inputFile ) {
+ final var editor = new MarkdownEditor( inputFile, getWorkspace() );
+
+ mProcessors.computeIfAbsent(
+ editor, p -> createProcessors(
+ createProcessorContext( inputFile.toPath() ),
+ createHtmlPreviewProcessor()
+ )
+ );
+
+ // Listener for editor modifications or caret position changes.
+ editor.addDirtyListener( ( c, o, n ) -> {
+ if( n ) {
+ // Reset the status bar after changing the text.
+ clue();
+
+ // Processing the text may update the status bar.
+ process( getTextEditor() );
+
+ // Update the caret position in the status bar.
+ CaretMovedEvent.fire( editor.getCaret() );
+ }
+ } );
+
+ editor.addEventListener(
+ keyPressed( SPACE, CONTROL_DOWN ), this::autoinsert
+ );
+
+ editor.addEventListener(
+ keyPressed( ENTER, ALT_DOWN ), event -> mEditorSpeller.autofix( editor )
+ );
+
+ final var textArea = editor.getTextArea();
+
+ // Spell check when the paragraph changes.
+ textArea
+ .plainTextChanges()
+ .filter( p -> !p.isIdentity() )
+ .subscribe( change -> mEditorSpeller.checkParagraph( textArea, change ) );
+
+ // Store the caret position to restore it after restarting the application.
+ textArea.caretPositionProperty().addListener(
+ ( c, o, n ) ->
+ getWorkspace().integerProperty( KEY_UI_RECENT_OFFSET ).setValue( n )
+ );
+
+ // Set the active editor, which refreshes the preview panel.
+ mTextEditor.set( editor );
+
+ // Check the entire document after the spellchecker is initialized (with
+ // a valid lexicon) so that only the current paragraph need be scanned
+ // while editing. (Technically, only the most recently modified word must
+ // be scanned.)
+ mSpellChecker.addListener(
+ ( c, o, n ) -> runLater(
+ () -> mEditorSpeller.checkDocument( mTextEditor.get() )
+ )
+ );
+
+ return editor;
+ }
+
+ /**
+ * Creates a {@link Processor} capable of rendering an HTML document onto
+ * a GUI widget.
+ *
+ * @return The {@link Processor} for rendering an HTML document.
+ */
+ private Processor<String> createHtmlPreviewProcessor() {
+ return new HtmlPreviewProcessor( getPreview() );
+ }
+
+ /**
+ * Creates a spellchecker that accepts all words as correct. This allows
+ * the spellchecker property to be initialized to a known valid value.
+ *
+ * @return A {@link PermissiveSpeller}.
+ */
+ private ObjectProperty<SpellChecker> createSpellChecker() {
+ return new SimpleObjectProperty<>( new PermissiveSpeller() );
+ }
+
+ private TextEditorSpellChecker createTextEditorSpellChecker(
+ final ObjectProperty<SpellChecker> spellChecker ) {
+ return new TextEditorSpellChecker( spellChecker );
}
src/main/java/com/keenwrite/editors/markdown/MarkdownEditor.java
import com.keenwrite.preferences.Workspace;
import com.keenwrite.processors.markdown.extensions.CaretExtension;
-import com.keenwrite.spelling.impl.TextEditorSpeller;
-import javafx.beans.binding.Bindings;
-import javafx.beans.property.*;
-import javafx.beans.value.ChangeListener;
-import javafx.event.Event;
-import javafx.scene.Node;
-import javafx.scene.control.ContextMenu;
-import javafx.scene.control.IndexRange;
-import javafx.scene.control.MenuItem;
-import javafx.scene.input.KeyEvent;
-import javafx.scene.layout.BorderPane;
-import org.fxmisc.flowless.VirtualizedScrollPane;
-import org.fxmisc.richtext.StyleClassedTextArea;
-import org.fxmisc.richtext.model.StyleSpans;
-import org.fxmisc.undo.UndoManager;
-import org.fxmisc.wellbehaved.event.EventPattern;
-import org.fxmisc.wellbehaved.event.Nodes;
-
-import java.io.File;
-import java.nio.charset.Charset;
-import java.text.BreakIterator;
-import java.text.MessageFormat;
-import java.util.*;
-import java.util.function.Consumer;
-import java.util.function.Supplier;
-import java.util.regex.Pattern;
-
-import static com.keenwrite.MainApp.keyDown;
-import static com.keenwrite.constants.Constants.*;
-import static com.keenwrite.events.StatusEvent.clue;
-import static com.keenwrite.io.MediaType.TEXT_MARKDOWN;
-import static com.keenwrite.io.MediaType.TEXT_R_MARKDOWN;
-import static com.keenwrite.preferences.AppKeys.*;
-import static java.lang.Character.isWhitespace;
-import static java.lang.String.format;
-import static java.util.Collections.singletonList;
-import static javafx.application.Platform.runLater;
-import static javafx.scene.control.ScrollPane.ScrollBarPolicy.ALWAYS;
-import static javafx.scene.input.KeyCode.*;
-import static javafx.scene.input.KeyCombination.*;
-import static org.apache.commons.lang3.StringUtils.stripEnd;
-import static org.apache.commons.lang3.StringUtils.stripStart;
-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() );
- }
- );
- }
- else {
- clue( "Editor.spelling.check.matches.okay", word );
- }
- }
-
- private ContextMenu createSuggestionsPopup() {
- final var menu = new ContextMenu();
-
- menu.setAutoHide( true );
- menu.setHideOnEscape( true );
- menu.setOnHidden( event -> getTextArea().setContextMenu( null ) );
-
- return menu;
- }
-
- /**
- * Creates a menu item capable of replacing a word under the cursor.
- *
- * @param i The beginning and ending text offset to replace.
- * @param s The text to replace at the given offset.
- * @return The menu item that, if actioned, will replace the text.
- */
- private MenuItem createSuggestedItem( final IndexRange i, final String s ) {
- final var menuItem = new MenuItem( s );
-
- menuItem.setOnAction( event -> getTextArea().replaceText( i, s ) );
-
- return menuItem;
+import javafx.beans.binding.Bindings;
+import javafx.beans.property.*;
+import javafx.beans.value.ChangeListener;
+import javafx.event.Event;
+import javafx.scene.Node;
+import javafx.scene.control.IndexRange;
+import javafx.scene.input.KeyEvent;
+import javafx.scene.layout.BorderPane;
+import org.fxmisc.flowless.VirtualizedScrollPane;
+import org.fxmisc.richtext.StyleClassedTextArea;
+import org.fxmisc.richtext.model.StyleSpans;
+import org.fxmisc.undo.UndoManager;
+import org.fxmisc.wellbehaved.event.EventPattern;
+import org.fxmisc.wellbehaved.event.Nodes;
+
+import java.io.File;
+import java.nio.charset.Charset;
+import java.text.BreakIterator;
+import java.text.MessageFormat;
+import java.util.*;
+import java.util.function.Consumer;
+import java.util.function.Supplier;
+import java.util.regex.Pattern;
+
+import static com.keenwrite.MainApp.keyDown;
+import static com.keenwrite.constants.Constants.*;
+import static com.keenwrite.events.StatusEvent.clue;
+import static com.keenwrite.io.MediaType.TEXT_MARKDOWN;
+import static com.keenwrite.io.MediaType.TEXT_R_MARKDOWN;
+import static com.keenwrite.preferences.AppKeys.*;
+import static java.lang.Character.isWhitespace;
+import static java.lang.String.format;
+import static java.util.Collections.singletonList;
+import static javafx.application.Platform.runLater;
+import static javafx.scene.control.ScrollPane.ScrollBarPolicy.ALWAYS;
+import static javafx.scene.input.KeyCode.*;
+import static javafx.scene.input.KeyCombination.*;
+import static org.apache.commons.lang3.StringUtils.stripEnd;
+import static org.apache.commons.lang3.StringUtils.stripStart;
+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 );
+
+ /**
+ * 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 File file, final Workspace workspace ) {
+ mEncoding = open( mFile = file );
+ mWorkspace = workspace;
+
+ initTextArea( mTextArea );
+ initStyle( mTextArea );
+ initScrollPane( mScrollPane );
+ 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 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 );
+ }
+
+ 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 );
}
src/main/java/com/keenwrite/events/spelling/LexiconEvent.java
+/* Copyright 2022 White Magic Software, Ltd. -- All rights reserved. */
+package com.keenwrite.events.spelling;
+
+import com.keenwrite.events.AppEvent;
+
+public abstract class LexiconEvent implements AppEvent {
+}
src/main/java/com/keenwrite/events/spelling/LexiconLoadedEvent.java
+/* Copyright 2022 White Magic Software, Ltd. -- All rights reserved. */
+package com.keenwrite.events.spelling;
+
+import java.util.Map;
+
+/**
+ * Collates information about the lexicon. Fired when the lexicon has been
+ * fully loaded into memory.
+ */
+public class LexiconLoadedEvent extends LexiconEvent {
+
+ private final Map<String, Long> mLexicon;
+
+ private LexiconLoadedEvent( final Map<String, Long> lexicon ) {
+ mLexicon = lexicon;
+ }
+
+ public static void fire( final Map<String, Long> lexicon ) {
+ new LexiconLoadedEvent( lexicon ).publish();
+ }
+
+ /**
+ * Returns a word-frequency map used by the spell checking library.
+ *
+ * @return The lexicon that was loaded.
+ */
+ public Map<String, Long> getLexicon() {
+ return mLexicon;
+ }
+}
src/main/java/com/keenwrite/preferences/LocaleProperty.java
/**
- * Lists the locales having fonts that are supported by the application.
+ * The {@link Locale}s are used for multiple purposes, including:
+ *
+ * <ul>
+ * <li>supported text editor font listing in preferences dialog;</li>
+ * <li>text editor CSS;</li>
+ * <li>preview window CSS; and</li>
+ * <li>lexicon to load for spellcheck.</li>
+ * </ul>
+ *
* When the Markdown and preview CSS files are loaded, a general file is
* first loaded, then a specific file is loaded according to the locale.
* The specific file overrides font families so that different languages
* may be presented.
* <p>
* Using an instance of {@link LinkedHashMap} preserves display order.
* </p>
* <p>
* See
- * <a href="https://oracle.com/java/technologies/javase/jdk12locales.html">
- * JDK 12 Locales
+ * <a href="https://www.oracle.com/java/technologies/javase/jdk19-suported-locales.html">
+ * JDK 19 Supported Locales
* </a> for details.
* </p>
*/
private static final Map<String, Locale> sLocales = new LinkedHashMap<>();
static {
+ @SuppressWarnings( "SpellCheckingInspection" )
final String[] tags = {
+ // English
"en-Latn-AU",
"en-Latn-CA",
"en-Latn-GB",
"en-Latn-NZ",
"en-Latn-US",
"en-Latn-ZA",
+ // German
+ "de-Latn-AT",
+ "de-Latn-DE",
+ "de-Latn-LU",
+ "de-Latn-CH",
+ // Spanish
+ "es-Latn-AR",
+ "es-Latn-BO",
+ "es-Latn-CL",
+ "es-Latn-CO",
+ "es-Latn-CR",
+ "es-Latn-DO",
+ "es-Latn-EC",
+ "es-Latn-SV",
+ "es-Latn-GT",
+ "es-Latn-HN",
+ "es-Latn-MX",
+ "es-Latn-NI",
+ "es-Latn-PA",
+ "es-Latn-PY",
+ "es-Latn-PE",
+ "es-Latn-PR",
+ "es-Latn-ES",
+ "es-Latn-US",
+ "es-Latn-UY",
+ "es-Latn-VE",
+ // French
+ "fr-Latn-BE",
+ "fr-Latn-CA",
+ "fr-Latn-FR",
+ "fr-Latn-LU",
+ "fr-Latn-CH",
+ // Hebrew
+ //"iw-Hebr-IL",
+ // Italian
+ "it-Latn-IT",
+ "it-Latn-CH",
+ // Japanese
"ja-Jpan-JP",
+ // Korean
"ko-Kore-KR",
+ // Chinese
"zh-Hans-CN",
"zh-Hans-SG",
private static Locale sanitize( final Locale locale ) {
- // If the language is "und"efined then use the default locale.
+ // If the language is undefined then use the default locale.
return locale == null || "und".equalsIgnoreCase( locale.toLanguageTag() )
? LOCALE_DEFAULT
/**
* Performs an O(n) search through the given map to find the key that is
- * mapped to the given value. A bi-directional map would be faster, but
+ * mapped to the given value. A bidirectional map would be faster, but
* also introduces additional dependencies. This doesn't need to be fast
* because it happens once, at start up, and there aren't a lot of values.
src/main/java/com/keenwrite/spelling/impl/Lexicon.java
+/* Copyright 2022 White Magic Software, Ltd. -- All rights reserved. */
+package com.keenwrite.spelling.impl;
+
+import com.keenwrite.events.spelling.LexiconLoadedEvent;
+import com.keenwrite.exceptions.MissingFileException;
+
+import java.io.BufferedReader;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.util.HashMap;
+import java.util.Locale;
+
+import static com.keenwrite.constants.Constants.LEXICONS_DIRECTORY;
+import static com.keenwrite.events.StatusEvent.clue;
+import static java.lang.String.format;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+/**
+ * Responsible for loading a set of single words, asynchronously.
+ */
+public final class Lexicon {
+ /**
+ * Most lexicons have 100,000 words.
+ */
+ private static final int LEXICON_CAPACITY = 100_000;
+
+ /**
+ * The word-frequency entries are tab-delimited.
+ */
+ private static final char DELIMITER = '\t';
+
+ /**
+ * Load the lexicon into memory then fire an event indicating that the
+ * word-frequency pairs are available to use for spellchecking. This
+ * happens asynchronously so that the UI can load faster.
+ *
+ * @param locale The locale having a corresponding lexicon to load.
+ */
+ public static void read( final Locale locale ) {
+ assert locale != null;
+
+ new Thread( read( toResourcePath( locale ) ) ).start();
+ }
+
+ private static Runnable read( final String path ) {
+ return () -> {
+ try( final var resource = openResource( path ) ) {
+ read( resource );
+ } catch( final Exception ex ) {
+ clue( ex );
+ }
+ };
+ }
+
+ private static void read( final InputStream resource ) {
+ try( final var input = new InputStreamReader( resource, UTF_8 );
+ final var reader = new BufferedReader( input ) ) {
+ read( reader );
+ } catch( final Exception ex ) {
+ clue( ex );
+ }
+ }
+
+ private static void read( final BufferedReader reader ) {
+ try {
+ long count = 0;
+ final var lexicon = new HashMap<String, Long>( LEXICON_CAPACITY );
+ String line;
+
+ while( (line = reader.readLine()) != null ) {
+ final var index = line.indexOf( DELIMITER );
+ final var word = line.substring( 0, index == -1 ? 0 : index );
+ final var frequency = parse( line.substring( index + 1 ) );
+
+ lexicon.put( word, frequency );
+
+ // Slower machines may benefit users by showing a loading message.
+ if( ++count % 25_000 == 0 ) {
+ status( "loading", count );
+ }
+ }
+
+ // Indicate that loading the lexicon is finished.
+ status( "loaded", count );
+ LexiconLoadedEvent.fire( lexicon );
+ } catch( final Exception ex ) {
+ clue( ex );
+ }
+ }
+
+ /**
+ * Prevents autoboxing and uses cached values when possible. A return value
+ * of 0L means that the word will receive the lowest priority. If there's
+ * an error (i.e., data corruption) parsing the number, the spell checker
+ * will still work, but be suboptimal for all erroneous entries.
+ *
+ * @param number The numeric value to parse into a long object.
+ * @return The parsed value, or 0L if the number couldn't be parsed.
+ */
+ private static Long parse( final String number ) {
+ try {
+ return Long.valueOf( number );
+ } catch( final NumberFormatException ex ) {
+ clue( ex );
+ return 0L;
+ }
+ }
+
+ private static InputStream openResource( final String path )
+ throws MissingFileException {
+ final var resource = Lexicon.class.getResourceAsStream( path );
+
+ if( resource == null ) {
+ throw new MissingFileException( path );
+ }
+
+ return resource;
+ }
+
+ /**
+ * Convert a {@link Locale} into a path that can be loaded as a resource.
+ *
+ * @param locale The {@link Locale} to convert to a resource.
+ * @return The slash-separated path to a lexicon resource file.
+ */
+ private static String toResourcePath( final Locale locale ) {
+ final var language = locale.getLanguage();
+ return format( "/%s/%s.txt", LEXICONS_DIRECTORY, language );
+ }
+
+ private static void status( final String s, final long count ) {
+ clue( "Main.status.lexicon." + s, count );
+ }
+}
src/main/java/com/keenwrite/spelling/impl/SymSpellSpeller.java
package com.keenwrite.spelling.impl;
-import com.keenwrite.exceptions.MissingFileException;
import com.keenwrite.spelling.api.SpellCheckListener;
import com.keenwrite.spelling.api.SpellChecker;
import io.gitlab.rxp90.jsymspell.SymSpell;
import io.gitlab.rxp90.jsymspell.SymSpellBuilder;
import io.gitlab.rxp90.jsymspell.Verbosity;
import io.gitlab.rxp90.jsymspell.api.SuggestItem;
-import java.io.BufferedReader;
-import java.io.InputStreamReader;
import java.text.BreakIterator;
import java.util.ArrayList;
-import java.util.HashMap;
import java.util.List;
import java.util.Map;
-import static com.keenwrite.constants.Constants.LEXICONS_DIRECTORY;
import static com.keenwrite.events.StatusEvent.clue;
import static io.gitlab.rxp90.jsymspell.Verbosity.ALL;
import static io.gitlab.rxp90.jsymspell.Verbosity.CLOSEST;
import static java.lang.Character.isLetter;
-import static java.lang.Long.parseLong;
-import static java.nio.charset.StandardCharsets.UTF_8;
/**
* Creates a new spellchecker for a lexicon of words in the specified file.
*
- * @param filename Lexicon language file (e.g., "en.txt").
+ * @param lexicon The word-frequency map.
* @return An instance of {@link SpellChecker} that can check if a word
* is correct and suggest alternatives, or {@link PermissiveSpeller} if the
* lexicon cannot be loaded.
*/
- public static SpellChecker forLexicon( final String filename ) {
- assert filename != null;
- assert !filename.isBlank();
-
- try {
- final var lexicon = readLexicon( filename );
- return SymSpellSpeller.forLexicon( lexicon );
- } catch( final Exception ex ) {
- clue( ex );
- return new PermissiveSpeller();
- }
- }
-
- private static SpellChecker forLexicon( final Map<String, Long> lexicon ) {
+ public static SpellChecker forLexicon( final Map<String, Long> lexicon ) {
assert lexicon != null;
assert !lexicon.isEmpty();
previousIndex = boundaryIndex;
boundaryIndex = mBreakIterator.next();
- }
- }
-
- @SuppressWarnings( "SameParameterValue" )
- private static Map<String, Long> readLexicon( final String filename )
- throws Exception {
- assert filename != null;
- assert !filename.isEmpty();
-
- final var path = '/' + LEXICONS_DIRECTORY + '/' + filename;
- final var map = new HashMap<String, Long>();
-
- try( final var resource =
- SymSpellSpeller.class.getResourceAsStream( path ) ) {
- if( resource == null ) {
- throw new MissingFileException( path );
- }
-
- try( final var isr = new InputStreamReader( resource, UTF_8 );
- final var reader = new BufferedReader( isr ) ) {
- String line;
-
- while( (line = reader.readLine()) != null ) {
- final var tokens = line.split( "\\t" );
- map.put( tokens[ 0 ], parseLong( tokens[ 1 ] ) );
- }
- }
}
-
- return map;
}
src/main/java/com/keenwrite/spelling/impl/TextEditorSpeller.java
-/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
-package com.keenwrite.spelling.impl;
-
-import com.keenwrite.spelling.api.SpellCheckListener;
-import com.keenwrite.spelling.api.SpellChecker;
-import com.vladsch.flexmark.parser.Parser;
-import com.vladsch.flexmark.util.ast.NodeVisitor;
-import com.vladsch.flexmark.util.ast.VisitHandler;
-import org.fxmisc.richtext.StyleClassedTextArea;
-import org.fxmisc.richtext.model.StyleSpansBuilder;
-
-import java.util.Collection;
-import java.util.List;
-import java.util.concurrent.atomic.AtomicInteger;
-
-import static com.keenwrite.spelling.impl.SymSpellSpeller.forLexicon;
-import static java.util.Collections.emptyList;
-import static java.util.Collections.singleton;
-import static org.fxmisc.richtext.model.TwoDimensional.Bias.Forward;
-
-/**
- * Responsible for checking the spelling of a document being edited.
- */
-public final class TextEditorSpeller {
- /**
- * Only load the dictionary into memory once, because it's huge.
- */
- private static final SpellChecker sSpellChecker = forLexicon( "en.txt" );
-
- private final Parser mParser;
-
- public TextEditorSpeller() {
- mParser = Parser.builder().build();
- }
-
- /**
- * Delegates to {@link #spellcheck(StyleClassedTextArea, String, int)}.
- * call to spell check the entire document.
- */
- public void checkDocument( final StyleClassedTextArea editor ) {
- spellcheck( editor, editor.getText(), -1 );
- }
-
- /**
- * Listen for changes to the any particular paragraph and perform a quick
- * spell check upon it. The style classes in the editor will be changed to
- * mark any spelling mistakes in the paragraph. The user may then interact
- * with any misspelled word (i.e., any piece of text that is marked) to
- * revise the spelling.
- *
- * @param editor The text area containing paragraphs to spellcheck.
- */
- public void checkParagraphs( final StyleClassedTextArea editor ) {
- // Use the plain text changes so that notifications of style changes
- // are suppressed. Checking against the identity ensures that only
- // new text additions or deletions trigger proofreading.
- editor.plainTextChanges()
- .filter( p -> !p.isIdentity() ).subscribe( change -> {
-
- // Check current paragraph; the whole document was checked upon
- // opening.
- final var offset = change.getPosition();
- final var position = editor.offsetToPosition( offset, Forward );
- final var paraId = position.getMajor();
- final var paragraph = editor.getParagraph( paraId );
- final var text = paragraph.getText();
-
- // Prevent doubling-up styles.
- editor.clearStyle( paraId );
-
- spellcheck( editor, text, paraId );
- } );
- }
-
- /**
- * Spellchecks a subset of the entire document.
- *
- * @param text Look up words for this text in the lexicon.
- * @param paraId Set to -1 to apply resulting style spans to the entire
- * text.
- */
- private void spellcheck(
- final StyleClassedTextArea editor, final String text, final int paraId ) {
- final var builder = new StyleSpansBuilder<Collection<String>>();
- final var runningIndex = new AtomicInteger( 0 );
-
- // The text nodes must be relayed through a contextual "visitor" that
- // can return text in chunks with correlative offsets into the string.
- // This allows Markdown, R Markdown, XML, and R XML documents to return
- // sets of words to check.
- final var node = mParser.parse( text );
- final var visitor = new TextVisitor( ( visited, bIndex, eIndex ) -> {
- // Treat hyphenated compound words as individual words.
- final var check = visited.replace( '-', ' ' );
-
- sSpellChecker.proofread( check, ( misspelled, prevIndex, currIndex ) -> {
- prevIndex += bIndex;
- currIndex += bIndex;
-
- // Clear styling between lexiconically absent words.
- builder.add( emptyList(), prevIndex - runningIndex.get() );
- builder.add( singleton( "spelling" ), currIndex - prevIndex );
- runningIndex.set( currIndex );
- } );
- } );
-
- visitor.visit( node );
-
- // If the running index was set, at least one word triggered the listener.
- if( runningIndex.get() > 0 ) {
- // Clear styling after the last lexiconically absent word.
- builder.add( emptyList(), text.length() - runningIndex.get() );
-
- final var spans = builder.create();
-
- if( paraId >= 0 ) {
- editor.setStyleSpans( paraId, 0, spans );
- }
- else {
- editor.setStyleSpans( 0, spans );
- }
- }
- }
-
- /**
- * Returns a list of suggests for the given word. This is typically used to
- * check for suitable replacements of the word at the caret position.
- *
- * @param word The word to spellcheck.
- * @param count The maximum number of suggested alternatives to return.
- * @return A list of recommended spellings for the given word.
- */
- public List<String> checkWord( final String word, final int count ) {
- return sSpellChecker.suggestions( word, count );
- }
-
- /**
- * TODO: #59 -- Replace with generic interface; provide Markdown/XML
- * implementations.
- */
- private static final class TextVisitor {
- private final NodeVisitor mVisitor = new NodeVisitor( new VisitHandler<>(
- com.vladsch.flexmark.ast.Text.class, this::visit )
- );
-
- private final SpellCheckListener mConsumer;
-
- public TextVisitor( final SpellCheckListener consumer ) {
- mConsumer = consumer;
- }
-
- private void visit( final com.vladsch.flexmark.util.ast.Node node ) {
- if( node instanceof com.vladsch.flexmark.ast.Text ) {
- mConsumer.accept( node.getChars().toString(),
- node.getStartOffset(),
- node.getEndOffset() );
- }
-
- mVisitor.visitChildren( node );
- }
- }
-}
src/main/java/com/keenwrite/ui/spelling/TextEditorSpellChecker.java
+/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
+package com.keenwrite.ui.spelling;
+
+import com.keenwrite.editors.TextEditor;
+import com.keenwrite.spelling.api.SpellCheckListener;
+import com.keenwrite.spelling.api.SpellChecker;
+import com.vladsch.flexmark.parser.Parser;
+import com.vladsch.flexmark.util.ast.NodeVisitor;
+import com.vladsch.flexmark.util.ast.VisitHandler;
+import javafx.beans.property.ObjectProperty;
+import javafx.scene.control.ContextMenu;
+import javafx.scene.control.IndexRange;
+import javafx.scene.control.MenuItem;
+import org.fxmisc.richtext.StyleClassedTextArea;
+import org.fxmisc.richtext.model.PlainTextChange;
+import org.fxmisc.richtext.model.StyleSpansBuilder;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static com.keenwrite.events.StatusEvent.clue;
+import static java.util.Collections.emptyList;
+import static java.util.Collections.singleton;
+import static org.fxmisc.richtext.model.TwoDimensional.Bias.Forward;
+
+/**
+ * Responsible for checking the spelling of a document being edited.
+ */
+public final class TextEditorSpellChecker {
+ private final ObjectProperty<SpellChecker> mSpellChecker;
+ private final Parser mParser = Parser.builder().build();
+
+ /**
+ * Create a new spellchecker that can highlight spelling mistakes within a
+ * {@link StyleClassedTextArea}. The given {@link SpellChecker} is wrapped
+ * in a mutable {@link ObjectProperty} because the user may swap languages
+ * at runtime.
+ *
+ * @param checker The spellchecker to use when scanning for spelling errors.
+ */
+ public TextEditorSpellChecker( final ObjectProperty<SpellChecker> checker ) {
+ assert checker != null;
+
+ mSpellChecker = checker;
+ }
+
+ /**
+ * Call to spellcheck the entire document.
+ */
+ public void checkDocument( final TextEditor editor ) {
+ spellcheck( editor.getTextArea(), editor.getText(), -1 );
+ }
+
+ /**
+ * Listen for changes to any particular paragraph and perform a quick
+ * spell check upon it. The style classes in the editor will be changed to
+ * mark any spelling mistakes in the paragraph. The user may then interact
+ * with any misspelled word (i.e., any piece of text that is marked) to
+ * revise the spelling.
+ * <p>
+ * Use {@link PlainTextChange} so that notifications of style changes
+ * are suppressed. Checking against the identity ensures that only
+ * new text additions or deletions trigger proofreading.
+ */
+ public void checkParagraph(
+ final StyleClassedTextArea editor,
+ final PlainTextChange change ) {
+ // Check current paragraph; the document was checked when opened.
+ final var offset = change.getPosition();
+ final var position = editor.offsetToPosition( offset, Forward );
+ final var paraId = position.getMajor();
+ final var paragraph = editor.getParagraph( paraId );
+ final var text = paragraph.getText();
+
+ // Prevent doubling-up styles.
+ editor.clearStyle( paraId );
+
+ spellcheck( editor, text, paraId );
+ }
+
+ /**
+ * Spellchecks a subset of the entire document.
+ *
+ * @param editor The document (or portions thereof) to spellcheck.
+ * @param text Look up words for this text in the lexicon.
+ * @param paraId Set to -1 to apply resulting style spans to the entire
+ * text.
+ */
+ private void spellcheck(
+ final StyleClassedTextArea editor, final String text, final int paraId ) {
+ final var builder = new StyleSpansBuilder<Collection<String>>();
+ final var runningIndex = new AtomicInteger( 0 );
+
+ // The text nodes must be relayed through a contextual "visitor" that
+ // can return text in chunks with correlative offsets into the string.
+ // This allows Markdown and R Markdown documents to return sets of
+ // words to check.
+ final var node = mParser.parse( text );
+ final var visitor = new TextVisitor( ( visited, bIndex, eIndex ) -> {
+ // Treat hyphenated compound words as individual words.
+ final var check = visited.replace( '-', ' ' );
+ final var checker = getSpellChecker();
+
+ checker.proofread( check, ( misspelled, prevIndex, currIndex ) -> {
+ prevIndex += bIndex;
+ currIndex += bIndex;
+
+ // Clear styling between lexiconically absent words.
+ builder.add( emptyList(), prevIndex - runningIndex.get() );
+ builder.add( singleton( "spelling" ), currIndex - prevIndex );
+ runningIndex.set( currIndex );
+ } );
+ } );
+
+ visitor.visit( node );
+
+ // If the running index was set, at least one word triggered the listener.
+ if( runningIndex.get() > 0 ) {
+ // Clear styling after the last lexiconically absent word.
+ builder.add( emptyList(), text.length() - runningIndex.get() );
+
+ final var spans = builder.create();
+
+ if( paraId >= 0 ) {
+ editor.setStyleSpans( paraId, 0, spans );
+ }
+ else {
+ editor.setStyleSpans( 0, spans );
+ }
+ }
+ }
+
+ /**
+ * Called to display a pop-up with a list of spelling corrections. When the
+ * user selects an item from the list, the word at the caret position is
+ * replaced (with the selected item).
+ */
+ public void autofix( final TextEditor editor ) {
+ final var caretWord = editor.getCaretWord();
+ final var textArea = editor.getTextArea();
+ final var word = textArea.getText( caretWord );
+ final var suggestions = checkWord( word, 10 );
+
+ if( suggestions.isEmpty() ) {
+ clue( "Editor.spelling.check.matches.none", word );
+ }
+ else if( !suggestions.contains( word ) ) {
+ final var menu = createSuggestionsPopup( textArea );
+ final var items = menu.getItems();
+ textArea.setContextMenu( menu );
+
+ for( final var correction : suggestions ) {
+ items.add( createSuggestedItem( textArea, caretWord, correction ) );
+ }
+
+ textArea.getCaretBounds().ifPresent(
+ bounds -> {
+ menu.setOnShown( event -> menu.requestFocus() );
+ menu.show( textArea, bounds.getCenterX(), bounds.getCenterY() );
+ }
+ );
+ }
+ else {
+ clue( "Editor.spelling.check.matches.okay", word );
+ }
+ }
+
+ private ContextMenu createSuggestionsPopup(
+ final StyleClassedTextArea textArea ) {
+ final var menu = new ContextMenu();
+
+ menu.setAutoHide( true );
+ menu.setHideOnEscape( true );
+ menu.setOnHidden( event -> textArea.setContextMenu( null ) );
+
+ return menu;
+ }
+
+ /**
+ * Creates a menu item capable of replacing a word under the cursor.
+ *
+ * @param textArea The text upon which this action will replace.
+ * @param i The beginning and ending text offset to replace.
+ * @param s The text to replace at the given offset.
+ * @return The menu item that, if actioned, will replace the text.
+ */
+ private MenuItem createSuggestedItem(
+ final StyleClassedTextArea textArea,
+ final IndexRange i,
+ final String s ) {
+ final var menuItem = new MenuItem( s );
+
+ menuItem.setOnAction( event -> textArea.replaceText( i, s ) );
+
+ return menuItem;
+ }
+
+ /**
+ * Returns a list of suggests for the given word. This is typically used to
+ * check for suitable replacements of the word at the caret position.
+ *
+ * @param word The word to spellcheck.
+ * @param count The maximum number of suggested alternatives to return.
+ * @return A list of recommended spellings for the given word.
+ */
+ public List<String> checkWord( final String word, final int count ) {
+ return getSpellChecker().suggestions( word, count );
+ }
+
+ private SpellChecker getSpellChecker() {
+ return mSpellChecker.get();
+ }
+
+ private static final class TextVisitor {
+ private final NodeVisitor mVisitor = new NodeVisitor( new VisitHandler<>(
+ com.vladsch.flexmark.ast.Text.class, this::visit )
+ );
+
+ private final SpellCheckListener mConsumer;
+
+ public TextVisitor( final SpellCheckListener consumer ) {
+ mConsumer = consumer;
+ }
+
+ private void visit( final com.vladsch.flexmark.util.ast.Node node ) {
+ if( node instanceof com.vladsch.flexmark.ast.Text ) {
+ mConsumer.accept( node.getChars().toString(),
+ node.getStartOffset(),
+ node.getEndOffset() );
+ }
+
+ mVisitor.visitChildren( node );
+ }
+ }
+}
src/main/resources/com/keenwrite/editor/markdown_de-Latn-AT.css
src/main/resources/com/keenwrite/editor/markdown_de-Latn-CH.css
src/main/resources/com/keenwrite/editor/markdown_de-Latn-DE.css
src/main/resources/com/keenwrite/editor/markdown_de-Latn-LU.css
src/main/resources/com/keenwrite/editor/markdown_es-Latn-AR.css
src/main/resources/com/keenwrite/editor/markdown_es-Latn-BO.css
src/main/resources/com/keenwrite/editor/markdown_es-Latn-CL.css
src/main/resources/com/keenwrite/editor/markdown_es-Latn-CO.css
src/main/resources/com/keenwrite/editor/markdown_es-Latn-CR.css
src/main/resources/com/keenwrite/editor/markdown_es-Latn-DO.css
src/main/resources/com/keenwrite/editor/markdown_es-Latn-EC.css
src/main/resources/com/keenwrite/editor/markdown_es-Latn-ES.css
src/main/resources/com/keenwrite/editor/markdown_es-Latn-GT.css
src/main/resources/com/keenwrite/editor/markdown_es-Latn-HN.css
src/main/resources/com/keenwrite/editor/markdown_es-Latn-MX.css
src/main/resources/com/keenwrite/editor/markdown_es-Latn-NI.css
src/main/resources/com/keenwrite/editor/markdown_es-Latn-PA.css
src/main/resources/com/keenwrite/editor/markdown_es-Latn-PE.css
src/main/resources/com/keenwrite/editor/markdown_es-Latn-PR.css
src/main/resources/com/keenwrite/editor/markdown_es-Latn-PY.css
src/main/resources/com/keenwrite/editor/markdown_es-Latn-SV.css
src/main/resources/com/keenwrite/editor/markdown_es-Latn-US.css
src/main/resources/com/keenwrite/editor/markdown_es-Latn-UY.css
src/main/resources/com/keenwrite/editor/markdown_es-Latn-VE.css
src/main/resources/com/keenwrite/editor/markdown_fr-Latn-BE.css
src/main/resources/com/keenwrite/editor/markdown_fr-Latn-CA.css
src/main/resources/com/keenwrite/editor/markdown_fr-Latn-CH.css
src/main/resources/com/keenwrite/editor/markdown_fr-Latn-FR.css
src/main/resources/com/keenwrite/editor/markdown_fr-Latn-LU.css
src/main/resources/com/keenwrite/editor/markdown_it-Latn-CH.css
src/main/resources/com/keenwrite/editor/markdown_it-Latn-IT.css
src/main/resources/com/keenwrite/editor/markdown_iw-Hebr-IL.css
src/main/resources/com/keenwrite/messages.properties
Main.status.typeset.ended.failure=Failed to typeset ''{0}'' ({1} elapsed)
+Main.status.lexicon.loading=Loading lexicon: {0} words
+Main.status.lexicon.loaded=Loaded lexicon: {0} words
+
# ########################################################################
# Search Bar
src/main/resources/lexicons/README.md
-# Building
+# Lexicons
-The lexicon files are retrieved from:
+This directory contains lexicons used for spell checking. Each lexicon
+file contains tab-delimited word-frequency pairs.
-https://github.com/wolfgarbe/SymSpell/tree/master/SymSpell
+Compiling a high-quality list of correctly spelled words requires the
+following steps:
-The lexicons and bigrams are space-separated by default, but parsing a
-tab-delimited file is easier, so change them to tab-separated files.
+1. Download a unigram frequency list for all words for a given language.
+1. Download a high-quality source list of correctly spelled words.
+1. Filter the unigram frequency list using all words in the source list.
+1. Sort the filtered list by the frequency in descending order.
+
+The latter steps can be accomplished as follows:
+
+ # Extract unigram and frequency based on existence in source lexicon.
+ for i in $(cat source-lexicon.txt); do
+ grep -m 1 "^$i"$'\t' unigram-frequencies.txt;
+ done > filtered.txt
+
+ # Sort numerically (-n) using column two (-k2) in reverse order (-r).
+ sort -n -k2 -r filtered.txt > en.txt
+
+There may be more efficient ways to filter the data, which takes a few hours
+to complete (on modern hardware).
+
+# Lexicons
+
+There are numerous sources of word and frequency lists available, including:
+
+* https://storage.googleapis.com/books/ngrams/books/datasetsv3.html
+* https://github.com/hermitdave/FrequencyWords/
+* https://github.com/neilk/wordfrequencies
src/main/resources/lexicons/en.txt
Binary files differ
Delta2222 lines added, 1948 lines removed, 274-line increase