Dave Jarvis' Repositories

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

Start splitting GUI actions into reusable CLI actions

AuthorDaveJarvis <email>
Date2021-12-12 00:36:05 GMT-0800
Commit48b6d4c98138a1c57174b269abc35983dc7ea9e2
Parent8087835
Delta637 lines added, 6 lines removed, 631-line increase
src/main/java/com/keenwrite/ui/dialogs/ThemePicker.java
private boolean pick() {
try {
- // List themes in alphabetical order (human readable by directory name).
+ // List themes in alphabetical order (human-readable by directory name).
final var choices = new TreeMap<String, String>();
final String[] selection = new String[]{""};
src/main/java/com/keenwrite/ui/actions/ApplicationBars.java
* Creates the main application affordances.
*
- * @param actions The {@link ApplicationActions} that map user interface
+ * @param actions The {@link GuiCommands} that map user interface
* selections to executable code.
* @return An instance of {@link MenuBar} that contains the menu.
*/
- public static MenuBar createMenuBar( final ApplicationActions actions ) {
+ public static MenuBar createMenuBar( final GuiCommands actions ) {
final var SEPARATOR_ACTION = new SeparatorAction();
src/main/java/com/keenwrite/ui/actions/GuiCommands.java
+/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
+package com.keenwrite.ui.actions;
+
+import com.keenwrite.ExportFormat;
+import com.keenwrite.MainPane;
+import com.keenwrite.MainScene;
+import com.keenwrite.constants.Constants;
+import com.keenwrite.editors.TextDefinition;
+import com.keenwrite.editors.TextEditor;
+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 com.keenwrite.ui.dialogs.LinkDialog;
+import com.keenwrite.ui.dialogs.ThemePicker;
+import com.keenwrite.ui.explorer.FilePicker;
+import com.keenwrite.ui.explorer.FilePickerFactory;
+import com.keenwrite.ui.logging.LogView;
+import com.keenwrite.util.AlphanumComparator;
+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.io.File;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.ExecutorService;
+
+import static com.keenwrite.Bootstrap.*;
+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.*;
+import static com.keenwrite.processors.ProcessorFactory.createProcessors;
+import static com.keenwrite.ui.explorer.FilePickerFactory.Options;
+import static com.keenwrite.ui.explorer.FilePickerFactory.Options.*;
+import static com.keenwrite.util.FileWalker.walk;
+import static java.nio.file.Files.readString;
+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;
+import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST;
+import static org.apache.commons.io.FilenameUtils.getExtension;
+
+/**
+ * Responsible for abstracting how functionality is mapped to the application.
+ * This allows users to customize accelerator keys and will provide pluggable
+ * functionality so that different text markup languages can change documents
+ * using their respective syntax.
+ */
+public final class GuiCommands {
+ private static final ExecutorService sExecutor = newFixedThreadPool( 1 );
+
+ private static final String STYLE_SEARCH = "search";
+
+ /**
+ * Sci-fi genres, which are can be longer than other genres, typically fall
+ * below 150,000 words at 6 chars per word. This reduces re-allocations of
+ * memory when concatenating files together when exporting novels.
+ */
+ private static final int DOCUMENT_LENGTH = 150_000 * 6;
+
+ /**
+ * When an action is executed, this is one of the recipients.
+ */
+ private final MainPane mMainPane;
+
+ private final MainScene mMainScene;
+
+ private final LogView mLogView;
+
+ /**
+ * Tracks finding text in the active document.
+ */
+ private final SearchModel mSearchModel;
+
+ public GuiCommands( final MainScene scene, final MainPane pane ) {
+ mMainScene = scene;
+ mMainPane = pane;
+ mLogView = new LogView();
+ mSearchModel = new SearchModel();
+ mSearchModel.matchOffsetProperty().addListener( ( c, o, n ) -> {
+ final var editor = getActiveTextEditor();
+
+ // Clear highlighted areas before highlighting a new region.
+ if( o != null ) {
+ editor.unstylize( STYLE_SEARCH );
+ }
+
+ if( n != null ) {
+ editor.moveTo( n.getStart() );
+ editor.stylize( n, STYLE_SEARCH );
+ }
+ } );
+
+ // When the active text editor changes, update the haystack.
+ mMainPane.activeTextEditorProperty().addListener(
+ ( c, o, n ) -> mSearchModel.search( getActiveTextEditor().getText() )
+ );
+ }
+
+ public void file_new() {
+ getMainPane().newTextEditor();
+ }
+
+ public void file_open() {
+ pickFiles( FILE_OPEN_MULTIPLE ).ifPresent( l -> getMainPane().open( l ) );
+ }
+
+ public void file_close() {
+ getMainPane().close();
+ }
+
+ public void file_close_all() {
+ getMainPane().closeAll();
+ }
+
+ public void file_save() {
+ getMainPane().save();
+ }
+
+ public void file_save_as() {
+ pickFiles( FILE_SAVE_AS ).ifPresent( l -> getMainPane().saveAs( l ) );
+ }
+
+ public void file_save_all() {
+ getMainPane().saveAll();
+ }
+
+ /**
+ * Converts the actively edited file in the given file format.
+ *
+ * @param format The destination file format.
+ */
+ private void file_export( final ExportFormat format ) {
+ file_export( format, false );
+ }
+
+ /**
+ * Converts one or more files into the given file format. If {@code dir}
+ * is set to true, this will first append all files in the same directory
+ * as the actively edited file.
+ *
+ * @param format The destination file format.
+ * @param dir Export all files in the actively edited file's directory.
+ */
+ private void file_export( final ExportFormat format, final boolean dir ) {
+ final var main = getMainPane();
+ final var editor = main.getActiveTextEditor();
+ final var exported = getWorkspace().fileProperty( KEY_UI_RECENT_EXPORT );
+ final var filename = format.toExportFilename( editor.getPath() );
+ final var selection = pickFiles(
+ Constants.PDF_DEFAULT.getName().equals( exported.get().getName() )
+ ? filename
+ : exported.get(), FILE_EXPORT
+ );
+
+ selection.ifPresent( ( files ) -> {
+ editor.save();
+
+ final var file = files.get( 0 );
+ final var path = file.toPath();
+ final var document = dir ? append( editor ) : editor.getText();
+ final var context = main.createProcessorContext( path, format );
+
+ final var task = new Task<Path>() {
+ @Override
+ protected Path call() throws Exception {
+ final var chain = createProcessors( context );
+ final var export = chain.apply( document );
+
+ // 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 -> {
+ // Remember the exported file name for next time.
+ exported.setValue( file );
+
+ final var result = task.getValue();
+
+ // Binary formats must notify users of success independently.
+ if( result != null ) {
+ clue( "Main.status.export.success", result );
+ }
+ }
+ );
+
+ task.setOnFailed( e -> {
+ final var ex = task.getException();
+ clue( ex );
+
+ if( ex instanceof TypeNotPresentException ) {
+ fireExportFailedEvent();
+ }
+ } );
+
+ sExecutor.execute( task );
+ } );
+ }
+
+ /**
+ * @param dir {@code true} means to export all files in the active file
+ * editor's directory; {@code false} means to export only the
+ * actively edited file.
+ */
+ private void file_export_pdf( final boolean dir ) {
+ final var workspace = getWorkspace();
+ final var themes = workspace.toFile( KEY_TYPESET_CONTEXT_THEMES_PATH );
+ final var theme = workspace.stringProperty(
+ KEY_TYPESET_CONTEXT_THEME_SELECTION );
+
+ if( Typesetter.canRun() ) {
+ // If the typesetter is installed, allow the user to select a theme. If
+ // the themes aren't installed, a status message will appear.
+ if( ThemePicker.choose( themes, theme ) ) {
+ file_export( APPLICATION_PDF, dir );
+ }
+ }
+ else {
+ fireExportFailedEvent();
+ }
+ }
+
+ public void file_export_pdf() {
+ file_export_pdf( false );
+ }
+
+ public void file_export_pdf_dir() {
+ file_export_pdf( true );
+ }
+
+ public void file_export_html_svg() {
+ file_export( HTML_TEX_SVG );
+ }
+
+ public void file_export_html_tex() {
+ file_export( HTML_TEX_DELIMITED );
+ }
+
+ public void file_export_xhtml_tex() {
+ file_export( XHTML_TEX );
+ }
+
+ public void file_export_markdown() {
+ file_export( MARKDOWN_PLAIN );
+ }
+
+ private void fireExportFailedEvent() {
+ runLater( ExportFailedEvent::fire );
+ }
+
+ public void file_exit() {
+ final var window = getWindow();
+ fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) );
+ }
+
+ public void edit_undo() {
+ getActiveTextEditor().undo();
+ }
+
+ public void edit_redo() {
+ getActiveTextEditor().redo();
+ }
+
+ public void edit_cut() {
+ getActiveTextEditor().cut();
+ }
+
+ public void edit_copy() {
+ getActiveTextEditor().copy();
+ }
+
+ public void edit_paste() {
+ getActiveTextEditor().paste();
+ }
+
+ public void edit_select_all() {
+ getActiveTextEditor().selectAll();
+ }
+
+ public void edit_find() {
+ final var nodes = getMainScene().getStatusBar().getLeftItems();
+
+ if( nodes.isEmpty() ) {
+ final var searchBar = new SearchBar();
+
+ searchBar.matchIndexProperty().bind( mSearchModel.matchIndexProperty() );
+ searchBar.matchCountProperty().bind( mSearchModel.matchCountProperty() );
+
+ searchBar.setOnCancelAction( ( event ) -> {
+ final var editor = getActiveTextEditor();
+ nodes.remove( searchBar );
+ editor.unstylize( STYLE_SEARCH );
+ editor.getNode().requestFocus();
+ } );
+
+ searchBar.addInputListener( ( c, o, n ) -> {
+ if( n != null && !n.isEmpty() ) {
+ mSearchModel.search( n, getActiveTextEditor().getText() );
+ }
+ } );
+
+ searchBar.setOnNextAction( ( event ) -> edit_find_next() );
+ searchBar.setOnPrevAction( ( event ) -> edit_find_prev() );
+
+ nodes.add( searchBar );
+ searchBar.requestFocus();
+ }
+ else {
+ nodes.clear();
+ }
+ }
+
+ public void edit_find_next() {
+ mSearchModel.advance();
+ }
+
+ public void edit_find_prev() {
+ mSearchModel.retreat();
+ }
+
+ public void edit_preferences() {
+ try {
+ new PreferencesController( getWorkspace() ).show();
+ } catch( final Exception ex ) {
+ clue( ex );
+ }
+ }
+
+ public void format_bold() {
+ getActiveTextEditor().bold();
+ }
+
+ public void format_italic() {
+ getActiveTextEditor().italic();
+ }
+
+ public void format_monospace() {
+ getActiveTextEditor().monospace();
+ }
+
+ public void format_superscript() {
+ getActiveTextEditor().superscript();
+ }
+
+ public void format_subscript() {
+ getActiveTextEditor().subscript();
+ }
+
+ public void format_strikethrough() {
+ getActiveTextEditor().strikethrough();
+ }
+
+ public void insert_blockquote() {
+ getActiveTextEditor().blockquote();
+ }
+
+ public void insert_code() {
+ getActiveTextEditor().code();
+ }
+
+ public void insert_fenced_code_block() {
+ getActiveTextEditor().fencedCodeBlock();
+ }
+
+ public void insert_link() {
+ insertObject( createLinkDialog() );
+ }
+
+ public void insert_image() {
+ insertObject( createImageDialog() );
+ }
+
+ private void insertObject( final Dialog<String> dialog ) {
+ final var textArea = getActiveTextEditor().getTextArea();
+ dialog.showAndWait().ifPresent( textArea::replaceSelection );
+ }
+
+ private Dialog<String> createLinkDialog() {
+ return new LinkDialog( getWindow(), createHyperlinkModel() );
+ }
+
+ private Dialog<String> createImageDialog() {
+ final var path = getActiveTextEditor().getPath();
+ final var parentDir = path.getParent();
+ return new ImageDialog( getWindow(), parentDir );
+ }
+
+ /**
+ * Returns one of: selected text, word under cursor, or parsed hyperlink from
+ * the Markdown AST.
+ *
+ * @return An instance containing the link URL and display text.
+ */
+ private HyperlinkModel createHyperlinkModel() {
+ final var context = getMainPane().createProcessorContext();
+ final var editor = getActiveTextEditor();
+ final var textArea = editor.getTextArea();
+ final var selectedText = textArea.getSelectedText();
+
+ // Convert current paragraph to Markdown nodes.
+ final var mp = MarkdownProcessor.create( context );
+ final var p = textArea.getCurrentParagraph();
+ final var paragraph = textArea.getText( p );
+ final var node = mp.toNode( paragraph );
+ final var visitor = new LinkVisitor( textArea.getCaretColumn() );
+ final var link = visitor.process( node );
+
+ if( link != null ) {
+ textArea.selectRange( p, link.getStartOffset(), p, link.getEndOffset() );
+ }
+
+ return createHyperlinkModel( link, selectedText );
+ }
+
+ private HyperlinkModel createHyperlinkModel(
+ final Link link, final String selection ) {
+
+ return link == null
+ ? new HyperlinkModel( selection, "https://localhost" )
+ : new HyperlinkModel( link );
+ }
+
+ public void insert_heading_1() {
+ insert_heading( 1 );
+ }
+
+ public void insert_heading_2() {
+ insert_heading( 2 );
+ }
+
+ public void insert_heading_3() {
+ insert_heading( 3 );
+ }
+
+ private void insert_heading( final int level ) {
+ getActiveTextEditor().heading( level );
+ }
+
+ public void insert_unordered_list() {
+ getActiveTextEditor().unorderedList();
+ }
+
+ public void insert_ordered_list() {
+ getActiveTextEditor().orderedList();
+ }
+
+ public void insert_horizontal_rule() {
+ getActiveTextEditor().horizontalRule();
+ }
+
+ public void definition_create() {
+ getActiveTextDefinition().createDefinition();
+ }
+
+ public void definition_rename() {
+ getActiveTextDefinition().renameDefinition();
+ }
+
+ public void definition_delete() {
+ getActiveTextDefinition().deleteDefinitions();
+ }
+
+ public void definition_autoinsert() {
+ getMainPane().autoinsert();
+ }
+
+ public void view_refresh() {
+ getMainPane().viewRefresh();
+ }
+
+ public void view_preview() {
+ getMainPane().viewPreview();
+ }
+
+ public void view_outline() {
+ getMainPane().viewOutline();
+ }
+
+ public void view_files() {getMainPane().viewFiles();}
+
+ public void view_statistics() {
+ getMainPane().viewStatistics();
+ }
+
+ public void view_menubar() {
+ getMainScene().toggleMenuBar();
+ }
+
+ public void view_toolbar() {
+ getMainScene().toggleToolBar();
+ }
+
+ public void view_statusbar() {
+ getMainScene().toggleStatusBar();
+ }
+
+ public void view_log() {
+ mLogView.view();
+ }
+
+ public void help_about() {
+ final var alert = new Alert( INFORMATION );
+ final var prefix = "Dialog.about.";
+ alert.setTitle( get( prefix + "title", APP_TITLE ) );
+ alert.setHeaderText( get( prefix + "header", APP_TITLE ) );
+ alert.setContentText( get( prefix + "content", APP_YEAR, APP_VERSION ) );
+ alert.setGraphic( ICON_DIALOG_NODE );
+ alert.initOwner( getWindow() );
+ alert.showAndWait();
+ }
+
+ /**
+ * Concatenates all the files in the same directory as the given file into
+ * a string. The extension is determined by the given file name pattern; the
+ * order files are concatenated is based on their numeric sort order (this
+ * avoids lexicographic sorting).
+ * <p>
+ * If the parent path to the file being edited in the text editor cannot
+ * be found then this will return the editor's text, without iterating through
+ * the parent directory. (Should never happen, but who knows?)
+ * </p>
+ * <p>
+ * New lines are automatically appended to separate each file.
+ * </p>
+ *
+ * @param editor The text editor containing
+ * @return All files in the same directory as the file being edited
+ * concatenated into a single string.
+ */
+ private String append( final TextEditor editor ) {
+ final var pattern = editor.getPath();
+ final var parent = pattern.getParent();
+
+ // Short-circuit because nothing else can be done.
+ if( parent == null ) {
+ clue( "Main.status.export.concat.parent", pattern );
+ return editor.getText();
+ }
+
+ final var filename = pattern.getFileName().toString();
+ final var extension = getExtension( filename );
+
+ if( extension.isBlank() ) {
+ clue( "Main.status.export.concat.extension", filename );
+ return editor.getText();
+ }
+
+ try {
+ final var glob = "**/*." + extension;
+ final ArrayList<Path> files = new ArrayList<>();
+ walk( parent, glob, files::add );
+ files.sort( new AlphanumComparator<>() );
+
+ final var text = new StringBuilder( DOCUMENT_LENGTH );
+
+ files.forEach( ( file ) -> {
+ try {
+ clue( "Main.status.export.concat", file );
+ text.append( readString( file ) );
+ } catch( final IOException ex ) {
+ clue( "Main.status.export.concat.io", file );
+ }
+ } );
+
+ return text.toString();
+ } catch( final Throwable t ) {
+ clue( t );
+ return editor.getText();
+ }
+ }
+
+ private Optional<List<File>> pickFiles( final Options... options ) {
+ return createPicker( options ).choose();
+ }
+
+ private Optional<List<File>> pickFiles(
+ final File filename, final Options... options ) {
+ final var picker = createPicker( options );
+ picker.setInitialFilename( filename );
+ return picker.choose();
+ }
+
+ private FilePicker createPicker( final Options... options ) {
+ final var factory = new FilePickerFactory( getWorkspace() );
+ return factory.createModal( getWindow(), options );
+ }
+
+ private TextEditor getActiveTextEditor() {
+ return getMainPane().getActiveTextEditor();
+ }
+
+ private TextDefinition getActiveTextDefinition() {
+ return getMainPane().getActiveTextDefinition();
+ }
+
+ private MainScene getMainScene() {
+ return mMainScene;
+ }
+
+ private MainPane getMainPane() {
+ return mMainPane;
+ }
+
+ private Workspace getWorkspace() {
+ return mMainPane.getWorkspace();
+ }
+
+ private Window getWindow() {
+ return getMainPane().getWindow();
+ }
+}
src/main/java/com/keenwrite/MainScene.java
import com.keenwrite.io.FileWatchService;
import com.keenwrite.preferences.Workspace;
-import com.keenwrite.ui.actions.ApplicationActions;
+import com.keenwrite.ui.actions.GuiCommands;
import com.keenwrite.ui.listeners.CaretListener;
import javafx.scene.Node;
}
- private ApplicationActions createApplicationActions(
+ private GuiCommands createApplicationActions(
final MainPane mainPane ) {
- return new ApplicationActions( this, mainPane );
+ return new GuiCommands( this, mainPane );
}