Dave Jarvis' Repositories

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

Use mutable set for open files in workspace

AuthorDaveJarvis <email>
Date2020-12-30 18:41:30 GMT-0800
Commit64e1df7836314b13ec31451c2b1db200be9efbca
Parentf5db165
Delta598 lines added, 612 lines removed, 14-line decrease
src/main/resources/com/keenwrite/messages.properties
# ########################################################################
-TextResource.loadFailed.message=Failed to load ''{0}''.\n\nReason: {1}
-TextResource.loadFailed.reason.permissions=File must be readable and writable.
+TextResource.load.error.unsaved=The file ''{0}'' is unsaved or does not exist.
+TextResource.load.error.permissions=The file ''{0}'' must be readable and writable.
# ########################################################################
src/main/java/com/keenwrite/MainPane.java
} )
);
-
- forceRepaint();
- }
-
- /**
- * Force preview pane refresh on Windows.
- */
- private void forceRepaint() {
-// if( IS_OS_WINDOWS ) {
-// splitPane.getDividers().get( 1 ).positionProperty().addListener(
-// ( l, oValue, nValue ) -> runLater(
-// () -> getHtmlPreview().repaintScrollPane()
-// )
-// );
-// }
- }
-
- /**
- * 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 = obtainDetachableTabPane( mediaType );
- final var newTabPane = !getItems().contains( tabPane );
-
- tab.setTooltip( createTooltip( file ) );
- tabPane.setFocusTraversable( false );
- tabPane.setTabClosingPolicy( ALL_TABS );
- tabPane.getTabs().add( tab );
-
- if( newTabPane ) {
- var index = getItems().size();
-
- if( node instanceof TextDefinition ) {
- tabPane.setSceneFactory( mDefinitionTabSceneFactory::create );
- index = 0;
- }
-
- addTabPane( index, 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 node = tab.getContent();
-
- if( node instanceof TextEditor &&
- (closable &= canClose( (TextEditor) node )) ) {
- tabIterator.remove();
- close( tab );
- }
- }
- }
-
- 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(TextEditor)}.
- */
- public void close() {
- final var editor = getActiveTextEditor();
- if( canClose( editor ) ) {
- close( editor );
- }
- }
-
- /**
- * Closes the given {@link TextEditor}. 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 editor The {@link TextEditor} to close, without confirming with
- * the user.
- */
- private void close( final TextEditor editor ) {
- getTab( editor ).ifPresent(
- ( tab ) -> {
- tab.getTabPane().getTabs().remove( tab );
- close( tab );
- }
- );
- }
-
- /**
- * Answers whether the given {@link TextEditor} may be closed.
- *
- * @param editor The {@link TextEditor} 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 TextEditor 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 ) {
- mHtmlPreview.setBaseUri( n.getPath() );
- process( n );
- }
- } );
-
- return editor;
- }
-
- public void viewPreview() {
- final var tabPane = obtainDetachableTabPane( TEXT_HTML );
-
- // Prevent multiple HTML previews because in the end, there can be only one.
- for( final var tab : tabPane.getTabs() ) {
- if( tab.getContent() == mHtmlPreview ) {
- return;
- }
- }
-
- tabPane.addTab( "HTML", mHtmlPreview );
- addTabPane( tabPane );
- }
-
- public void viewRefresh() {
- mHtmlPreview.refresh();
- }
-
- /**
- * Returns the tab that contains the given {@link TextEditor}.
- *
- * @param editor The {@link TextEditor} instance to find amongst the tabs.
- * @return The first tab having content that matches the given tab.
- */
- private Optional<Tab> getTab( final TextEditor editor ) {
- return mTabPanes.values()
- .stream()
- .flatMap( pane -> pane.getTabs().stream() )
- .filter( tab -> editor.equals( tab.getContent() ) )
- .findFirst();
- }
-
- /**
- * Creates a new {@link DefinitionEditor} wrapped in a listener that
- * is used to detect when the active {@link DefinitionEditor} has changed.
- * Upon changing, the {@link #mResolvedMap} is updated and the active
- * text editor is refreshed.
- *
- * @param editor Text editor to update with the revised resolved map.
- * @return A newly configured property that represents the active
- * {@link DefinitionEditor}, never null.
- */
- private ObjectProperty<TextDefinition> createActiveDefinitionEditor(
- final ObjectProperty<TextEditor> editor ) {
- final var definitions = new SimpleObjectProperty<TextDefinition>();
- definitions.addListener( ( c, o, n ) -> {
- resolve( n == null ? createDefinitionEditor() : n );
- process( editor.get() );
- } );
-
- return definitions;
- }
-
- /**
- * Instantiates a factory that's responsible for creating new scenes when
- * a tab is dropped outside of any application window. The definition tabs
- * are fairly complex in that only one may be active at any time. When
- * activated, the {@link #mResolvedMap} must be updated to reflect the
- * hierarchy displayed in the {@link DefinitionEditor}.
- *
- * @param activeDefinitionEditor The current {@link DefinitionEditor}.
- * @return An object that listens to {@link DefinitionEditor} tab focus
- * changes.
- */
- private DefinitionTabSceneFactory createDefinitionTabSceneFactory(
- final ObjectProperty<TextDefinition> activeDefinitionEditor ) {
- return new DefinitionTabSceneFactory( ( tab ) -> {
- assert tab != null;
-
- var node = tab.getContent();
- if( node instanceof TextDefinition ) {
- activeDefinitionEditor.set( (DefinitionEditor) node );
- }
- } );
- }
-
- private DetachableTab createTab( final File file ) {
- final var r = createTextResource( file );
- final var tab = new DetachableTab( r.getFilename(), r.getNode() );
-
- r.modifiedProperty().addListener(
- ( c, o, n ) -> tab.setText( r.getFilename() + (n ? "*" : "") )
- );
-
- // This is called when either the tab is closed by the user clicking on
- // the tab's close icon or when closing (all) from the file menu.
- tab.setOnClosed(
- ( __ ) -> getRecentFiles().remove( file.getAbsolutePath() )
- );
-
- return tab;
- }
-
- /**
- * Creates bins for the different {@link MediaType}s, which eventually are
- * added to the UI as separate tab panes. If ever a general-purpose scene
- * exporter is developed to serialize a scene to an FXML file, this could
- * be replaced by such a class.
- * <p>
- * When binning the files, this makes sure that at least one file exists
- * for every type. If the user has opted to close a particular type (such
- * as the definition pane), the view will suppressed elsewhere.
- * </p>
- * <p>
- * The order that the binned files are returned will be reflected in the
- * order that the corresponding panes are rendered in the UI.
- * </p>
- *
- * @param paths The file paths to bin according to their type.
- * @return An in-order list of files, first by structured definition files,
- * then by plain text documents.
- */
- private List<File> bin( final SetProperty<String> paths ) {
- // Treat all files destined for the text editor as plain text documents
- // so that they are added to the same pane. Grouping by TEXT_PLAIN is a
- // bit arbitrary, but means explicitly capturing TEXT_PLAIN isn't needed.
- final Function<MediaType, MediaType> bin =
- m -> PLAIN_TEXT_FORMAT.contains( m ) ? TEXT_PLAIN : m;
-
- // Create two groups: YAML files and plain text files.
- final var bins = paths
- .stream()
- .collect(
- groupingBy( path -> bin.apply( MediaType.valueFrom( path ) ) )
- );
-
- bins.putIfAbsent( TEXT_YAML, List.of( DEFINITION_DEFAULT.toString() ) );
- bins.putIfAbsent( TEXT_PLAIN, List.of( DOCUMENT_DEFAULT.toString() ) );
-
- final var result = new ArrayList<File>( paths.size() );
-
- // Ensure that the same types are listed together (keep insertion order).
- bins.forEach( ( mediaType, files ) -> result.addAll(
- files.stream().map( File::new ).collect( Collectors.toList() ) )
- );
-
- return result;
- }
-
- /**
- * Uses the given {@link TextDefinition} instance to update the
- * {@link #mResolvedMap}.
- *
- * @param editor A non-null, possibly empty definition editor.
- */
- private void resolve( final TextDefinition editor ) {
- assert editor != null;
-
- final var tokens = createDefinitionTokens();
- final var operator = new YamlSigilOperator( tokens );
- final var map = new HashMap<String, String>();
-
- editor.toMap().forEach( ( k, v ) -> map.put( operator.entoken( k ), v ) );
-
- mResolvedMap.clear();
- mResolvedMap.putAll( editor.interpolate( map, tokens ) );
- }
-
- /**
- * Force the active editor to update, which will cause the processor
- * to re-evaluate the interpolated definition map thereby updating the
- * preview pane.
- *
- * @param editor Contains the source document to update in the preview pane.
- */
- private void process( final TextEditor editor ) {
- mProcessors.getOrDefault( editor, IdentityProcessor.INSTANCE )
- .apply( editor == null ? "" : editor.getText() );
- mHtmlPreview.scrollTo( CARET_ID );
- }
-
- /**
- * Lazily creates a {@link DetachableTabPane} configured to handle focus
- * requests by delegating to the selected tab's content. The tab pane is
- * associated with a given media type so that similar files can be grouped
- * together.
- *
- * @param mediaType The media type to associate with the tab pane.
- * @return An instance of {@link DetachableTabPane} that will handle
- * docking of tabs.
- */
- private DetachableTabPane obtainDetachableTabPane(
- final MediaType mediaType ) {
- return mTabPanes.computeIfAbsent(
- mediaType, ( mt ) -> createDetachableTabPane()
- );
- }
-
- /**
- * Creates an initialized {@link DetachableTabPane} instance.
- *
- * @return A new {@link DetachableTabPane} with all listeners configured.
- */
- private DetachableTabPane createDetachableTabPane() {
- final var tabPane = new DetachableTabPane();
-
- initStageOwnerFactory( tabPane );
- initTabListener( tabPane );
- initSelectionModelListener( tabPane );
-
- return tabPane;
- }
-
- /**
- * When any {@link DetachableTabPane} is detached from the main window,
- * the stage owner factory must be given its parent window, which will
- * own the child window. The parent window is the {@link MainPane}'s
- * {@link Scene}'s {@link Window} instance.
- *
- * <p>
- * This will derives the new title from the main window title, incrementing
- * the window count to help uniquely identify the child windows.
- * </p>
- *
- * @param tabPane A new {@link DetachableTabPane} to configure.
- */
- private void initStageOwnerFactory( final DetachableTabPane tabPane ) {
- tabPane.setStageOwnerFactory( ( stage ) -> {
- final var title = get(
- "Detach.tab.title",
- ((Stage) getWindow()).getTitle(), ++mWindowCount
- );
- stage.setTitle( title );
- return getScene().getWindow();
- } );
- }
-
- /**
- * Responsible for configuring the content of each {@link DetachableTab} when
- * it is added to the given {@link DetachableTabPane} instance.
- * <p>
- * For {@link TextEditor} contents, an instance of {@link ScrollEventHandler}
- * is initialized to perform synchronized scrolling between the editor and
- * its preview window. Additionally, the last tab in the tab pane's list of
- * tabs is given focus.
- * </p>
- * <p>
- * Note that multiple tabs can be added simultaneously.
- * </p>
- *
- * @param tabPane A new {@link DetachableTabPane} to configure.
- */
- private void initTabListener( final DetachableTabPane tabPane ) {
- tabPane.getTabs().addListener(
- ( final ListChangeListener.Change<? extends Tab> listener ) -> {
- while( listener.next() ) {
- if( listener.wasAdded() ) {
- final var tabs = listener.getAddedSubList();
-
- tabs.forEach( ( tab ) -> {
- final var node = tab.getContent();
-
- if( node instanceof TextEditor ) {
- initScrollEventListener( tab );
- }
- } );
-
- // Select and give focus to the last tab opened.
- final var index = tabs.size() - 1;
- if( index >= 0 ) {
- final var tab = tabs.get( index );
- tabPane.getSelectionModel().select( tab );
- tab.getContent().requestFocus();
- }
- }
- }
- }
- );
- }
-
- /**
- * Responsible for handling tab change events.
- *
- * @param tabPane A new {@link DetachableTabPane} to configure.
- */
- private void initSelectionModelListener( final DetachableTabPane tabPane ) {
- final var model = tabPane.getSelectionModel();
-
- model.selectedItemProperty().addListener( ( c, o, n ) -> {
- if( o != null && n == null ) {
- final var node = o.getContent();
-
- // If the last definition editor in the active pane was closed,
- // clear out the definitions then refresh the text editor.
- if( node instanceof TextDefinition ) {
- mActiveDefinitionEditor.set( createDefinitionEditor() );
- }
- }
- else if( n != null ) {
- final var node = n.getContent();
-
- if( node instanceof TextEditor ) {
- // Changing the active node will fire an event, which will
- // update the preview panel and grab focus.
- mActiveTextEditor.set( (TextEditor) node );
- runLater( node::requestFocus );
- }
- else if( node instanceof TextDefinition ) {
- mActiveDefinitionEditor.set( (DefinitionEditor) node );
- }
- }
- } );
- }
-
- /**
- * Synchronizes scrollbar positions between the given {@link Tab} that
- * contains an instance of {@link TextEditor} and {@link HtmlPreview} pane.
- *
- * @param tab The container for an instance of {@link TextEditor}.
- */
- private void initScrollEventListener( final Tab tab ) {
- final var editor = (TextEditor) tab.getContent();
- final var scrollPane = editor.getScrollPane();
- final var scrollBar = mHtmlPreview.getVerticalScrollBar();
- final var handler = new ScrollEventHandler( scrollPane, scrollBar );
- handler.enabledProperty().bind( tab.selectedProperty() );
- }
-
- private void addTabPane( final int index, final DetachableTabPane tabPane ) {
- final var items = getItems();
- if( !items.contains( tabPane ) ) {
- items.add( index, tabPane );
- }
- }
-
- private void addTabPane( final DetachableTabPane tabPane ) {
- addTabPane( getItems().size(), tabPane );
- }
-
- /**
- * @param path Used by {@link ProcessorFactory} to determine
- * {@link Processor} type to create based on file type.
- * @param caret Used by {@link CaretExtension} to add ID attribute into
- * preview document for scrollbar synchronization.
- * @return A new {@link ProcessorContext} to use when creating an instance of
- * {@link Processor}.
- */
- private ProcessorContext createProcessorContext(
- final Path path, final Caret caret ) {
- return new ProcessorContext(
- mHtmlPreview, mResolvedMap, path, caret, NONE, mWorkspace
- );
- }
-
- public ProcessorContext createProcessorContext( final TextEditor t ) {
- return createProcessorContext( t.getPath(), t.getCaret() );
- }
-
- @SuppressWarnings( {"RedundantCast", "unchecked", "RedundantSuppression"} )
- private TextResource createTextResource( final File file ) {
- // TODO: Create PlainTextEditor that's returned by default.
- return switch( MediaType.valueFrom( file ) ) {
- case TEXT_MARKDOWN, TEXT_R_MARKDOWN -> createMarkdownEditor( file );
- case TEXT_YAML -> createDefinitionEditor( file );
- default -> createMarkdownEditor( file );
- };
+ }
+
+ /**
+ * 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 = obtainDetachableTabPane( mediaType );
+ final var newTabPane = !getItems().contains( tabPane );
+
+ tab.setTooltip( createTooltip( file ) );
+ tabPane.setFocusTraversable( false );
+ tabPane.setTabClosingPolicy( ALL_TABS );
+ tabPane.getTabs().add( tab );
+
+ if( newTabPane ) {
+ var index = getItems().size();
+
+ if( node instanceof TextDefinition ) {
+ tabPane.setSceneFactory( mDefinitionTabSceneFactory::create );
+ index = 0;
+ }
+
+ addTabPane( index, 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 node = tab.getContent();
+
+ if( node instanceof TextEditor &&
+ (closable &= canClose( (TextEditor) node )) ) {
+ tabIterator.remove();
+ close( tab );
+ }
+ }
+ }
+
+ 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(TextEditor)}.
+ */
+ public void close() {
+ final var editor = getActiveTextEditor();
+ if( canClose( editor ) ) {
+ close( editor );
+ }
+ }
+
+ /**
+ * Closes the given {@link TextEditor}. 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 editor The {@link TextEditor} to close, without confirming with
+ * the user.
+ */
+ private void close( final TextEditor editor ) {
+ getTab( editor ).ifPresent(
+ ( tab ) -> {
+ tab.getTabPane().getTabs().remove( tab );
+ close( tab );
+ }
+ );
+ }
+
+ /**
+ * Answers whether the given {@link TextEditor} may be closed.
+ *
+ * @param editor The {@link TextEditor} 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 TextEditor 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 ) {
+ mHtmlPreview.setBaseUri( n.getPath() );
+ process( n );
+ }
+ } );
+
+ return editor;
+ }
+
+ /**
+ * Adds the HTML preview tab to its own tab pane. This will only add the
+ * preview once.
+ */
+ public void viewPreview() {
+ final var tabPane = obtainDetachableTabPane( TEXT_HTML );
+
+ // Prevent multiple HTML previews because in the end, there can be only one.
+ for( final var tab : tabPane.getTabs() ) {
+ if( tab.getContent() == mHtmlPreview ) {
+ return;
+ }
+ }
+
+ tabPane.addTab( "HTML", mHtmlPreview );
+ addTabPane( tabPane );
+ }
+
+ public void viewRefresh() {
+ mHtmlPreview.refresh();
+ }
+
+ /**
+ * Returns the tab that contains the given {@link TextEditor}.
+ *
+ * @param editor The {@link TextEditor} instance to find amongst the tabs.
+ * @return The first tab having content that matches the given tab.
+ */
+ private Optional<Tab> getTab( final TextEditor editor ) {
+ return mTabPanes.values()
+ .stream()
+ .flatMap( pane -> pane.getTabs().stream() )
+ .filter( tab -> editor.equals( tab.getContent() ) )
+ .findFirst();
+ }
+
+ /**
+ * Creates a new {@link DefinitionEditor} wrapped in a listener that
+ * is used to detect when the active {@link DefinitionEditor} has changed.
+ * Upon changing, the {@link #mResolvedMap} is updated and the active
+ * text editor is refreshed.
+ *
+ * @param editor Text editor to update with the revised resolved map.
+ * @return A newly configured property that represents the active
+ * {@link DefinitionEditor}, never null.
+ */
+ private ObjectProperty<TextDefinition> createActiveDefinitionEditor(
+ final ObjectProperty<TextEditor> editor ) {
+ final var definitions = new SimpleObjectProperty<TextDefinition>();
+ definitions.addListener( ( c, o, n ) -> {
+ resolve( n == null ? createDefinitionEditor() : n );
+ process( editor.get() );
+ } );
+
+ return definitions;
+ }
+
+ /**
+ * Instantiates a factory that's responsible for creating new scenes when
+ * a tab is dropped outside of any application window. The definition tabs
+ * are fairly complex in that only one may be active at any time. When
+ * activated, the {@link #mResolvedMap} must be updated to reflect the
+ * hierarchy displayed in the {@link DefinitionEditor}.
+ *
+ * @param activeDefinitionEditor The current {@link DefinitionEditor}.
+ * @return An object that listens to {@link DefinitionEditor} tab focus
+ * changes.
+ */
+ private DefinitionTabSceneFactory createDefinitionTabSceneFactory(
+ final ObjectProperty<TextDefinition> activeDefinitionEditor ) {
+ return new DefinitionTabSceneFactory( ( tab ) -> {
+ assert tab != null;
+
+ var node = tab.getContent();
+ if( node instanceof TextDefinition ) {
+ activeDefinitionEditor.set( (DefinitionEditor) node );
+ }
+ } );
+ }
+
+ private DetachableTab createTab( final File file ) {
+ final var r = createTextResource( file );
+ final var tab = new DetachableTab( r.getFilename(), r.getNode() );
+
+ r.modifiedProperty().addListener(
+ ( c, o, n ) -> tab.setText( r.getFilename() + (n ? "*" : "") )
+ );
+
+ // This is called when either the tab is closed by the user clicking on
+ // the tab's close icon or when closing (all) from the file menu.
+ tab.setOnClosed(
+ ( __ ) -> getRecentFiles().remove( file.getAbsolutePath() )
+ );
+
+ return tab;
+ }
+
+ /**
+ * Creates bins for the different {@link MediaType}s, which eventually are
+ * added to the UI as separate tab panes. If ever a general-purpose scene
+ * exporter is developed to serialize a scene to an FXML file, this could
+ * be replaced by such a class.
+ * <p>
+ * When binning the files, this makes sure that at least one file exists
+ * for every type. If the user has opted to close a particular type (such
+ * as the definition pane), the view will suppressed elsewhere.
+ * </p>
+ * <p>
+ * The order that the binned files are returned will be reflected in the
+ * order that the corresponding panes are rendered in the UI.
+ * </p>
+ *
+ * @param paths The file paths to bin according to their type.
+ * @return An in-order list of files, first by structured definition files,
+ * then by plain text documents.
+ */
+ private List<File> bin( final SetProperty<String> paths ) {
+ // Treat all files destined for the text editor as plain text documents
+ // so that they are added to the same pane. Grouping by TEXT_PLAIN is a
+ // bit arbitrary, but means explicitly capturing TEXT_PLAIN isn't needed.
+ final Function<MediaType, MediaType> bin =
+ m -> PLAIN_TEXT_FORMAT.contains( m ) ? TEXT_PLAIN : m;
+
+ // Create two groups: YAML files and plain text files.
+ final var bins = paths
+ .stream()
+ .collect(
+ groupingBy( path -> bin.apply( MediaType.valueFrom( path ) ) )
+ );
+
+ bins.putIfAbsent( TEXT_YAML, List.of( DEFINITION_DEFAULT.toString() ) );
+ bins.putIfAbsent( TEXT_PLAIN, List.of( DOCUMENT_DEFAULT.toString() ) );
+
+ final var result = new ArrayList<File>( paths.size() );
+
+ // Ensure that the same types are listed together (keep insertion order).
+ bins.forEach( ( mediaType, files ) -> result.addAll(
+ files.stream().map( File::new ).collect( Collectors.toList() ) )
+ );
+
+ return result;
+ }
+
+ /**
+ * Uses the given {@link TextDefinition} instance to update the
+ * {@link #mResolvedMap}.
+ *
+ * @param editor A non-null, possibly empty definition editor.
+ */
+ private void resolve( final TextDefinition editor ) {
+ assert editor != null;
+
+ final var tokens = createDefinitionTokens();
+ final var operator = new YamlSigilOperator( tokens );
+ final var map = new HashMap<String, String>();
+
+ editor.toMap().forEach( ( k, v ) -> map.put( operator.entoken( k ), v ) );
+
+ mResolvedMap.clear();
+ mResolvedMap.putAll( editor.interpolate( map, tokens ) );
+ }
+
+ /**
+ * Force the active editor to update, which will cause the processor
+ * to re-evaluate the interpolated definition map thereby updating the
+ * preview pane.
+ *
+ * @param editor Contains the source document to update in the preview pane.
+ */
+ private void process( final TextEditor editor ) {
+ mProcessors.getOrDefault( editor, IdentityProcessor.INSTANCE )
+ .apply( editor == null ? "" : editor.getText() );
+ mHtmlPreview.scrollTo( CARET_ID );
+ }
+
+ /**
+ * Lazily creates a {@link DetachableTabPane} configured to handle focus
+ * requests by delegating to the selected tab's content. The tab pane is
+ * associated with a given media type so that similar files can be grouped
+ * together.
+ *
+ * @param mediaType The media type to associate with the tab pane.
+ * @return An instance of {@link DetachableTabPane} that will handle
+ * docking of tabs.
+ */
+ private DetachableTabPane obtainDetachableTabPane(
+ final MediaType mediaType ) {
+ return mTabPanes.computeIfAbsent(
+ mediaType, ( mt ) -> createDetachableTabPane()
+ );
+ }
+
+ /**
+ * Creates an initialized {@link DetachableTabPane} instance.
+ *
+ * @return A new {@link DetachableTabPane} with all listeners configured.
+ */
+ private DetachableTabPane createDetachableTabPane() {
+ final var tabPane = new DetachableTabPane();
+
+ initStageOwnerFactory( tabPane );
+ initTabListener( tabPane );
+ initSelectionModelListener( tabPane );
+
+ return tabPane;
+ }
+
+ /**
+ * When any {@link DetachableTabPane} is detached from the main window,
+ * the stage owner factory must be given its parent window, which will
+ * own the child window. The parent window is the {@link MainPane}'s
+ * {@link Scene}'s {@link Window} instance.
+ *
+ * <p>
+ * This will derives the new title from the main window title, incrementing
+ * the window count to help uniquely identify the child windows.
+ * </p>
+ *
+ * @param tabPane A new {@link DetachableTabPane} to configure.
+ */
+ private void initStageOwnerFactory( final DetachableTabPane tabPane ) {
+ tabPane.setStageOwnerFactory( ( stage ) -> {
+ final var title = get(
+ "Detach.tab.title",
+ ((Stage) getWindow()).getTitle(), ++mWindowCount
+ );
+ stage.setTitle( title );
+ return getScene().getWindow();
+ } );
+ }
+
+ /**
+ * Responsible for configuring the content of each {@link DetachableTab} when
+ * it is added to the given {@link DetachableTabPane} instance.
+ * <p>
+ * For {@link TextEditor} contents, an instance of {@link ScrollEventHandler}
+ * is initialized to perform synchronized scrolling between the editor and
+ * its preview window. Additionally, the last tab in the tab pane's list of
+ * tabs is given focus.
+ * </p>
+ * <p>
+ * Note that multiple tabs can be added simultaneously.
+ * </p>
+ *
+ * @param tabPane A new {@link DetachableTabPane} to configure.
+ */
+ private void initTabListener( final DetachableTabPane tabPane ) {
+ tabPane.getTabs().addListener(
+ ( final ListChangeListener.Change<? extends Tab> listener ) -> {
+ while( listener.next() ) {
+ if( listener.wasAdded() ) {
+ final var tabs = listener.getAddedSubList();
+
+ tabs.forEach( ( tab ) -> {
+ final var node = tab.getContent();
+
+ if( node instanceof TextEditor ) {
+ initScrollEventListener( tab );
+ }
+ } );
+
+ // Select and give focus to the last tab opened.
+ final var index = tabs.size() - 1;
+ if( index >= 0 ) {
+ final var tab = tabs.get( index );
+ tabPane.getSelectionModel().select( tab );
+ tab.getContent().requestFocus();
+ }
+ }
+ }
+ }
+ );
+ }
+
+ /**
+ * Responsible for handling tab change events.
+ *
+ * @param tabPane A new {@link DetachableTabPane} to configure.
+ */
+ private void initSelectionModelListener( final DetachableTabPane tabPane ) {
+ final var model = tabPane.getSelectionModel();
+
+ model.selectedItemProperty().addListener( ( c, o, n ) -> {
+ if( o != null && n == null ) {
+ final var node = o.getContent();
+
+ // If the last definition editor in the active pane was closed,
+ // clear out the definitions then refresh the text editor.
+ if( node instanceof TextDefinition ) {
+ mActiveDefinitionEditor.set( createDefinitionEditor() );
+ }
+ }
+ else if( n != null ) {
+ final var node = n.getContent();
+
+ if( node instanceof TextEditor ) {
+ // Changing the active node will fire an event, which will
+ // update the preview panel and grab focus.
+ mActiveTextEditor.set( (TextEditor) node );
+ runLater( node::requestFocus );
+ }
+ else if( node instanceof TextDefinition ) {
+ mActiveDefinitionEditor.set( (DefinitionEditor) node );
+ }
+ }
+ } );
+ }
+
+ /**
+ * Synchronizes scrollbar positions between the given {@link Tab} that
+ * contains an instance of {@link TextEditor} and {@link HtmlPreview} pane.
+ *
+ * @param tab The container for an instance of {@link TextEditor}.
+ */
+ private void initScrollEventListener( final Tab tab ) {
+ final var editor = (TextEditor) tab.getContent();
+ final var scrollPane = editor.getScrollPane();
+ final var scrollBar = mHtmlPreview.getVerticalScrollBar();
+ final var handler = new ScrollEventHandler( scrollPane, scrollBar );
+ handler.enabledProperty().bind( tab.selectedProperty() );
+ }
+
+ private void addTabPane( final int index, final DetachableTabPane tabPane ) {
+ final var items = getItems();
+ if( !items.contains( tabPane ) ) {
+ items.add( index, tabPane );
+ }
+ }
+
+ private void addTabPane( final DetachableTabPane tabPane ) {
+ addTabPane( getItems().size(), tabPane );
+ }
+
+ /**
+ * @param path Used by {@link ProcessorFactory} to determine
+ * {@link Processor} type to create based on file type.
+ * @param caret Used by {@link CaretExtension} to add ID attribute into
+ * preview document for scrollbar synchronization.
+ * @return A new {@link ProcessorContext} to use when creating an instance of
+ * {@link Processor}.
+ */
+ private ProcessorContext createProcessorContext(
+ final Path path, final Caret caret ) {
+ return new ProcessorContext(
+ mHtmlPreview, mResolvedMap, path, caret, NONE, mWorkspace
+ );
+ }
+
+ public ProcessorContext createProcessorContext( final TextEditor t ) {
+ return createProcessorContext( t.getPath(), t.getCaret() );
+ }
+
+ private TextResource createTextResource( final File file ) {
+ // TODO: Create PlainTextEditor that's returned by default.
+ return MediaType.valueFrom( file ) == TEXT_YAML
+ ? createDefinitionEditor( file )
+ : createMarkdownEditor( file );
}
src/main/java/com/keenwrite/MainScene.java
private final Scene mScene;
- public MainScene( final Workspace preferences ) {
- final var mainPane = createMainPane( preferences );
+ public MainScene( final Workspace workspace ) {
+ final var mainPane = createMainPane( workspace );
final var actions = createApplicationActions( mainPane );
final var menuBar = createMenuBar( actions );
}
- private MainPane createMainPane( final Workspace preferences ) {
- return new MainPane( preferences );
+ private MainPane createMainPane( final Workspace workspace ) {
+ return new MainPane( workspace );
}