Dave Jarvis' Repositories

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

Add typeset alarm, add hyperlink event handler, update typesetting documentation

Author DaveJarvis <email>
Date 2021-04-12 00:12:41 GMT-0700
Commit e9b095dc2510375e660430489456bb9d90b8fca3
Parent 75f4eab
docs/README.md
-## Documents
+# Documentation
-See the following documents for more information:
+The following documents have additional details about using the editor:
-* [i18n.md](i18n.md) -- Using internationalization features
-* [div.md](div.md) -- Extended syntax for fenced divs
+* [div.md](div.md) -- Syntax for annotated text (fenced divs)
+* [i18n.md](i18n.md) -- Internationalization features
+* [r.md](r.md) -- R functions within R Markdown documents
+* [samples](samples) -- Example documents
+* [skins.md](skins.md) -- User interface customization
+* [svg.md](svg.md) -- Resolve issues with some SVG files
+* [typesetting.md](typesetting.md) -- Document typesetting
* [variables.md](variables.md) -- Variable definitions and interpolation
-* [r.md](r.md) -- Call R functions within R Markdown documents
-* [svg.md](svg.md) -- Fix known issues with displaying SVG files
-* [skins.md](skins.md) -- Describes how to customize the user interface
+
+# Contributions
+
* [credits.md](credits.md) -- Thanks to authors of contributing projects
-* [samples](samples) -- Contains example documents
+* [licenses](licenses) -- Third-party licenses
docs/images/app-install-context.png
Binary files differ
docs/typesetting.md
+# Overview
+
+This document describes how to typeset from within the text editor.
+
+# Background
+
+This editor helps keep content separated from presentation. Plain text documents will remain readable long after proprietary formats have become obsolete. However, we've come to expect much more in what we read than mere text: from hyperlinked tables of contents to indexes, from footers to footnotes, from mathematical expressions to complex graphics, modern documents are nuanced and multifaceted.
+
+Programming computers to typeset internationalized text automatically at the level we've become accustomed takes decades of development effort. Many free and open source software solutions can typeset text, including: ConTeXt, LaTeX, Sile, and others. ConTeXt is ideal for typesetting plain text into beautiful documents because it is developed with a notion of *setups*. These setups wholly describe how text is to be typeset and---by being external to the text itself---configuring setups provides ample control over the document's final appearance without changing the prose.
+
+# Installation
+
+Install ConTeXt as follows:
+
+1. Start the text editor.
+1. Click **File → Export As → PDF** (or type `Ctrl+p`).
+1. Note the operating system name, instruction set, and architecture (e.g., Windows x86 64-bit).
+1. Click the [link](https://wiki.contextgarden.net/Installation) in the dialog.
+1. Download the ConTeXt version for your computer's operating system.
+1. Follow the step-by-step instructions on the ConTeXt installation web page.
+
+ConTeXt is installed.
+
+**Note:** The `PATH` environment variable must include the ConTeXt `bin` directory, otherwise the text editor will not be able to generate PDF files.
+
+# Typeset document
+
+Typeset a document as follows:
+
+1. Start the text editor.
+1. Click **File → New** (or type `Ctrl+n`).
+1. Type in some text.
+1. Click **File → Export As → PDF** (or type `Ctrl+p`).
+1. Set the **File name** to the PDF file name.
+1. Click **Save**.
+
+The document is typeset; open the PDF file in any PDF reader to view the result.
src/main/java/com/keenwrite/MainApp.java
package com.keenwrite;
+import com.keenwrite.events.HyperlinkOpenEvent;
import com.keenwrite.preferences.Workspace;
import javafx.application.Application;
import javafx.event.Event;
import javafx.event.EventType;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.stage.Stage;
+import org.greenrobot.eventbus.Subscribe;
import java.util.function.BooleanSupplier;
import java.util.logging.LogManager;
import static com.keenwrite.Bootstrap.APP_TITLE;
import static com.keenwrite.constants.GraphicsConstants.LOGOS;
+import static com.keenwrite.events.Bus.register;
import static com.keenwrite.preferences.WorkspaceKeys.*;
import static com.keenwrite.util.FontLoader.initFonts;
stage.show();
+ register( this );
}
mMainScene = new MainScene( mWorkspace );
stage.setScene( mMainScene.getScene() );
+ }
+
+ /**
+ * When a hyperlink website URL is clicked, this method is called to launch
+ * the default browser to the event's location.
+ *
+ * @param event The event called when a hyperlink was clicked.
+ */
+ @Subscribe
+ public void handle( final HyperlinkOpenEvent event ) {
+ getHostServices().showDocument( event.getUri().toString() );
}
src/main/java/com/keenwrite/MainPane.java
import com.keenwrite.editors.definition.yaml.YamlTreeTransformer;
import com.keenwrite.editors.markdown.MarkdownEditor;
-import com.keenwrite.events.CaretNavigationEvent;
-import com.keenwrite.events.FileOpenEvent;
-import com.keenwrite.events.TextDefinitionFocusEvent;
-import com.keenwrite.events.TextEditorFocusEvent;
-import com.keenwrite.io.MediaType;
-import com.keenwrite.preferences.Key;
-import com.keenwrite.preferences.Workspace;
-import com.keenwrite.preview.HtmlPanel;
-import com.keenwrite.preview.HtmlPreview;
-import com.keenwrite.processors.Processor;
-import com.keenwrite.processors.ProcessorContext;
-import com.keenwrite.processors.ProcessorFactory;
-import com.keenwrite.processors.markdown.extensions.CaretExtension;
-import com.keenwrite.service.events.Notifier;
-import com.keenwrite.sigils.RSigilOperator;
-import com.keenwrite.sigils.SigilOperator;
-import com.keenwrite.sigils.Tokens;
-import com.keenwrite.sigils.YamlSigilOperator;
-import com.keenwrite.ui.explorer.FilePickerFactory;
-import com.keenwrite.ui.heuristics.DocumentStatistics;
-import com.keenwrite.ui.outline.DocumentOutline;
-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.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 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 = getActiveTextEditor();
- 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 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 factory = new FilePickerFactory( mWorkspace );
- 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();
- }
-
- /**
- * 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() );
+import com.keenwrite.events.*;
+import com.keenwrite.io.MediaType;
+import com.keenwrite.preferences.Key;
+import com.keenwrite.preferences.Workspace;
+import com.keenwrite.preview.HtmlPanel;
+import com.keenwrite.preview.HtmlPreview;
+import com.keenwrite.processors.Processor;
+import com.keenwrite.processors.ProcessorContext;
+import com.keenwrite.processors.ProcessorFactory;
+import com.keenwrite.processors.markdown.extensions.CaretExtension;
+import com.keenwrite.service.events.Notifier;
+import com.keenwrite.sigils.RSigilOperator;
+import com.keenwrite.sigils.SigilOperator;
+import com.keenwrite.sigils.Tokens;
+import com.keenwrite.sigils.YamlSigilOperator;
+import com.keenwrite.ui.explorer.FilePickerFactory;
+import com.keenwrite.ui.heuristics.DocumentStatistics;
+import com.keenwrite.ui.outline.DocumentOutline;
+import com.panemu.tiwulfx.control.dock.DetachableTab;
+import com.panemu.tiwulfx.control.dock.DetachableTabPane;
+import javafx.application.Application;
+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.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.constants.GraphicsConstants.ICON_DIALOG_NODE;
+import static com.keenwrite.events.Bus.register;
+import static com.keenwrite.events.HyperlinkOpenEvent.fireHyperlinkOpenEvent;
+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.awt.Desktop.Action.BROWSE;
+import static java.awt.Desktop.getDesktop;
+import static java.lang.String.format;
+import static java.lang.System.getProperty;
+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.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 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();
+ } );
+ }
+
+ @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( () -> fireHyperlinkOpenEvent( url ) );
+ } );
+
+ alert.showAndWait();
+ }
+
+ /**
+ * 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 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 = getActiveTextEditor();
+ 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 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 factory = new FilePickerFactory( mWorkspace );
+ 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();
+ }
+
+ /**
+ * 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() {
+ try {
+ final var p = mProcessors.getOrDefault( editor, IDENTITY );
+ p.apply( editor == null ? "" : editor.getText() );
+ } catch( final Exception ex ) {
+ clue( ex );
+ }
+
return null;
}
src/main/java/com/keenwrite/events/ExportFailedEvent.java
+/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
+package com.keenwrite.events;
+
+/**
+ * Responsible for kicking off an alert message when exporting (e.g., to PDF)
+ * fails. This can happen when the executable to typeset the document cannot
+ * be found.
+ */
+public class ExportFailedEvent implements AppEvent {
+ public static void fireExportFailedEvent() {
+ new ExportFailedEvent().fire();
+ }
+}
src/main/java/com/keenwrite/events/HyperlinkOpenEvent.java
+/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
+package com.keenwrite.events;
+
+import java.io.IOException;
+import java.net.URI;
+
+import static com.keenwrite.events.StatusEvent.clue;
+
+/**
+ * Collates information about a URL requested to be opened.
+ */
+public class HyperlinkOpenEvent implements AppEvent {
+ private final URI mUri;
+
+ private HyperlinkOpenEvent( final URI uri ) {
+ mUri = uri;
+ }
+
+ /**
+ * Requests to open the default browser at the given location.
+ *
+ * @param uri The location to open.
+ */
+ public static void fireHyperlinkOpenEvent( final URI uri )
+ throws IOException {
+ new HyperlinkOpenEvent( uri ).fire();
+ }
+
+ /**
+ * Requests to open the default browser at the given location.
+ *
+ * @param uri The location to open.
+ */
+ public static void fireHyperlinkOpenEvent( final String uri ) {
+ try {
+ fireHyperlinkOpenEvent( new URI( uri ) );
+ } catch( final Exception ex ) {
+ clue( ex );
+ }
+ }
+
+ /**
+ * Returns the requested resource to be opened.
+ *
+ * @return A reference that can be opened in a web browser.
+ */
+ public URI getUri() {
+ return mUri;
+ }
+}
src/main/java/com/keenwrite/events/StatusEvent.java
*/
private static String toEnglish( Throwable problem ) {
- if( problem instanceof RuntimeException &&
+ assert problem != null;
+
+ // Subclasses of RuntimeException must be subject to Englishification.
+ if( problem.getClass().equals( RuntimeException.class ) &&
(problem = problem.getCause()) == null ) {
return "";
*/
public static void clue( final Throwable problem ) {
- fireStatusEvent( problem.getMessage(), problem );
+ fireStatusEvent( "", problem );
}
src/main/java/com/keenwrite/preview/HtmlPanel.java
import java.net.URI;
-import static com.keenwrite.events.FileOpenEvent.fireFileOpenEvent;
import static com.keenwrite.events.DocumentChangedEvent.fireDocumentChangedEvent;
+import static com.keenwrite.events.FileOpenEvent.fireFileOpenEvent;
+import static com.keenwrite.events.HyperlinkOpenEvent.fireHyperlinkOpenEvent;
import static com.keenwrite.events.StatusEvent.clue;
import static com.keenwrite.util.ProtocolScheme.getProtocol;
-import static java.awt.Desktop.Action.BROWSE;
-import static java.awt.Desktop.getDesktop;
import static java.lang.Boolean.FALSE;
import static java.lang.Boolean.TRUE;
private static final class HyperlinkListener extends LinkListener {
@Override
- public void linkClicked( final BasicPanel panel, final String uri ) {
+ public void linkClicked( final BasicPanel panel, final String link ) {
try {
- switch( getProtocol( uri ) ) {
- case HTTP -> {
- final var desktop = getDesktop();
+ final var uri = new URI( link );
- if( desktop.isSupported( BROWSE ) ) {
- desktop.browse( new URI( uri ) );
- }
- }
- case FILE -> fireFileOpenEvent( new URI( uri ) );
+ switch( getProtocol( uri ) ) {
+ case HTTP -> fireHyperlinkOpenEvent( uri );
+ case FILE -> fireFileOpenEvent( uri );
}
} catch( final Exception ex ) {
src/main/java/com/keenwrite/processors/ExecutorProcessor.java
import java.util.concurrent.atomic.AtomicReference;
-import static com.keenwrite.events.StatusEvent.clue;
-
/**
* Responsible for transforming data through a variety of chained handlers.
while( handler.isPresent() ) {
handler = handler.flatMap( p -> {
- try {
- result.set( p.apply( result.get() ) );
- } catch( final Exception ex ) {
- clue( ex );
- }
-
+ result.set( p.apply( result.get() ) );
return p.next();
} );
src/main/java/com/keenwrite/processors/PdfProcessor.java
import com.keenwrite.typesetting.Typesetter;
+import java.io.IOException;
+
import static com.keenwrite.Bootstrap.APP_TITLE_LOWERCASE;
import static com.keenwrite.Messages.get;
typesetter.typeset( pathInput, pathOutput );
- } catch( final Exception ex ) {
+ } catch( final IOException | InterruptedException ex ) {
+ // Typesetter runtime exceptions will pass up the call stack.
clue( get( "Main.status.typeset.failed" ), ex );
}
src/main/java/com/keenwrite/typesetting/Typesetter.java
}
+ public static boolean canRun() {
+ return TYPESETTER.canRun();
+ }
+
/**
- * This will typeset the document using a new process.
+ * This will typeset the document using a new process. The return value only
+ * indicates whether the typesetter exists, not whether the typesetting was
+ * successful.
*
* @param in The input document to typeset.
* @param out Path to the finished typeset document.
- * @throws IOException If the process could not be started.
- * @throws InterruptedException If the process was killed.
+ * @throws IOException If the process could not be started.
+ * @throws InterruptedException If the process was killed.
+ * @throws TypesetterNotFoundException When no typesetter is along the PATH.
*/
public void typeset( final Path in, final Path out )
- throws IOException, InterruptedException {
+ throws IOException, InterruptedException, TypesetterNotFoundException {
if( TYPESETTER.canRun() ) {
clue( get( "Main.status.typeset.began", out ) );
out, since( time ) )
);
+ }
+ else {
+ throw new TypesetterNotFoundException( TYPESETTER.toString() );
}
}
final var paths = getProperty( KEY_TYPESET_CONTEXT_PATH );
final var envs = getProperty( KEY_TYPESET_CONTEXT_ENV );
- final var exists = !empty( getCacheDir().toPath() );
+ final var cacheExists = !isEmpty( getCacheDir().toPath() );
// Ensure invoking multiple times will load the correct arguments.
mArgs.clear();
mArgs.add( TYPESETTER.getName() );
- if( exists ) {
+ if( cacheExists ) {
mArgs.add( "--autogenerate" );
mArgs.add( "--script" );
}
- return exists;
+ return cacheExists;
}
* @return {@code true} if the directory is empty.
*/
- private boolean empty( final Path path ) {
+ private boolean isEmpty( final Path path ) {
try( final var stream = newDirectoryStream( path ) ) {
return !stream.iterator().hasNext();
clue( get(
"Main.status.typeset.page",
- pageCount, pageTotal == 0 ? "?" : pageTotal, passCount
+ pageCount, pageTotal < 1 ? "?" : pageTotal, passCount
) );
}
src/main/java/com/keenwrite/typesetting/TypesetterNotFoundException.java
+/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
+package com.keenwrite.typesetting;
+
+/**
+ * Responsible for creating an alternate execution path when a typesetter
+ * cannot be found.
+ */
+public class TypesetterNotFoundException extends RuntimeException {
+ /**
+ * Constructs a new exception that indicates the typesetting engine cannot
+ * be found anywhere along the PATH.
+ *
+ * @param name Typesetter executable file name.
+ */
+ public TypesetterNotFoundException( final String name ) {
+ super( name );
+ }
+}
src/main/java/com/keenwrite/ui/actions/ApplicationActions.java
import com.keenwrite.editors.markdown.HyperlinkModel;
import com.keenwrite.editors.markdown.LinkVisitor;
+import com.keenwrite.events.ExportFailedEvent;
import com.keenwrite.preferences.PreferencesController;
import com.keenwrite.preferences.Workspace;
import com.keenwrite.processors.markdown.MarkdownProcessor;
import com.keenwrite.search.SearchModel;
+import com.keenwrite.typesetting.Typesetter;
import com.keenwrite.ui.controls.SearchBar;
import com.keenwrite.ui.dialogs.ImageDialog;
import static java.nio.file.Files.writeString;
import static java.util.concurrent.Executors.newFixedThreadPool;
+import static javafx.application.Platform.runLater;
import static javafx.event.Event.fireEvent;
import static javafx.scene.control.Alert.AlertType.INFORMATION;
);
- task.setOnFailed( e -> clue( task.getException() ) );
+ task.setOnFailed( e -> {
+ clue( task.getException() );
+ fireExportFailedEvent();
+ } );
sExecutor.execute( task );
} );
}
public void file‿export‿pdf() {
- file‿export( APPLICATION_PDF );
+ if( Typesetter.canRun() ) {
+ file‿export( APPLICATION_PDF );
+ }
+ else {
+ fireExportFailedEvent();
+ }
}
public void file‿export‿markdown() {
file‿export( MARKDOWN_PLAIN );
+ }
+
+ private void fireExportFailedEvent() {
+ runLater( ExportFailedEvent::fireExportFailedEvent );
}
src/main/java/com/keenwrite/util/ProtocolScheme.java
import java.io.File;
+import java.net.MalformedURLException;
import java.net.URI;
import java.net.URL;
* Returns the protocol for a given URI or file name.
*
- * @param resource Determine the protocol for this URI or file name.
+ * @param uri Determine the protocol for this URI or file name.
* @return The protocol for the given resource.
*/
- public static ProtocolScheme getProtocol( final String resource ) {
+ public static ProtocolScheme getProtocol( final String uri ) {
try {
- final var uri = new URI( resource );
- return uri.isAbsolute()
- ? valueFrom( uri )
- : valueFrom( new URL( resource ) );
+ return getProtocol( new URI( uri ) );
} catch( final Exception ex ) {
// Using double-slashes is a short-hand to instruct the browser to
// reference a resource using the parent URL's security model. This
// is known as a protocol-relative URL.
- return resource.startsWith( "//" )
- ? HTTP
- : valueFrom( new File( resource ) );
+ return uri.startsWith( "//" ) ? HTTP : valueFrom( new File( uri ) );
}
+ }
+
+ /**
+ * Returns the protocol for a given URI or file name.
+ *
+ * @param uri Determine the protocol for this URI or file name.
+ * @return The protocol for the given resource.
+ */
+ public static ProtocolScheme getProtocol( final URI uri )
+ throws MalformedURLException {
+ return uri.isAbsolute()
+ ? valueFrom( uri )
+ : valueFrom( uri.toURL() );
}
src/main/resources/com/keenwrite/messages.properties
Main.status.image.request.error.cert=Could not accept certificate for ''{0}''
+Main.status.font.search.missing=No font name starting with ''{0}'' was found
+
Main.status.typeset.create=Creating typesetter
Main.status.typeset.xhtml=Export document as XHTML
Main.status.typeset.began=Started typesetting ''{0}''
Main.status.typeset.failed=Could not generate PDF file
Main.status.typeset.page=Typesetting page {0} of {1} (pass {2})
Main.status.typeset.ended.success=Finished typesetting ''{0}'' ({1} elapsed)
Main.status.typeset.ended.failure=Failed to typeset ''{0}'' ({1} elapsed)
-
-Main.status.font.search.missing=No font name starting with ''{0}'' was found
# ########################################################################
Alert.file.close.title=Close
Alert.file.close.text=Save changes to {0}?
+
+# ########################################################################
+# Typesetting Alert Dialog
+# ########################################################################
+
+Alert.typesetter.missing.title=Missing Typesetter
+Alert.typesetter.missing.header=Install typesetter
+Alert.typesetter.missing.version=for {0} {1} {2}-bit
+Alert.typesetter.missing.installer.text=Download and install ConTeXt
+Alert.typesetter.missing.installer.url=https://wiki.contextgarden.net/Installation
# ########################################################################
Delta 970 lines added, 746 lines removed, 224-line increase