| Author | DaveJarvis <email> |
|---|---|
| Date | 2021-04-08 23:45:38 GMT-0700 |
| Commit | b76f233b038e057b593efedbd0e53f32ef1f9208 |
| Parent | 4520ee8 |
| 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 |
| 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 ) { |
| } | ||
| - /** | ||
| - * 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() { |
| 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 ); | ||
| } | ||
| +/* 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(); | ||
| +} | ||
| +/* 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 ); | ||
| + } | ||
| + } | ||
| +} | ||
| 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(); | ||
| Delta | 1154 lines added, 936 lines removed, 218-line increase |
|---|