Dave Jarvis' Repositories

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

Move file saving behaviour into file tab

Author DaveJarvis <email>
Date 2020-10-29 20:20:00 GMT-0700
Commit 6cf6c79989cf8923b36c4ca35c9593e6d8b34f40
Parent 8f2a7e4
src/main/java/com/keenwrite/FileEditorTab.java
import com.keenwrite.processors.Processor;
import com.keenwrite.processors.markdown.CaretPosition;
-import com.keenwrite.service.events.Notification;
-import com.keenwrite.service.events.Notifier;
-import javafx.beans.binding.Bindings;
-import javafx.beans.property.BooleanProperty;
-import javafx.beans.property.ReadOnlyBooleanProperty;
-import javafx.beans.property.ReadOnlyBooleanWrapper;
-import javafx.beans.property.SimpleBooleanProperty;
-import javafx.beans.value.ChangeListener;
-import javafx.event.Event;
-import javafx.event.EventHandler;
-import javafx.event.EventType;
-import javafx.scene.Scene;
-import javafx.scene.control.Tooltip;
-import javafx.scene.text.Text;
-import javafx.stage.Window;
-import org.fxmisc.flowless.VirtualizedScrollPane;
-import org.fxmisc.richtext.StyleClassedTextArea;
-import org.fxmisc.undo.UndoManager;
-import org.jetbrains.annotations.NotNull;
-import org.mozilla.universalchardet.UniversalDetector;
-
-import java.io.File;
-import java.nio.charset.Charset;
-import java.nio.file.Files;
-import java.nio.file.Path;
-
-import static com.keenwrite.Messages.get;
-import static com.keenwrite.StatusBarNotifier.clue;
-import static com.keenwrite.StatusBarNotifier.getNotifier;
-import static java.nio.charset.StandardCharsets.UTF_8;
-import static java.util.Locale.ENGLISH;
-import static javafx.application.Platform.runLater;
-
-/**
- * Editor for a single file.
- */
-public final class FileEditorTab extends DetachableTab {
-
- private final MarkdownEditorPane mEditorPane = new MarkdownEditorPane();
-
- private final ReadOnlyBooleanWrapper mModified = new ReadOnlyBooleanWrapper();
- private final BooleanProperty canUndo = new SimpleBooleanProperty();
- private final BooleanProperty canRedo = new SimpleBooleanProperty();
-
- /**
- * Character encoding used by the file (or default encoding if none found).
- */
- private Charset mEncoding = UTF_8;
-
- /**
- * File to load into the editor.
- */
- private Path mPath;
-
- /**
- * Dynamically updated position of the caret within the text editor.
- */
- private final CaretPosition mCaretPosition;
-
- public FileEditorTab( final Path path ) {
- setPath( path );
-
- mModified.addListener( ( observable, oldPath, newPath ) -> updateTab() );
-
- setOnSelectionChanged( e -> {
- if( isSelected() ) {
- runLater( this::activated );
- requestFocus();
- }
- } );
-
- mCaretPosition = createCaretPosition( getEditor() );
- }
-
- private CaretPosition createCaretPosition(
- final StyleClassedTextArea editor ) {
- final var propParaIndex = editor.currentParagraphProperty();
- final var propParagraphs = editor.getParagraphs();
- final var propParaOffset = editor.caretColumnProperty();
- final var propTextOffset = editor.caretPositionProperty();
-
- return CaretPosition
- .builder()
- .with( CaretPosition.Mutator::setParagraph, propParaIndex )
- .with( CaretPosition.Mutator::setParagraphs, propParagraphs )
- .with( CaretPosition.Mutator::setParaOffset, propParaOffset )
- .with( CaretPosition.Mutator::setTextOffset, propTextOffset )
- .build();
- }
-
- private void updateTab() {
- setText( getTabTitle() );
- setGraphic( getModifiedMark() );
- setTooltip( getTabTooltip() );
- }
-
- /**
- * Returns the base filename (without the directory names).
- *
- * @return The untitled text if the path hasn't been set.
- */
- private String getTabTitle() {
- return getPath().getFileName().toString();
- }
-
- /**
- * Returns the full filename represented by the path.
- *
- * @return The untitled text if the path hasn't been set.
- */
- private Tooltip getTabTooltip() {
- final Path filePath = getPath();
- return new Tooltip( filePath == null ? "" : filePath.toString() );
- }
-
- /**
- * Returns a marker to indicate whether the file has been modified.
- *
- * @return "*" when the file has changed; otherwise null.
- */
- private Text getModifiedMark() {
- return isModified() ? new Text( "*" ) : null;
- }
-
- /**
- * Called when the user switches tab.
- */
- private void activated() {
- // Tab is closed or no longer active.
- if( getTabPane() == null || !isSelected() ) {
- return;
- }
-
- // If the tab is devoid of content, load it.
- if( getContent() == null ) {
- readFile();
- initLayout();
- initUndoManager();
- }
- }
-
- private void initLayout() {
- setContent( getScrollPane() );
- }
-
- /**
- * Tracks undo requests, but can only be called <em>after</em> load.
- */
- private void initUndoManager() {
- final UndoManager<?> undoManager = getUndoManager();
- undoManager.forgetHistory();
-
- // Bind the editor undo manager to the properties.
- mModified.bind( Bindings.not( undoManager.atMarkedPositionProperty() ) );
- canUndo.bind( undoManager.undoAvailableProperty() );
- canRedo.bind( undoManager.redoAvailableProperty() );
- }
-
- private void requestFocus() {
- getEditorPane().requestFocus();
- }
-
- /**
- * Searches from the caret position forward for the given string.
- *
- * @param needle The text string to match.
- */
- public void searchNext( final String needle ) {
- final String haystack = getEditorText();
- int index = haystack.indexOf( needle, getCaretTextOffset() );
-
- // Wrap around.
- if( index == -1 ) {
- index = haystack.indexOf( needle );
- }
-
- if( index >= 0 ) {
- setCaretTextOffset( index );
- getEditor().selectRange( index, index + needle.length() );
- }
- }
-
- /**
- * Gets a reference to the scroll pane that houses the editor.
- *
- * @return The editor's scroll pane, containing a vertical scrollbar.
- */
- public VirtualizedScrollPane<StyleClassedTextArea> getScrollPane() {
- return getEditorPane().getScrollPane();
- }
-
- /**
- * Returns an instance of {@link CaretPosition} that contains information
- * about the caret, including the offset into the text, the paragraph into
- * the text, maximum number of paragraphs, and more. This allows the main
- * application and the {@link Processor} instances to get the current
- * caret position.
- *
- * @return The current values for the caret's position within the editor.
- */
- public CaretPosition getCaretPosition() {
- return mCaretPosition;
- }
-
- /**
- * Returns the index into the text where the caret blinks happily away.
- *
- * @return A number from 0 to the editor's document text length.
- */
- private int getCaretTextOffset() {
- return getEditor().getCaretPosition();
- }
-
- /**
- * Moves the caret to a given offset.
- *
- * @param offset The new caret offset.
- */
- private void setCaretTextOffset( final int offset ) {
- getEditor().moveTo( offset );
- getEditor().requestFollowCaret();
- }
-
- /**
- * Returns the text area associated with this tab.
- *
- * @return A text editor.
- */
- private StyleClassedTextArea getEditor() {
- return getEditorPane().getEditor();
- }
-
- /**
- * Returns true if the given path exactly matches this tab's path.
- *
- * @param check The path to compare against.
- * @return true The paths are the same.
- */
- public boolean isPath( final Path check ) {
- final Path filePath = getPath();
-
- return filePath != null && filePath.equals( check );
- }
-
- /**
- * Reads the entire file contents from the path associated with this tab.
- */
- private void readFile() {
- final Path path = getPath();
- final File file = path.toFile();
-
- try {
- if( file.exists() ) {
- if( file.canWrite() && file.canRead() ) {
- final EditorPane pane = getEditorPane();
- pane.setText( asString( Files.readAllBytes( path ) ) );
- pane.scrollToTop();
- }
- else {
- final String msg = get( "FileEditor.loadFailed.reason.permissions" );
- clue( "FileEditor.loadFailed.message", file.toString(), msg );
- }
- }
- } catch( final Exception ex ) {
- clue( ex );
- }
- }
-
- /**
- * Saves the entire file contents from the path associated with this tab.
- *
- * @return true The file has been saved.
- */
- public boolean save() {
- try {
- final EditorPane editor = getEditorPane();
- Files.write( getPath(), asBytes( editor.getText() ) );
- editor.getUndoManager().mark();
- return true;
- } catch( final Exception ex ) {
- return popupAlert(
- "FileEditor.saveFailed.title",
- "FileEditor.saveFailed.message",
- ex
- );
- }
- }
-
- /**
- * Creates an alert dialog and waits for it to close.
- *
- * @param titleKey Resource bundle key for the alert dialog title.
- * @param messageKey Resource bundle key for the alert dialog message.
- * @param e The unexpected happening.
- * @return false
- */
- @SuppressWarnings("SameParameterValue")
- private boolean popupAlert(
- final String titleKey, final String messageKey, final Exception e ) {
- final Notifier service = getNotifier();
- final Path filePath = getPath();
-
- final Notification message = service.createNotification(
- get( titleKey ),
- get( messageKey ),
- filePath == null ? "" : filePath,
- e.getMessage()
- );
-
- try {
- service.createError( getWindow(), message ).showAndWait();
- } catch( final Exception ex ) {
- clue( ex );
- }
-
- return false;
- }
-
- private Window getWindow() {
- final Scene scene = getEditorPane().getScene();
-
- if( scene == null ) {
- throw new UnsupportedOperationException( "No scene window available" );
- }
-
- return scene.getWindow();
- }
-
- /**
- * Returns a best guess at the file encoding. If the encoding could not be
- * detected, this will return the default charset for the JVM.
- *
- * @param bytes The bytes to perform character encoding detection.
- * @return The character encoding.
- */
- private Charset detectEncoding( final byte[] bytes ) {
- final var detector = new UniversalDetector( null );
- detector.handleData( bytes, 0, bytes.length );
- detector.dataEnd();
-
- final String charset = detector.getDetectedCharset();
-
- return charset == null
- ? Charset.defaultCharset()
- : Charset.forName( charset.toUpperCase( ENGLISH ) );
- }
-
- /**
- * Converts the given string to an array of bytes using the encoding that was
- * originally detected (if any) and associated with this file.
- *
- * @param text The text to convert into the original file encoding.
- * @return A series of bytes ready for writing to a file.
- */
- private byte[] asBytes( final String text ) {
- return text.getBytes( getEncoding() );
- }
-
- /**
- * Converts the given bytes into a Java String. This will call setEncoding
- * with the encoding detected by the CharsetDetector.
- *
- * @param text The text of unknown character encoding.
- * @return The text, in its auto-detected encoding, as a String.
- */
- private String asString( final byte[] text ) {
- setEncoding( detectEncoding( text ) );
- return new String( text, getEncoding() );
- }
-
- /**
- * Returns the path to the file being edited in this tab.
- *
- * @return A non-null instance.
- */
- public Path getPath() {
- return mPath;
- }
-
- /**
- * Sets the path to a file for editing and then updates the tab with the
- * file contents.
- *
- * @param path A non-null instance.
- */
- public void setPath( final Path path ) {
- assert path != null;
- mPath = path;
-
- updateTab();
+import javafx.beans.binding.Bindings;
+import javafx.beans.property.BooleanProperty;
+import javafx.beans.property.ReadOnlyBooleanProperty;
+import javafx.beans.property.ReadOnlyBooleanWrapper;
+import javafx.beans.property.SimpleBooleanProperty;
+import javafx.beans.value.ChangeListener;
+import javafx.event.Event;
+import javafx.event.EventHandler;
+import javafx.event.EventType;
+import javafx.scene.Scene;
+import javafx.scene.control.Tooltip;
+import javafx.scene.text.Text;
+import javafx.stage.Window;
+import org.fxmisc.flowless.VirtualizedScrollPane;
+import org.fxmisc.richtext.StyleClassedTextArea;
+import org.fxmisc.undo.UndoManager;
+import org.jetbrains.annotations.NotNull;
+import org.mozilla.universalchardet.UniversalDetector;
+
+import java.io.File;
+import java.nio.charset.Charset;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import static com.keenwrite.Messages.get;
+import static com.keenwrite.StatusBarNotifier.clue;
+import static com.keenwrite.StatusBarNotifier.getNotifier;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.Locale.ENGLISH;
+import static javafx.application.Platform.runLater;
+
+/**
+ * Editor for a single file.
+ */
+public final class FileEditorTab extends DetachableTab {
+
+ private final MarkdownEditorPane mEditorPane = new MarkdownEditorPane();
+
+ private final ReadOnlyBooleanWrapper mModified = new ReadOnlyBooleanWrapper();
+ private final BooleanProperty canUndo = new SimpleBooleanProperty();
+ private final BooleanProperty canRedo = new SimpleBooleanProperty();
+
+ /**
+ * Character encoding used by the file (or default encoding if none found).
+ */
+ private Charset mEncoding = UTF_8;
+
+ /**
+ * File to load into the editor.
+ */
+ private Path mPath;
+
+ /**
+ * Dynamically updated position of the caret within the text editor.
+ */
+ private final CaretPosition mCaretPosition;
+
+ public FileEditorTab( final Path path ) {
+ setPath( path );
+
+ mModified.addListener( ( observable, oldPath, newPath ) -> updateTab() );
+
+ setOnSelectionChanged( e -> {
+ if( isSelected() ) {
+ runLater( this::activated );
+ requestFocus();
+ }
+ } );
+
+ mCaretPosition = createCaretPosition( getEditor() );
+ }
+
+ private CaretPosition createCaretPosition(
+ final StyleClassedTextArea editor ) {
+ final var propParaIndex = editor.currentParagraphProperty();
+ final var propParagraphs = editor.getParagraphs();
+ final var propParaOffset = editor.caretColumnProperty();
+ final var propTextOffset = editor.caretPositionProperty();
+
+ return CaretPosition
+ .builder()
+ .with( CaretPosition.Mutator::setParagraph, propParaIndex )
+ .with( CaretPosition.Mutator::setParagraphs, propParagraphs )
+ .with( CaretPosition.Mutator::setParaOffset, propParaOffset )
+ .with( CaretPosition.Mutator::setTextOffset, propTextOffset )
+ .build();
+ }
+
+ private void updateTab() {
+ setText( getTabTitle() );
+ setGraphic( getModifiedMark() );
+ setTooltip( getTabTooltip() );
+ }
+
+ /**
+ * Returns the base filename (without the directory names).
+ *
+ * @return The untitled text if the path hasn't been set.
+ */
+ private String getTabTitle() {
+ return getPath().getFileName().toString();
+ }
+
+ /**
+ * Returns the full filename represented by the path.
+ *
+ * @return The untitled text if the path hasn't been set.
+ */
+ private Tooltip getTabTooltip() {
+ final Path filePath = getPath();
+ return new Tooltip( filePath == null ? "" : filePath.toString() );
+ }
+
+ /**
+ * Returns a marker to indicate whether the file has been modified.
+ *
+ * @return "*" when the file has changed; otherwise null.
+ */
+ private Text getModifiedMark() {
+ return isModified() ? new Text( "*" ) : null;
+ }
+
+ /**
+ * Called when the user switches tab.
+ */
+ private void activated() {
+ // Tab is closed or no longer active.
+ if( getTabPane() == null || !isSelected() ) {
+ return;
+ }
+
+ // If the tab is devoid of content, load it.
+ if( getContent() == null ) {
+ readFile();
+ initLayout();
+ initUndoManager();
+ }
+ }
+
+ private void initLayout() {
+ setContent( getScrollPane() );
+ }
+
+ /**
+ * Tracks undo requests, but can only be called <em>after</em> load.
+ */
+ private void initUndoManager() {
+ final UndoManager<?> undoManager = getUndoManager();
+ undoManager.forgetHistory();
+
+ // Bind the editor undo manager to the properties.
+ mModified.bind( Bindings.not( undoManager.atMarkedPositionProperty() ) );
+ canUndo.bind( undoManager.undoAvailableProperty() );
+ canRedo.bind( undoManager.redoAvailableProperty() );
+ }
+
+ private void requestFocus() {
+ getEditorPane().requestFocus();
+ }
+
+ /**
+ * Searches from the caret position forward for the given string.
+ *
+ * @param needle The text string to match.
+ */
+ public void searchNext( final String needle ) {
+ final String haystack = getEditorText();
+ int index = haystack.indexOf( needle, getCaretTextOffset() );
+
+ // Wrap around.
+ if( index == -1 ) {
+ index = haystack.indexOf( needle );
+ }
+
+ if( index >= 0 ) {
+ setCaretTextOffset( index );
+ getEditor().selectRange( index, index + needle.length() );
+ }
+ }
+
+ /**
+ * Gets a reference to the scroll pane that houses the editor.
+ *
+ * @return The editor's scroll pane, containing a vertical scrollbar.
+ */
+ public VirtualizedScrollPane<StyleClassedTextArea> getScrollPane() {
+ return getEditorPane().getScrollPane();
+ }
+
+ /**
+ * Returns an instance of {@link CaretPosition} that contains information
+ * about the caret, including the offset into the text, the paragraph into
+ * the text, maximum number of paragraphs, and more. This allows the main
+ * application and the {@link Processor} instances to get the current
+ * caret position.
+ *
+ * @return The current values for the caret's position within the editor.
+ */
+ public CaretPosition getCaretPosition() {
+ return mCaretPosition;
+ }
+
+ /**
+ * Returns the index into the text where the caret blinks happily away.
+ *
+ * @return A number from 0 to the editor's document text length.
+ */
+ private int getCaretTextOffset() {
+ return getEditor().getCaretPosition();
+ }
+
+ /**
+ * Moves the caret to a given offset.
+ *
+ * @param offset The new caret offset.
+ */
+ private void setCaretTextOffset( final int offset ) {
+ getEditor().moveTo( offset );
+ getEditor().requestFollowCaret();
+ }
+
+ /**
+ * Returns the text area associated with this tab.
+ *
+ * @return A text editor.
+ */
+ private StyleClassedTextArea getEditor() {
+ return getEditorPane().getEditor();
+ }
+
+ /**
+ * Returns true if the given path exactly matches this tab's path.
+ *
+ * @param check The path to compare against.
+ * @return true The paths are the same.
+ */
+ public boolean isPath( final Path check ) {
+ final Path filePath = getPath();
+
+ return filePath != null && filePath.equals( check );
+ }
+
+ /**
+ * Reads the entire file contents from the path associated with this tab.
+ */
+ private void readFile() {
+ final Path path = getPath();
+ final File file = path.toFile();
+
+ try {
+ if( file.exists() ) {
+ if( file.canWrite() && file.canRead() ) {
+ final EditorPane pane = getEditorPane();
+ pane.setText( asString( Files.readAllBytes( path ) ) );
+ pane.scrollToTop();
+ }
+ else {
+ final String msg = get( "FileEditor.loadFailed.reason.permissions" );
+ clue( "FileEditor.loadFailed.message", file.toString(), msg );
+ }
+ }
+ } catch( final Exception ex ) {
+ clue( ex );
+ }
+ }
+
+ /**
+ * Saves the entire file contents from the path associated with this tab.
+ *
+ * @return true The file has been saved or wasn't modified.
+ */
+ public boolean save() {
+ try {
+ if( isModified() ) {
+ final var editor = getEditorPane();
+ Files.write( getPath(), asBytes( editor.getText() ) );
+ editor.getUndoManager().mark();
+ }
+
+ return true;
+ } catch( final Exception ex ) {
+ return alert(
+ "FileEditor.saveFailed.title",
+ "FileEditor.saveFailed.message",
+ ex
+ );
+ }
+ }
+
+ /**
+ * Creates an alert dialog and waits for it to close.
+ *
+ * @param titleKey Resource bundle key for the alert dialog title.
+ * @param messageKey Resource bundle key for the alert dialog message.
+ * @param e The unexpected happening.
+ * @return false
+ */
+ @SuppressWarnings("SameParameterValue")
+ private boolean alert(
+ final String titleKey, final String messageKey, final Exception e ) {
+ final var service = getNotifier();
+ final var message = service.createNotification(
+ get( titleKey ), get( messageKey ), getPath(), e.getMessage()
+ );
+
+ try {
+ service.createError( getWindow(), message ).showAndWait();
+ } catch( final Exception ex ) {
+ clue( ex );
+ }
+
+ return false;
+ }
+
+ private Window getWindow() {
+ final Scene scene = getEditorPane().getScene();
+
+ if( scene == null ) {
+ throw new UnsupportedOperationException( "No scene window available" );
+ }
+
+ return scene.getWindow();
+ }
+
+ /**
+ * Returns a best guess at the file encoding. If the encoding could not be
+ * detected, this will return the default charset for the JVM.
+ *
+ * @param bytes The bytes to perform character encoding detection.
+ * @return The character encoding.
+ */
+ private Charset detectEncoding( final byte[] bytes ) {
+ final var detector = new UniversalDetector( null );
+ detector.handleData( bytes, 0, bytes.length );
+ detector.dataEnd();
+
+ final String charset = detector.getDetectedCharset();
+
+ return charset == null
+ ? Charset.defaultCharset()
+ : Charset.forName( charset.toUpperCase( ENGLISH ) );
+ }
+
+ /**
+ * Converts the given string to an array of bytes using the encoding that was
+ * originally detected (if any) and associated with this file.
+ *
+ * @param text The text to convert into the original file encoding.
+ * @return A series of bytes ready for writing to a file.
+ */
+ private byte[] asBytes( final String text ) {
+ return text.getBytes( getEncoding() );
+ }
+
+ /**
+ * Converts the given bytes into a Java String. This will call setEncoding
+ * with the encoding detected by the CharsetDetector.
+ *
+ * @param text The text of unknown character encoding.
+ * @return The text, in its auto-detected encoding, as a String.
+ */
+ private String asString( final byte[] text ) {
+ setEncoding( detectEncoding( text ) );
+ return new String( text, getEncoding() );
+ }
+
+ /**
+ * Returns the path to the file being edited in this tab.
+ *
+ * @return A non-null instance.
+ */
+ public Path getPath() {
+ return mPath;
+ }
+
+ /**
+ * Sets the path to a file for editing and then updates the tab with the
+ * file contents.
+ *
+ * @param path A non-null instance.
+ */
+ public void setPath( final Path path ) {
+ assert path != null;
+ mPath = path;
+
+ updateTab();
+ }
+
+ /**
+ * Convenience method to set the path based on an instance of {@link File}.
+ *
+ * @param file A non-null instance.
+ */
+ public void setPath( final File file ) {
+ assert file != null;
+ setPath( file.toPath() );
}
src/main/java/com/keenwrite/FileEditorTabPane.java
* Constructs a new file editor tab pane.
*
- * @param caretPositionListener Listens for changes to caret position so
- * that the status bar can update.
- */
- public FileEditorTabPane(
- final ChangeListener<Integer> caretPositionListener ) {
- final var tabs = getTabs();
-
- setFocusTraversable( false );
- setTabClosingPolicy( TabClosingPolicy.ALL_TABS );
-
- addTabSelectionListener(
- ( tabPane, oldTab, newTab ) -> {
- if( newTab != null ) {
- mActiveFileEditor.set( (FileEditorTab) newTab );
- }
- }
- );
-
- final ChangeListener<Boolean> modifiedListener =
- ( observable, oldValue, newValue ) -> {
- for( final Tab tab : tabs ) {
- if( ((FileEditorTab) tab).isModified() ) {
- mAnyFileEditorModified.set( true );
- break;
- }
- }
- };
-
- tabs.addListener(
- (ListChangeListener<Tab>) change -> {
- while( change.next() ) {
- if( change.wasAdded() ) {
- change.getAddedSubList().forEach(
- ( tab ) -> {
- final var fet = (FileEditorTab) tab;
- fet.modifiedProperty().addListener( modifiedListener );
- } );
- }
- else if( change.wasRemoved() ) {
- change.getRemoved().forEach(
- ( tab ) -> {
- final var fet = (FileEditorTab) tab;
- fet.modifiedProperty().removeListener( modifiedListener );
- }
- );
- }
- }
-
- // Changes in the tabs may also change anyFileEditorModified property
- // (e.g. closed modified file)
- modifiedListener.changed( null, null, null );
- }
- );
-
- mCaretPositionListener = caretPositionListener;
- }
-
- /**
- * Allows observers to be notified when the current file editor tab changes.
- *
- * @param listener The listener to notify of tab change events.
- */
- public void addTabSelectionListener( final ChangeListener<Tab> listener ) {
- // Observe the tab so that when a new tab is opened or selected,
- // a notification is kicked off.
- getSelectionModel().selectedItemProperty().addListener( listener );
- }
-
- /**
- * Returns the tab that has keyboard focus.
- *
- * @return A non-null instance.
- */
- public FileEditorTab getActiveFileEditor() {
- return mActiveFileEditor.get();
- }
-
- /**
- * Returns the property corresponding to the tab that has focus.
- *
- * @return A non-null instance.
- */
- public ReadOnlyObjectProperty<FileEditorTab> activeFileEditorProperty() {
- return mActiveFileEditor.getReadOnlyProperty();
- }
-
- /**
- * Property that can answer whether the text has been modified.
- *
- * @return A non-null instance, true meaning the content has not been saved.
- */
- ReadOnlyBooleanProperty anyFileEditorModifiedProperty() {
- return mAnyFileEditorModified.getReadOnlyProperty();
- }
-
- /**
- * Creates a new editor instance from the given path.
- *
- * @param path The file to open.
- * @return A non-null instance.
- */
- private FileEditorTab createFileEditor( final Path path ) {
- assert path != null;
-
- final FileEditorTab tab = new FileEditorTab( path );
-
- tab.setOnCloseRequest( e -> {
- if( !canCloseEditor( tab ) ) {
- e.consume();
- }
- else if( isActiveFileEditor( tab ) ) {
- // Prevent prompting the user to save when there are no file editor
- // tabs open.
- mActiveFileEditor.set( null );
- }
- } );
-
- tab.addCaretPositionListener( mCaretPositionListener );
-
- return tab;
- }
-
- private boolean isActiveFileEditor( final FileEditorTab tab ) {
- return getActiveFileEditor() == tab;
- }
-
- private Path getDefaultPath() {
- final String filename = getDefaultFilename();
- return (new File( filename )).toPath();
- }
-
- private String getDefaultFilename() {
- return getSettings().getSetting( "file.default", "untitled.md" );
- }
-
- /**
- * Called to add a new {@link FileEditorTab} to the tab pane.
- */
- void newEditor() {
- final FileEditorTab tab = createFileEditor( getDefaultPath() );
-
- getTabs().add( tab );
- getSelectionModel().select( tab );
- }
-
- void openFileDialog() {
- final FileChooser dialog = createFileChooser(
- "Dialog.file.choose.open.title" );
- final List<File> files = dialog.showOpenMultipleDialog( getWindow() );
-
- if( files != null ) {
- openFiles( files );
- }
- }
-
- /**
- * Opens the files into new editors, unless one of those files was a
- * definition file. The definition file is loaded into the definition pane,
- * but only the first one selected (multiple definition files will result in a
- * warning).
- *
- * @param files The list of non-definition files that the were requested to
- * open.
- */
- private void openFiles( final List<File> files ) {
- final List<String> extensions =
- createExtensionFilter( DEFINITION ).getExtensions();
- final var predicate = createFileTypePredicate( extensions );
-
- // The user might have opened multiple definitions files. These will
- // be discarded from the text editable files.
- final var definitions
- = files.stream().filter( predicate ).collect( Collectors.toList() );
-
- // Create a modifiable list to remove any definition files that were
- // opened.
- final var editors = new ArrayList<>( files );
-
- if( !editors.isEmpty() ) {
- saveLastDirectory( editors.get( 0 ) );
- }
-
- editors.removeAll( definitions );
-
- // Open editor-friendly files (e.g,. Markdown, XML) in new tabs.
- if( !editors.isEmpty() ) {
- openEditors( editors, 0 );
- }
-
- if( !definitions.isEmpty() ) {
- openDefinition( definitions.get( 0 ) );
- }
- }
-
- private void openEditors( final List<File> files, final int activeIndex ) {
- final int fileTally = files.size();
- final List<Tab> tabs = getTabs();
-
- // Close single unmodified "Untitled" tab.
- if( tabs.size() == 1 ) {
- final FileEditorTab fileEditor = (FileEditorTab) (tabs.get( 0 ));
-
- if( fileEditor.getPath() == null && !fileEditor.isModified() ) {
- closeEditor( fileEditor, false );
- }
- }
-
- for( int i = 0; i < fileTally; i++ ) {
- final Path path = files.get( i ).toPath();
-
- FileEditorTab fileEditorTab = findEditor( path );
-
- // Only open new files.
- if( fileEditorTab == null ) {
- fileEditorTab = createFileEditor( path );
- getTabs().add( fileEditorTab );
- }
-
- // Select the first file in the list.
- if( i == activeIndex ) {
- getSelectionModel().select( fileEditorTab );
- }
- }
- }
-
- /**
- * Returns a property that changes when a new definition file is opened.
- *
- * @return The path to a definition file that was opened.
- */
- public ReadOnlyObjectProperty<Path> onOpenDefinitionFileProperty() {
- return getOnOpenDefinitionFile().getReadOnlyProperty();
- }
-
- private ReadOnlyObjectWrapper<Path> getOnOpenDefinitionFile() {
- return mOpenDefinition;
- }
-
- /**
- * Called when the user has opened a definition file (using the file open
- * dialog box). This will replace the current set of definitions for the
- * active tab.
- *
- * @param definition The file to open.
- */
- private void openDefinition( final File definition ) {
- // TODO: Prevent reading this file twice when a new text document is opened.
- // (might be a matter of checking the value first).
- getOnOpenDefinitionFile().set( definition.toPath() );
- }
-
- /**
- * Called when the contents of the editor are to be saved.
- *
- * @param tab The tab containing content to save.
- * @return true The contents were saved (or needn't be saved).
- */
- public boolean saveEditor( final FileEditorTab tab ) {
- if( tab == null || !tab.isModified() ) {
- return true;
- }
-
- return tab.getPath() == null ? saveEditorAs( tab ) : tab.save();
- }
-
- /**
- * Opens the Save As dialog for the user to save the content under a new
- * path.
- *
- * @param tab The tab with contents to save.
- * @return true The contents were saved, or the tab was null.
- */
- public boolean saveEditorAs( final FileEditorTab tab ) {
- if( tab == null ) {
- return true;
- }
-
- getSelectionModel().select( tab );
-
- final FileChooser chooser = createFileChooser(
- "Dialog.file.choose.save.title" );
- final File file = chooser.showSaveDialog( getWindow() );
- if( file == null ) {
- return false;
- }
-
- saveLastDirectory( file );
- tab.setPath( file.toPath() );
-
- return tab.save();
- }
-
- void saveAllEditors() {
- for( final FileEditorTab fileEditor : getAllEditors() ) {
- saveEditor( fileEditor );
- }
- }
-
- /**
- * Answers whether the file has had modifications.
- *
- * @param tab THe tab to check for modifications.
- * @return false The file is unmodified.
- */
- @SuppressWarnings("BooleanMethodIsAlwaysInverted")
- boolean canCloseEditor( final FileEditorTab tab ) {
- final AtomicReference<Boolean> canClose = new AtomicReference<>();
- canClose.set( true );
-
- if( tab.isModified() ) {
- final Notification message = getNotifyService().createNotification(
- Messages.get( "Alert.file.close.title" ),
- Messages.get( "Alert.file.close.text" ),
- tab.getText()
- );
-
- final Alert confirmSave = getNotifyService().createConfirmation(
- getWindow(), message );
-
- final Optional<ButtonType> buttonType = confirmSave.showAndWait();
-
- buttonType.ifPresent(
- save -> canClose.set(
- save == YES ? saveEditor( tab ) : save == ButtonType.NO
+ * @param caretPositionListener Listens for changes to caret position so
+ * that the status bar can update.
+ */
+ public FileEditorTabPane(
+ final ChangeListener<Integer> caretPositionListener ) {
+ final var tabs = getTabs();
+
+ setFocusTraversable( false );
+ setTabClosingPolicy( TabClosingPolicy.ALL_TABS );
+
+ addTabSelectionListener(
+ ( tabPane, oldTab, newTab ) -> {
+ if( newTab != null ) {
+ mActiveFileEditor.set( (FileEditorTab) newTab );
+ }
+ }
+ );
+
+ final ChangeListener<Boolean> modifiedListener =
+ ( observable, oldValue, newValue ) -> {
+ for( final Tab tab : tabs ) {
+ if( ((FileEditorTab) tab).isModified() ) {
+ mAnyFileEditorModified.set( true );
+ break;
+ }
+ }
+ };
+
+ tabs.addListener(
+ (ListChangeListener<Tab>) change -> {
+ while( change.next() ) {
+ if( change.wasAdded() ) {
+ change.getAddedSubList().forEach(
+ ( tab ) -> {
+ final var fet = (FileEditorTab) tab;
+ fet.modifiedProperty().addListener( modifiedListener );
+ } );
+ }
+ else if( change.wasRemoved() ) {
+ change.getRemoved().forEach(
+ ( tab ) -> {
+ final var fet = (FileEditorTab) tab;
+ fet.modifiedProperty().removeListener( modifiedListener );
+ }
+ );
+ }
+ }
+
+ // Changes in the tabs may also change anyFileEditorModified property
+ // (e.g. closed modified file)
+ modifiedListener.changed( null, null, null );
+ }
+ );
+
+ mCaretPositionListener = caretPositionListener;
+ }
+
+ /**
+ * Allows observers to be notified when the current file editor tab changes.
+ *
+ * @param listener The listener to notify of tab change events.
+ */
+ public void addTabSelectionListener( final ChangeListener<Tab> listener ) {
+ // Observe the tab so that when a new tab is opened or selected,
+ // a notification is kicked off.
+ getSelectionModel().selectedItemProperty().addListener( listener );
+ }
+
+ /**
+ * Returns the tab that has keyboard focus.
+ *
+ * @return A non-null instance.
+ */
+ public FileEditorTab getActiveFileEditor() {
+ return mActiveFileEditor.get();
+ }
+
+ /**
+ * Returns the property corresponding to the tab that has focus.
+ *
+ * @return A non-null instance.
+ */
+ public ReadOnlyObjectProperty<FileEditorTab> activeFileEditorProperty() {
+ return mActiveFileEditor.getReadOnlyProperty();
+ }
+
+ /**
+ * Property that can answer whether the text has been modified.
+ *
+ * @return A non-null instance, true meaning the content has not been saved.
+ */
+ ReadOnlyBooleanProperty anyFileEditorModifiedProperty() {
+ return mAnyFileEditorModified.getReadOnlyProperty();
+ }
+
+ /**
+ * Creates a new editor instance from the given path.
+ *
+ * @param path The file to open.
+ * @return A non-null instance.
+ */
+ private FileEditorTab createFileEditor( final Path path ) {
+ assert path != null;
+
+ final FileEditorTab tab = new FileEditorTab( path );
+
+ tab.setOnCloseRequest( e -> {
+ if( !canCloseEditor( tab ) ) {
+ e.consume();
+ }
+ else if( isActiveFileEditor( tab ) ) {
+ // Prevent prompting the user to save when there are no file editor
+ // tabs open.
+ mActiveFileEditor.set( null );
+ }
+ } );
+
+ tab.addCaretPositionListener( mCaretPositionListener );
+
+ return tab;
+ }
+
+ private boolean isActiveFileEditor( final FileEditorTab tab ) {
+ return getActiveFileEditor() == tab;
+ }
+
+ private Path getDefaultPath() {
+ final String filename = getDefaultFilename();
+ return (new File( filename )).toPath();
+ }
+
+ private String getDefaultFilename() {
+ return getSettings().getSetting( "file.default", "untitled.md" );
+ }
+
+ /**
+ * Called to add a new {@link FileEditorTab} to the tab pane.
+ */
+ void newEditor() {
+ final FileEditorTab tab = createFileEditor( getDefaultPath() );
+
+ getTabs().add( tab );
+ getSelectionModel().select( tab );
+ }
+
+ void openFileDialog() {
+ final FileChooser dialog = createFileChooser(
+ "Dialog.file.choose.open.title" );
+ final List<File> files = dialog.showOpenMultipleDialog( getWindow() );
+
+ if( files != null ) {
+ openFiles( files );
+ }
+ }
+
+ /**
+ * Opens the files into new editors, unless one of those files was a
+ * definition file. The definition file is loaded into the definition pane,
+ * but only the first one selected (multiple definition files will result in a
+ * warning).
+ *
+ * @param files The list of non-definition files that the were requested to
+ * open.
+ */
+ private void openFiles( final List<File> files ) {
+ final List<String> extensions =
+ createExtensionFilter( DEFINITION ).getExtensions();
+ final var predicate = createFileTypePredicate( extensions );
+
+ // The user might have opened multiple definitions files. These will
+ // be discarded from the text editable files.
+ final var definitions
+ = files.stream().filter( predicate ).collect( Collectors.toList() );
+
+ // Create a modifiable list to remove any definition files that were
+ // opened.
+ final var editors = new ArrayList<>( files );
+
+ if( !editors.isEmpty() ) {
+ saveLastDirectory( editors.get( 0 ) );
+ }
+
+ editors.removeAll( definitions );
+
+ // Open editor-friendly files (e.g,. Markdown, XML) in new tabs.
+ if( !editors.isEmpty() ) {
+ openEditors( editors, 0 );
+ }
+
+ if( !definitions.isEmpty() ) {
+ openDefinition( definitions.get( 0 ) );
+ }
+ }
+
+ private void openEditors( final List<File> files, final int activeIndex ) {
+ final int fileTally = files.size();
+ final List<Tab> tabs = getTabs();
+
+ // Close single unmodified "Untitled" tab.
+ if( tabs.size() == 1 ) {
+ final FileEditorTab fileEditor = (FileEditorTab) (tabs.get( 0 ));
+
+ if( fileEditor.getPath() == null && !fileEditor.isModified() ) {
+ closeEditor( fileEditor, false );
+ }
+ }
+
+ for( int i = 0; i < fileTally; i++ ) {
+ final Path path = files.get( i ).toPath();
+
+ FileEditorTab fileEditorTab = findEditor( path );
+
+ // Only open new files.
+ if( fileEditorTab == null ) {
+ fileEditorTab = createFileEditor( path );
+ getTabs().add( fileEditorTab );
+ }
+
+ // Select the first file in the list.
+ if( i == activeIndex ) {
+ getSelectionModel().select( fileEditorTab );
+ }
+ }
+ }
+
+ /**
+ * Returns a property that changes when a new definition file is opened.
+ *
+ * @return The path to a definition file that was opened.
+ */
+ public ReadOnlyObjectProperty<Path> onOpenDefinitionFileProperty() {
+ return getOnOpenDefinitionFile().getReadOnlyProperty();
+ }
+
+ private ReadOnlyObjectWrapper<Path> getOnOpenDefinitionFile() {
+ return mOpenDefinition;
+ }
+
+ /**
+ * Called when the user has opened a definition file (using the file open
+ * dialog box). This will replace the current set of definitions for the
+ * active tab.
+ *
+ * @param definition The file to open.
+ */
+ private void openDefinition( final File definition ) {
+ // TODO: Prevent reading this file twice when a new text document is opened.
+ // (might be a matter of checking the value first).
+ getOnOpenDefinitionFile().set( definition.toPath() );
+ }
+
+ /**
+ * Opens the Save As dialog for the user to save the content under a new
+ * path.
+ *
+ * @param tab The tab with contents to save.
+ * @return true The contents were saved, or the tab was null.
+ */
+ public boolean saveEditorAs( final FileEditorTab tab ) {
+ if( tab == null ) {
+ return true;
+ }
+
+ getSelectionModel().select( tab );
+
+ final var chooser = createFileChooser( "Dialog.file.choose.save.title" );
+ final var file = chooser.showSaveDialog( getWindow() );
+
+ if( file == null ) {
+ return false;
+ }
+
+ saveLastDirectory( file );
+ tab.setPath( file );
+
+ return tab.save();
+ }
+
+ void saveAllEditors() {
+ for( final var fileEditorTab : getAllEditors() ) {
+ fileEditorTab.save();
+ }
+ }
+
+ /**
+ * Answers whether the file has had modifications.
+ *
+ * @param tab THe tab to check for modifications.
+ * @return false The file is unmodified.
+ */
+ @SuppressWarnings("BooleanMethodIsAlwaysInverted")
+ boolean canCloseEditor( final FileEditorTab tab ) {
+ final AtomicReference<Boolean> canClose = new AtomicReference<>();
+ canClose.set( true );
+
+ if( tab.isModified() ) {
+ final Notification message = getNotifyService().createNotification(
+ Messages.get( "Alert.file.close.title" ),
+ Messages.get( "Alert.file.close.text" ),
+ tab.getText()
+ );
+
+ final Alert confirmSave = getNotifyService().createConfirmation(
+ getWindow(), message );
+
+ final Optional<ButtonType> buttonType = confirmSave.showAndWait();
+
+ buttonType.ifPresent(
+ save -> canClose.set(
+ save == YES ? tab.save() : save == ButtonType.NO
)
);
src/main/java/com/keenwrite/MainWindow.java
}
- private void fileSave() {
- getFileEditorPane().saveEditor( getActiveFileEditorTab() );
- }
-
private void fileSaveAs() {
final FileEditorTab editor = getActiveFileEditorTab();
.setAccelerator( "Shortcut+S" )
.setIcon( FLOPPY_ALT )
- .setAction( e -> fileSave() )
+ .setAction( e -> getActiveFileEditorTab().save() )
.setDisabled( createActiveBooleanProperty(
FileEditorTab::modifiedProperty ).not() )
Delta 707 lines added, 719 lines removed, 12-line decrease