Dave Jarvis' Repositories

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

Run all export tasks in non-JavaFX thread

AuthorDaveJarvis <email>
Date2021-04-06 18:32:09 GMT-0700
Commit7d771ba1fe451f50bba26bd061765aabf37dd743
Parent17da710
src/main/java/com/keenwrite/MainPane.java
import javafx.beans.property.*;
import javafx.collections.ListChangeListener;
-import javafx.event.ActionEvent;
-import javafx.event.Event;
-import javafx.event.EventHandler;
-import javafx.scene.Node;
-import javafx.scene.Scene;
-import javafx.scene.control.SplitPane;
-import javafx.scene.control.Tab;
-import javafx.scene.control.TabPane;
-import javafx.scene.control.Tooltip;
-import javafx.scene.control.TreeItem.TreeModificationEvent;
-import javafx.scene.input.KeyEvent;
-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.atomic.AtomicBoolean;
-import java.util.function.Function;
-import java.util.stream.Collectors;
-
-import static com.keenwrite.constants.Constants.*;
-import static com.keenwrite.ExportFormat.NONE;
-import static com.keenwrite.Messages.get;
-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.WorkspaceKeys.*;
-import static com.keenwrite.processors.IdentityProcessor.IDENTITY;
-import static com.keenwrite.processors.ProcessorFactory.createProcessors;
-import static java.util.stream.Collectors.groupingBy;
-import static javafx.application.Platform.runLater;
-import static javafx.scene.control.ButtonType.NO;
-import static javafx.scene.control.ButtonType.YES;
-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 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 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, TEXT_R_XML, UNDEFINED
- );
-
- /**
- * 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 Map<MediaType, TabPane> mTabPanes = new HashMap<>();
-
- /**
- * Stores definition names and values.
- */
- private final Map<String, String> mResolvedMap =
- new HashMap<>( MAP_SIZE_DEFAULT );
-
- /**
- * 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> mActiveTextEditor =
- 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> mActiveDefinitionEditor =
- createActiveDefinitionEditor( mActiveTextEditor );
-
- /**
- * 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;
-
- /**
- * Called when the definition data is changed.
- */
- private final EventHandler<TreeModificationEvent<Event>> mTreeHandler =
- event -> {
- final var editor = mActiveDefinitionEditor.get();
-
- resolve( editor );
- process( getActiveTextEditor() );
- save( editor );
- };
-
- 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 );
-
- open( bin( 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 here. We want to close all the tabs to ensure each
- // is saved, but after they are closed, the workspace should still
- // retain the list of files that were open. If this line came after
- // closing, then restarting the application would list no files.
- mWorkspace.save();
-
- if( closeAll() ) {
- Platform.exit();
- System.exit( 0 );
- }
- else {
- event.consume();
- }
- } )
- );
-
- register( this );
- }
-
- @Subscribe
- public void handle( final TextEditorFocusEvent event ) {
- mActiveTextEditor.set( event.get() );
- }
-
- @Subscribe
- public void handle( final TextDefinitionFocusEvent event ) {
- mActiveDefinitionEditor.set( event.get() );
- }
-
- /**
- * Typically called when a file name is clicked in the {@link HtmlPanel}.
- *
- * @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 = getActiveTextEditor().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 = getActiveTextEditor().getTextArea();
- textArea.moveTo( event.getOffset() );
- textArea.requestFollowCaret();
- textArea.requestFocus();
- } );
- }
-
- /**
- * TODO: Load divider positions from exported settings, see bin() 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.
- *
- * @param file The file to open.
- */
- private void open( final File file ) {
- final var tab = createTab( file );
- final var node = tab.getContent();
- final var mediaType = MediaType.valueFrom( file );
- final var tabPane = obtainTabPane( mediaType );
-
- tab.setTooltip( createTooltip( file ) );
- 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
- );
- }
-
- getRecentFiles().add( file.getAbsolutePath() );
- }
-
- /**
- * 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(
- ( mt, tp ) -> tp.getTabs().forEach( ( tab ) -> {
- final var node = tab.getContent();
- if( node instanceof TextEditor ) {
- save( ((TextEditor) node) );
- }
- } )
- );
- }
-
- /**
- * 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( getActiveTextEditor() );
- }
-
- /**
- * Saves the active {@link TextEditor} under a new name.
- *
- * @param file The new active editor {@link File} reference.
- */
- public void saveAs( final File file ) {
- assert file != null;
- final var editor = getActiveTextEditor();
- final var tab = getTab( editor );
-
- 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 entry : mTabPanes.entrySet() ) {
- final var tabPane = entry.getValue();
- 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 ) {
- 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 = getActiveTextEditor();
-
- 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 ) -> {
- tab.getTabPane().getTabs().remove( tab );
- close( 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 fileManager = new FilesView( mWorkspace );
- 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();
- }
-
- /**
- * 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.values()
- .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 {@link #mResolvedMap} is updated and the active
- * text editor is refreshed.
- *
- * @param editor 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> editor ) {
- final var definitions = new SimpleObjectProperty<TextDefinition>();
- definitions.addListener( ( c, o, n ) -> {
- resolve( n == null ? createDefinitionEditor() : n );
- process( editor.get() );
- } );
-
- return definitions;
- }
-
- 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() )
- );
-
- 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> bin( 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.
- final var bins = paths
- .stream()
- .collect(
- groupingBy( path -> bin.apply( MediaType.fromFilename( path ) ) )
- );
-
- bins.putIfAbsent( TEXT_YAML, List.of( DEFINITION_DEFAULT.toString() ) );
- bins.putIfAbsent( TEXT_PLAIN, List.of( DOCUMENT_DEFAULT.toString() ) );
-
- final var result = new ArrayList<File>( paths.size() );
-
- // Ensure that the same types are listed together (keep insertion order).
- bins.forEach( ( mediaType, files ) -> result.addAll(
- files.stream().map( File::new ).collect( Collectors.toList() ) )
- );
-
- return result;
- }
-
- /**
- * Uses the given {@link TextDefinition} instance to update the
- * {@link #mResolvedMap}.
- *
- * @param editor A non-null, possibly empty definition editor.
- */
- private void resolve( final TextDefinition editor ) {
- assert editor != null;
-
- final var tokens = createDefinitionTokens();
- final var operator = new YamlSigilOperator( tokens );
- final var map = new HashMap<String, String>();
-
- editor.toMap().forEach( ( k, v ) -> map.put( operator.entoken( k ), v ) );
-
- mResolvedMap.clear();
- mResolvedMap.putAll( editor.interpolate( map, tokens ) );
- }
-
- /**
- * 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 that these are run from within the Swing event dispatch thread
- // so that the text editor thread is immediately freed for caret movement.
- // This means that the preview will have a slight delay when catching up
- // to the caret position.
- invokeLater( () -> {
- final var processor = mProcessors.getOrDefault( editor, IDENTITY );
- processor.apply( editor == null ? "" : editor.getText() );
- mPreview.scrollTo( CARET_ID );
- } );
+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.SplitPane;
+import javafx.scene.control.Tab;
+import javafx.scene.control.TabPane;
+import javafx.scene.control.Tooltip;
+import javafx.scene.control.TreeItem.TreeModificationEvent;
+import javafx.scene.input.KeyEvent;
+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.atomic.AtomicBoolean;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+import static com.keenwrite.ExportFormat.NONE;
+import static com.keenwrite.Messages.get;
+import static com.keenwrite.constants.Constants.*;
+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.WorkspaceKeys.*;
+import static com.keenwrite.processors.IdentityProcessor.IDENTITY;
+import static com.keenwrite.processors.ProcessorFactory.createProcessors;
+import static java.util.concurrent.Executors.newFixedThreadPool;
+import static java.util.stream.Collectors.groupingBy;
+import static javafx.application.Platform.runLater;
+import static javafx.scene.control.ButtonType.NO;
+import static javafx.scene.control.ButtonType.YES;
+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 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, TEXT_R_XML, UNDEFINED
+ );
+
+ /**
+ * 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 Map<MediaType, TabPane> mTabPanes = new HashMap<>();
+
+ /**
+ * Stores definition names and values.
+ */
+ private final Map<String, String> mResolvedMap =
+ new HashMap<>( MAP_SIZE_DEFAULT );
+
+ /**
+ * 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> mActiveTextEditor =
+ 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> mActiveDefinitionEditor =
+ createActiveDefinitionEditor( mActiveTextEditor );
+
+ /**
+ * 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;
+
+ /**
+ * Called when the definition data is changed.
+ */
+ private final EventHandler<TreeModificationEvent<Event>> mTreeHandler =
+ event -> {
+ final var editor = mActiveDefinitionEditor.get();
+
+ resolve( editor );
+ process( getActiveTextEditor() );
+ save( editor );
+ };
+
+ 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 );
+
+ open( bin( 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 here. We want to close all the tabs to ensure each
+ // is saved, but after they are closed, the workspace should still
+ // retain the list of files that were open. If this line came after
+ // closing, then restarting the application would list no files.
+ mWorkspace.save();
+
+ if( closeAll() ) {
+ Platform.exit();
+ System.exit( 0 );
+ }
+ else {
+ event.consume();
+ }
+ } )
+ );
+
+ register( this );
+ }
+
+ @Subscribe
+ public void handle( final TextEditorFocusEvent event ) {
+ mActiveTextEditor.set( event.get() );
+ }
+
+ @Subscribe
+ public void handle( final TextDefinitionFocusEvent event ) {
+ mActiveDefinitionEditor.set( event.get() );
+ }
+
+ /**
+ * Typically called when a file name is clicked in the {@link HtmlPanel}.
+ *
+ * @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 = getActiveTextEditor().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 = getActiveTextEditor().getTextArea();
+ textArea.moveTo( event.getOffset() );
+ textArea.requestFollowCaret();
+ textArea.requestFocus();
+ } );
+ }
+
+ /**
+ * TODO: Load divider positions from exported settings, see bin() 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.
+ *
+ * @param file The file to open.
+ */
+ private void open( final File file ) {
+ final var tab = createTab( file );
+ final var node = tab.getContent();
+ final var mediaType = MediaType.valueFrom( file );
+ final var tabPane = obtainTabPane( mediaType );
+
+ tab.setTooltip( createTooltip( file ) );
+ 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
+ );
+ }
+
+ getRecentFiles().add( file.getAbsolutePath() );
+ }
+
+ /**
+ * 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(
+ ( mt, tp ) -> tp.getTabs().forEach( ( tab ) -> {
+ final var node = tab.getContent();
+ if( node instanceof TextEditor ) {
+ save( ((TextEditor) node) );
+ }
+ } )
+ );
+ }
+
+ /**
+ * 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( getActiveTextEditor() );
+ }
+
+ /**
+ * Saves the active {@link TextEditor} under a new name.
+ *
+ * @param file The new active editor {@link File} reference.
+ */
+ public void saveAs( final File file ) {
+ assert file != null;
+ final var editor = getActiveTextEditor();
+ final var tab = getTab( editor );
+
+ 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 entry : mTabPanes.entrySet() ) {
+ final var tabPane = entry.getValue();
+ 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 ) {
+ 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 = getActiveTextEditor();
+
+ 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 ) -> {
+ tab.getTabPane().getTabs().remove( tab );
+ close( 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 fileManager = new FilesView( mWorkspace );
+ 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();
+ }
+
+ /**
+ * 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.values()
+ .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 {@link #mResolvedMap} is updated and the active
+ * text editor is refreshed.
+ *
+ * @param editor 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> editor ) {
+ final var definitions = new SimpleObjectProperty<TextDefinition>();
+ definitions.addListener( ( c, o, n ) -> {
+ resolve( n == null ? createDefinitionEditor() : n );
+ process( editor.get() );
+ } );
+
+ return definitions;
+ }
+
+ 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() )
+ );
+
+ 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> bin( 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.
+ final var bins = paths
+ .stream()
+ .collect(
+ groupingBy( path -> bin.apply( MediaType.fromFilename( path ) ) )
+ );
+
+ bins.putIfAbsent( TEXT_YAML, List.of( DEFINITION_DEFAULT.toString() ) );
+ bins.putIfAbsent( TEXT_PLAIN, List.of( DOCUMENT_DEFAULT.toString() ) );
+
+ final var result = new ArrayList<File>( paths.size() );
+
+ // Ensure that the same types are listed together (keep insertion order).
+ bins.forEach( ( mediaType, files ) -> result.addAll(
+ files.stream().map( File::new ).collect( Collectors.toList() ) )
+ );
+
+ return result;
+ }
+
+ /**
+ * Uses the given {@link TextDefinition} instance to update the
+ * {@link #mResolvedMap}.
+ *
+ * @param editor A non-null, possibly empty definition editor.
+ */
+ private void resolve( final TextDefinition editor ) {
+ assert editor != null;
+
+ final var tokens = createDefinitionTokens();
+ final var operator = new YamlSigilOperator( tokens );
+ final var map = new HashMap<String, String>();
+
+ editor.toMap().forEach( ( k, v ) -> map.put( operator.entoken( k ), v ) );
+
+ mResolvedMap.clear();
+ mResolvedMap.putAll( editor.interpolate( map, tokens ) );
+ }
+
+ /**
+ * 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() {
+ final var processor = mProcessors.getOrDefault( editor, IDENTITY );
+ processor.apply( editor == null ? "" : editor.getText() );
+ return null;
+ }
+ };
+
+ 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 );
}
src/main/java/com/keenwrite/io/HttpFacade.java
import java.util.zip.GZIPInputStream;
+import static com.keenwrite.events.StatusEvent.clue;
import static java.lang.System.getProperty;
import static java.lang.System.setProperty;
assert url != null;
- //clue( "Main.status.image.request.init" );
+ clue( "Main.status.image.request.init" );
final var connection = url.openConnection();
mConn.setRequestProperty( "connection", "close" );
mConn.connect();
- //clue( "Main.status.image.request.fetch", url.getHost() );
+ clue( "Main.status.image.request.fetch", url.getHost() );
final var code = mConn.getResponseCode();
}
- //clue( "Main.status.image.request.success", mediaType );
+ clue( "Main.status.image.request.success", mediaType );
return mediaType;
}
src/main/java/com/keenwrite/preview/HtmlPreview.java
}
- if( box != null ) {
+ scrollTo( box );
+ } );
+ }
+
+ /**
+ * Scrolls to the location specified by the {@link Box} that corresponds
+ * to a point somewhere in the preview pane. If there is no caret, then
+ * this will not change the scroll position. Changing the scroll position
+ * to the top if the {@link Box} instance is {@code null} will result in
+ * jumping around a lot and inconsistent synchronization issues.
+ *
+ * @param box The rectangular region containing the caret, or {@code null}
+ * if the HTML does not have a caret.
+ */
+ private void scrollTo( final Box box ) {
+ if( box != null ) {
+ invokeLater( () -> {
mView.scrollTo( createPoint( box ) );
getScrollPane().repaint();
- }
- } );
+ } );
+ }
}
src/main/java/com/keenwrite/processors/PdfProcessor.java
import com.keenwrite.typesetting.Typesetter;
-import javafx.concurrent.Task;
-
-import java.nio.file.Path;
-import java.util.concurrent.ExecutorService;
import static com.keenwrite.Bootstrap.APP_TITLE_LOWERCASE;
import static com.keenwrite.Messages.get;
import static com.keenwrite.events.StatusEvent.clue;
import static com.keenwrite.io.MediaType.TEXT_XML;
import static java.nio.file.Files.writeString;
-import static java.util.concurrent.Executors.newFixedThreadPool;
/**
* Responsible for using a typesetting engine to convert an XHTML document
- * into a PDF file.
+ * into a PDF file. This must not be run from the JavaFX thread.
*/
public final class PdfProcessor extends ExecutorProcessor<String> {
- private static final ExecutorService sExecutor = newFixedThreadPool( 5 );
-
private final ProcessorContext mContext;
*/
public String apply( final String xhtml ) {
- final var exporter = new Exporter( xhtml );
- exporter.setOnRunning( e -> clue( get( "Main.status.typeset.create" ) ) );
- exporter.setOnSucceeded( e -> {
- clue( get( "Main.status.typeset.export" ) );
-
+ try {
+ clue( get( "Main.status.typeset.create" ) );
+ final var document = TEXT_XML.createTemporaryFile( APP_TITLE_LOWERCASE );
+ final var pathInput = writeString( document, xhtml );
final var pathOutput = mContext.getExportPath();
- final var pathInput = exporter.getValue();
final var typesetter = new Typesetter( mContext.getWorkspace() );
-
- try {
- typesetter.typeset( pathInput, pathOutput );
- } catch( final Exception ex ) {
- clue( ex );
- }
- } );
- sExecutor.execute( exporter );
+ typesetter.typeset( pathInput, pathOutput );
+ } catch( final Exception ex ) {
+ clue( ex );
+ }
// Do not continue processing (the document was typeset into a binary).
return null;
- }
-
- /**
- * Responsible for exporting the active document to a file. That file is
- * then read and typeset by a third-party application.
- */
- private static class Exporter extends Task<Path> {
- private final String mXhtml;
-
- private Exporter( final String xhtml ) {
- mXhtml = xhtml;
- }
-
- @Override
- protected Path call() throws Exception {
- final var document = TEXT_XML.createTemporaryFile( APP_TITLE_LOWERCASE );
- return writeString( document, mXhtml );
- }
}
}
src/main/java/com/keenwrite/processors/XhtmlProcessor.java
import static com.keenwrite.Bootstrap.APP_TITLE_LOWERCASE;
+import static com.keenwrite.Messages.get;
import static com.keenwrite.events.StatusEvent.clue;
import static com.keenwrite.io.HttpFacade.httpGet;
@Override
public String apply( final String html ) {
+ clue( get( "Main.status.typeset.xhtml" ) );
+
final var doc = parse( html );
doc.outputSettings().syntax( Syntax.xml );
src/main/java/com/keenwrite/typesetting/Typesetter.java
import com.keenwrite.preferences.Key;
import com.keenwrite.preferences.Workspace;
-import javafx.concurrent.Task;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
-import java.util.concurrent.TimeoutException;
+import java.util.concurrent.Callable;
import static com.keenwrite.Messages.get;
import static com.keenwrite.constants.Constants.DEFAULT_DIRECTORY;
import static com.keenwrite.events.StatusEvent.clue;
import static com.keenwrite.preferences.WorkspaceKeys.KEY_TYPESET_CONTEXT_ENV;
import static com.keenwrite.preferences.WorkspaceKeys.KEY_TYPESET_CONTEXT_PATH;
-import static java.lang.Long.MAX_VALUE;
import static java.lang.String.format;
import static java.lang.System.currentTimeMillis;
-import static java.util.concurrent.Executors.newFixedThreadPool;
import static java.util.concurrent.TimeUnit.*;
* This will typeset the document using a new process.
*
- * @param input The input document to typeset.
- * @param output Path to the finished typeset document.
+ * @param in The input document to typeset.
+ * @param out Path to the finished typeset document.
*/
- public void typeset( final Path input, final Path output )
+ public void typeset( final Path in, final Path out )
throws Exception {
if( TYPESETTER.canRun() ) {
- final var executor = newFixedThreadPool( 5 );
- final var task = new TypesetTask( input, output );
- final var elapsed = currentTimeMillis();
-
- task.setOnRunning(
- e -> clue( get(
- "Main.status.typeset.began", output
- ) )
- );
-
- task.setOnSucceeded(
- e -> clue( get(
- "Main.status.typeset.ended.success", output, since( elapsed )
- ) )
- );
-
- task.setOnFailed(
- e -> clue( get(
- "Main.status.typeset.ended.failure",
- output, since( elapsed ), task.getValue()
- ) )
- );
+ clue( get( "Main.status.typeset.began", out ) );
+ final var task = new TypesetTask( in, out );
+ final var time = currentTimeMillis();
+ final var code = task.call();
+ final var message = code == 0
+ ? get( "Main.status.typeset.ended.success", out, since( time ) )
+ : get( "Main.status.typeset.ended.failure", out, since( time ), code );
- executor.execute( task );
- executor.shutdown();
- if( !executor.awaitTermination( MAX_VALUE, NANOSECONDS ) ) {
- throw new TimeoutException();
- }
+ clue( message );
}
}
/**
* Launches a task to typeset a document.
*/
- private class TypesetTask extends Task<Integer> {
+ private class TypesetTask implements Callable<Integer> {
private final List<String> mArgs = new ArrayList<>();
src/main/java/com/keenwrite/ui/actions/ApplicationActions.java
import com.keenwrite.ui.logging.LogView;
import com.vladsch.flexmark.ast.Link;
+import javafx.concurrent.Task;
import javafx.scene.control.Alert;
import javafx.scene.control.Dialog;
import javafx.stage.Window;
import javafx.stage.WindowEvent;
+
+import java.nio.file.Path;
+import java.util.concurrent.ExecutorService;
import static com.keenwrite.Bootstrap.*;
-import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG_NODE;
import static com.keenwrite.ExportFormat.*;
import static com.keenwrite.Messages.get;
+import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG_NODE;
import static com.keenwrite.events.StatusEvent.clue;
import static com.keenwrite.preferences.WorkspaceKeys.KEY_UI_RECENT_DIR;
import static com.keenwrite.processors.ProcessorFactory.createProcessors;
import static java.nio.file.Files.writeString;
+import static java.util.concurrent.Executors.newFixedThreadPool;
import static javafx.event.Event.fireEvent;
import static javafx.scene.control.Alert.AlertType.INFORMATION;
@SuppressWarnings( "NonAsciiCharacters" )
public final class ApplicationActions {
+ private static final ExecutorService sExecutor = newFixedThreadPool( 1 );
+
private static final String STYLE_SEARCH = "search";
final var editor = main.getActiveTextEditor();
final var filename = format.toExportFilename( editor.getPath() );
- final var chooser = createFileChooser();
- final var selection = chooser.exportAs( filename );
+ final var selection = createFileChooser().exportAs( filename );
selection.ifPresent( ( file ) -> {
- final var doc = editor.getText();
- final var context = main.createProcessorContext( file.toPath(), format );
- final var chain = createProcessors( context );
- final var export = chain.apply( doc );
+ final var task = new Task<Path>() {
+ @Override
+ protected Path call() throws Exception {
+ final var path = file.toPath();
+ final var doc = editor.getText();
+ final var context = main.createProcessorContext( path, format );
+ final var chain = createProcessors( context );
+ final var export = chain.apply( doc );
- try {
- // Processors can export in binary formats that are incompatible with
- // Java language String objects. In such cases, the processor will
- // return the null sentinel to signal no further processing is needed.
- if( export != null ) {
- writeString( file.toPath(), export );
+ // Processors can export binary files. In such cases, processors
+ // return null to prevent further processing.
+ return export == null ? null : writeString( path, export );
+ }
+ };
+
+ task.setOnSucceeded(
+ e -> {
+ final var result = task.getValue();
// Binary formats must notify users of success independently.
- clue( get( "Main.status.export.success", file.toString() ) );
+ if( result != null ) {
+ clue( get( "Main.status.export.success", result ) );
+ }
}
- } catch( final Exception ex ) {
- clue( ex );
- }
+ );
+
+ task.setOnFailed( e -> clue( task.getException() ) );
+
+ sExecutor.execute( task );
} );
}
src/main/resources/com/keenwrite/messages.properties
Main.status.typeset.create=Creating typesetter
-Main.status.typeset.export=Export document as XHTML
+Main.status.typeset.xhtml=Export document as XHTML
Main.status.typeset.began=Started typesetting ''{0}''
Main.status.typeset.ended.success=Finished typesetting ''{0}'' ({1} elapsed)
Delta757 lines added, 758 lines removed, 1-line decrease