Dave Jarvis' Repositories

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

Replace buggy JavaFX FileChooser with new widget

AuthorDaveJarvis <email>
Date2021-04-08 23:45:38 GMT-0700
Commitb76f233b038e057b593efedbd0e53f32ef1f9208
Parent4520ee8
build.gradle
exclude group: 'org.openjfx'
}
+ implementation 'com.io7m.jwheatsheaf:com.io7m.jwheatsheaf:2.0.1'
+ implementation 'com.io7m.jwheatsheaf:com.io7m.jwheatsheaf.api:2.0.1'
+ implementation 'com.io7m.jwheatsheaf:com.io7m.jwheatsheaf.ui:2.0.1'
// Markdown
src/main/java/com/keenwrite/MainPane.java
import com.keenwrite.sigils.Tokens;
import com.keenwrite.sigils.YamlSigilOperator;
-import com.keenwrite.ui.explorer.FilesView;
-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 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 );
+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 ) {
src/main/java/com/keenwrite/ui/actions/Action.java
}
- /**
- * Runs this action. Most actions are mapped to menu items, but some actions
- * (such as the Insert key to toggle overwrite mode) are not.
- */
- public void execute() {
- mHandler.handle( new ActionEvent() );
- }
-
@Override
public MenuItem createMenuItem() {
src/main/java/com/keenwrite/ui/actions/ApplicationActions.java
import com.keenwrite.ui.dialogs.ImageDialog;
import com.keenwrite.ui.dialogs.LinkDialog;
-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.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;
-import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST;
-
-/**
- * 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.
- */
-@SuppressWarnings( "NonAsciiCharacters" )
-public final class ApplicationActions {
- private static final ExecutorService sExecutor = newFixedThreadPool( 1 );
-
- private static final String STYLE_SEARCH = "search";
-
- /**
- * 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 ApplicationActions( 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() {
- getMainPane().open( createFileChooser().openFiles() );
- }
-
- public void file‿close() {
- getMainPane().close();
- }
-
- public void file‿close_all() {
- getMainPane().closeAll();
- }
-
- public void file‿save() {
- getMainPane().save();
- }
-
- public void file‿save_as() {
- final var file = createFileChooser().saveAs();
- file.ifPresent( ( f ) -> getMainPane().saveAs( f ) );
- }
-
- public void file‿save_all() {
- getMainPane().saveAll();
- }
-
- private void file‿export( final ExportFormat format ) {
- final var main = getMainPane();
- final var editor = main.getActiveTextEditor();
- final var filename = format.toExportFilename( editor.getPath() );
- final var selection = createFileChooser().exportAs( filename );
-
- selection.ifPresent( ( file ) -> {
- final var path = file.toPath();
- final var document = 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 -> {
- final var result = task.getValue();
-
- // Binary formats must notify users of success independently.
- if( result != null ) {
- clue( get( "Main.status.export.success", result ) );
- }
- }
- );
-
- task.setOnFailed( e -> clue( task.getException() ) );
-
- sExecutor.execute( task );
- } );
- }
-
- public void file‿export‿pdf() {
- file‿export( APPLICATION_PDF );
- }
-
- 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 );
- }
-
- 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() {
- new PreferencesController( getWorkspace() ).show();
- }
-
- public void format‿bold() {
- getActiveTextEditor().bold();
- }
-
- public void format‿italic() {
- getActiveTextEditor().italic();
- }
-
- 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‿issues() {
- 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();
- }
-
- private FileChooserCommand createFileChooser() {
- final var dir = getWorkspace().fileProperty( KEY_UI_RECENT_DIR );
- return new FileChooserCommand( getWindow(), dir );
+import com.keenwrite.ui.explorer.FilePicker;
+import com.keenwrite.ui.explorer.FilePickerFactory;
+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.io.File;
+import java.nio.file.Path;
+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.processors.ProcessorFactory.createProcessors;
+import static com.keenwrite.ui.explorer.FilePickerFactory.Options;
+import static com.keenwrite.ui.explorer.FilePickerFactory.Options.*;
+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;
+import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST;
+
+/**
+ * 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.
+ */
+@SuppressWarnings( "NonAsciiCharacters" )
+public final class ApplicationActions {
+ private static final ExecutorService sExecutor = newFixedThreadPool( 1 );
+
+ private static final String STYLE_SEARCH = "search";
+
+ /**
+ * 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 ApplicationActions( 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();
+ }
+
+ private void file‿export( final ExportFormat format ) {
+ final var main = getMainPane();
+ final var editor = main.getActiveTextEditor();
+ final var filename = format.toExportFilename( editor.getPath() );
+ final var selection = pickFiles( filename, FILE_EXPORT );
+
+ selection.ifPresent( ( files ) -> {
+ final var file = files.get(0);
+ final var path = file.toPath();
+ final var document = 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 -> {
+ final var result = task.getValue();
+
+ // Binary formats must notify users of success independently.
+ if( result != null ) {
+ clue( get( "Main.status.export.success", result ) );
+ }
+ }
+ );
+
+ task.setOnFailed( e -> clue( task.getException() ) );
+
+ sExecutor.execute( task );
+ } );
+ }
+
+ public void file‿export‿pdf() {
+ file‿export( APPLICATION_PDF );
+ }
+
+ 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 );
+ }
+
+ 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() {
+ new PreferencesController( getWorkspace() ).show();
+ }
+
+ public void format‿bold() {
+ getActiveTextEditor().bold();
+ }
+
+ public void format‿italic() {
+ getActiveTextEditor().italic();
+ }
+
+ 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‿issues() {
+ 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();
+ }
+
+ 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 );
}
src/main/java/com/keenwrite/ui/explorer/FilePicker.java
+/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
+package com.keenwrite.ui.explorer;
+
+import java.io.File;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Responsible for providing the user with a way to select a file.
+ */
+public interface FilePicker {
+
+ /**
+ * Establishes the default file name to use when the UI is displayed. The
+ * path portion of the file, if any, is ignored.
+ *
+ * @param file The initial {@link File} to choose when prompting the user
+ * to select a file.
+ */
+ default void setInitialFilename( File file ) {}
+
+ /**
+ * Establishes the directory to browse when the UI is displayed.
+ *
+ * @param path The initial {@link Path} to use when navigating the system.
+ */
+ default void setInitialDirectory( Path path ) {}
+
+ /**
+ * Sets the list of file names to display. For example, a single call to
+ * this method with values of ("**.pdf", "Portable Document Format (PDF)")
+ * would display only a file listing of PDF files.
+ *
+ * @param glob Pattern that allows matching file names to be listed.
+ * @param text Human-readable description of the pattern.
+ */
+ default void addIncludeFileFilter( String glob, String text ) {}
+
+ /**
+ * Sets the list of file names to suppress. For example, a single call to
+ * this method with values of (".*") would prevent listing files that begin
+ * with a period.
+ *
+ * @param glob Pattern that allows matching file names to be suppressed.
+ */
+ default void addExcludeFileFilter( String glob ) {}
+
+ /**
+ * Returns the list of {@link File} objects selected by the user.
+ *
+ * @return A list of {@link File} objects, empty when nothing was selected.
+ */
+ Optional<List<File>> choose();
+}
src/main/java/com/keenwrite/ui/explorer/FilePickerFactory.java
+/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
+package com.keenwrite.ui.explorer;
+
+import com.io7m.jwheatsheaf.api.JWFileChoosersType;
+import com.io7m.jwheatsheaf.ui.JWFileChoosers;
+import com.keenwrite.preferences.Workspace;
+import javafx.beans.property.ObjectProperty;
+import javafx.scene.Node;
+import javafx.stage.FileChooser;
+import javafx.stage.Window;
+
+import java.io.File;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.Optional;
+
+import static com.io7m.jwheatsheaf.api.JWFileChooserAction.*;
+import static com.io7m.jwheatsheaf.api.JWFileChooserConfiguration.Builder;
+import static com.io7m.jwheatsheaf.api.JWFileChooserConfiguration.builder;
+import static com.keenwrite.constants.Constants.USER_DIRECTORY;
+import static com.keenwrite.preferences.WorkspaceKeys.KEY_UI_RECENT_DIR;
+import static java.nio.file.FileSystems.getDefault;
+import static java.util.Optional.ofNullable;
+
+/**
+ * Shim for a {@link FilePicker} instance that is implemented in pure Java.
+ * This particular picker is added to avoid using the bug-ridden JavaFX
+ * {@link FileChooser} that invokes the native file chooser.
+ */
+public class FilePickerFactory {
+ public enum Options {
+ DIRECTORY_OPEN,
+ FILE_IMPORT,
+ FILE_EXPORT,
+ FILE_OPEN_SINGLE,
+ FILE_OPEN_MULTIPLE,
+ FILE_OPEN_NEW,
+ FILE_SAVE_AS,
+ PERMIT_CREATE_DIRS,
+ }
+
+ private final ObjectProperty<File> mDirectory;
+ private final Locale mLocale;
+
+ public FilePickerFactory( final Workspace workspace ) {
+ mDirectory = workspace.fileProperty( KEY_UI_RECENT_DIR );
+ mLocale = workspace.getLocale();
+ }
+
+ public FilePicker createModal(
+ final Window owner, final Options... options ) {
+ final var picker = new PureFilePicker( owner, options );
+ picker.setInitialDirectory( mDirectory.get().toPath() );
+
+ return picker;
+ }
+
+ public Node createModeless() {
+ return new FilesView( mDirectory, mLocale );
+ }
+
+ /**
+ * Pure Java implementation of a file selection widget.
+ */
+ private class PureFilePicker implements FilePicker {
+ private final Window mParent;
+ private final JWFileChoosersType mChooserType = JWFileChoosers.create();
+ private final Builder mBuilder;
+
+ private PureFilePicker( final Window window, final Options... options ) {
+ mParent = window;
+ mBuilder = builder().setFileSystem( getDefault() );
+
+ final var args = ofNullable( options ).orElse( options );
+
+ var title = "Dialog.file.choose.open.title";
+ var action = OPEN_EXISTING_SINGLE;
+
+ // It is a programming error to provide options that save or export to
+ // multiple files.
+ for( final var arg : args ) {
+ switch( arg ) {
+ case FILE_EXPORT -> title = "Dialog.file.choose.export.title";
+ case FILE_SAVE_AS -> title = "Dialog.file.choose.save.title";
+ case FILE_OPEN_MULTIPLE -> action = OPEN_EXISTING_MULTIPLE;
+ case FILE_OPEN_NEW -> action = CREATE;
+ case PERMIT_CREATE_DIRS -> mBuilder.setAllowDirectoryCreation( true );
+ }
+ }
+
+ //mBuilder.setTitle( get(title) );
+ mBuilder.setAction( action );
+ }
+
+ @Override
+ public void setInitialDirectory( final Path path ) {
+ mBuilder.setInitialDirectory( path );
+ }
+
+ @Override
+ public Optional<List<File>> choose() {
+ final var config = mBuilder.build();
+ final var chooser = mChooserType.create( mParent, config );
+ final var paths = chooser.showAndWait();
+ final var files = new ArrayList<File>( paths.size() );
+ paths.forEach( path -> {
+ final var file = path.toFile();
+ files.add( file );
+
+ // Set to the directory of the last file opened successfully.
+ setRecentDirectory( file );
+ } );
+
+ return files.isEmpty() ? Optional.empty() : Optional.of( files );
+ }
+ }
+
+ /**
+ * Sets the value for the most recent directly selected. This will get the
+ * parent location from the given file. If the parent is a readable directory
+ * then this will update the most recent directory property.
+ *
+ * @param file A file contained in a directory.
+ */
+ private void setRecentDirectory( final File file ) {
+ assert file != null;
+
+ final var parent = file.getParentFile();
+ final var dir = parent == null ? USER_DIRECTORY : parent;
+
+ if( dir.isDirectory() && dir.canRead() ) {
+ mDirectory.setValue( dir );
+ }
+ }
+}
src/main/java/com/keenwrite/ui/explorer/FilesView.java
package com.keenwrite.ui.explorer;
-import com.keenwrite.preferences.Workspace;
import com.keenwrite.ui.controls.BrowseButton;
import javafx.beans.property.*;
import javafx.collections.ObservableList;
import javafx.collections.transformation.SortedList;
import javafx.scene.control.*;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
+import javafx.stage.FileChooser;
import javafx.util.Callback;
import java.io.File;
import java.io.IOException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.Instant;
import java.time.format.DateTimeFormatter;
+import java.util.List;
import java.util.Locale;
+import java.util.Optional;
import static com.keenwrite.constants.Constants.UI_CONTROL_SPACING;
import static com.keenwrite.events.FileOpenEvent.fireFileOpenEvent;
import static com.keenwrite.events.StatusEvent.clue;
-import static com.keenwrite.preferences.WorkspaceKeys.KEY_UI_RECENT_DIR;
import static com.keenwrite.ui.fonts.IconFactory.createFileIcon;
import static java.nio.file.Files.size;
* Responsible for browsing files.
*/
-public class FilesView extends BorderPane {
+public class FilesView extends BorderPane implements FilePicker {
/**
* When this directory changes, the input field will update accordingly.
* restored upon restart.
*
- * @param workspace Contains the initial (recent) directory and locale.
+ * @param recent Contains the initial (recent) directory.
+ * @param locale Contains the language settings.
*/
- public FilesView( final Workspace workspace ) {
- assert workspace != null;
-
- mDirectory = workspace.fileProperty( KEY_UI_RECENT_DIR );
-
- final var locale = workspace.getLocale();
+ public FilesView(
+ final ObjectProperty<File> recent, final Locale locale ) {
+ mDirectory = recent;
mDateFormatter = createFormatter( "yyyy-MMM-dd", locale );
mTimeFormatter = createFormatter( "HH:mm:ss", locale );
mDirectory.addListener( ( c, o, n ) -> updateListing( n ) );
updateListing( mDirectory.get() );
+ }
+
+ @Override
+ public Optional<List<File>> choose() {
+ return Optional.empty();
}
}
+ /**
+ * Allows the user to use an instance of {@link FileChooser} to change the
+ * directory.
+ *
+ * @return The browse button and input field.
+ */
private HBox createDirectoryChooser() {
final var dirProperty = directoryProperty();
Delta1154 lines added, 936 lines removed, 218-line increase