| | 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.ScheduledExecutorService; |
| | -import java.util.concurrent.ScheduledFuture; |
| | -import java.util.concurrent.atomic.AtomicBoolean; |
| | -import java.util.concurrent.atomic.AtomicReference; |
| | -import java.util.function.Consumer; |
| | -import java.util.function.Function; |
| | -import java.util.stream.Collectors; |
| | - |
| | -import static com.keenwrite.ExportFormat.NONE; |
| | -import static com.keenwrite.Launcher.terminate; |
| | -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.io.MediaType.TypeName.TEXT; |
| | -import static com.keenwrite.preferences.AppKeys.*; |
| | -import static com.keenwrite.processors.IdentityProcessor.IDENTITY; |
| | -import static com.keenwrite.processors.ProcessorContext.Mutator; |
| | -import static com.keenwrite.processors.ProcessorContext.builder; |
| | -import static com.keenwrite.processors.ProcessorFactory.createProcessors; |
| | -import static java.awt.Desktop.getDesktop; |
| | -import static java.util.concurrent.Executors.newFixedThreadPool; |
| | -import static java.util.concurrent.Executors.newScheduledThreadPool; |
| | -import static java.util.concurrent.TimeUnit.SECONDS; |
| | -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.ENTER; |
| | -import static javafx.scene.input.KeyCode.SPACE; |
| | -import static javafx.scene.input.KeyCombination.ALT_DOWN; |
| | -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 {@link 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, UNDEFINED |
| | - ); |
| | - |
| | - private final ScheduledExecutorService mSaver = newScheduledThreadPool( 1 ); |
| | - private final AtomicReference<ScheduledFuture<?>> mSaveTask = |
| | - new AtomicReference<>(); |
| | - |
| | - /** |
| | - * 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 List<TabPane> mTabPanes = new ArrayList<>(); |
| | - |
| | - /** |
| | - * 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> mTextEditor = |
| | - 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> mDefinitionEditor; |
| | - |
| | - private final ObjectProperty<SpellChecker> mSpellChecker; |
| | - |
| | - private final TextEditorSpellChecker mEditorSpeller; |
| | - |
| | - /** |
| | - * Called when the definition data is changed. |
| | - */ |
| | - private final EventHandler<TreeModificationEvent<Event>> mTreeHandler = |
| | - event -> { |
| | - process( getTextEditor() ); |
| | - save( getTextDefinition() ); |
| | - }; |
| | - |
| | - /** |
| | - * 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; |
| | - |
| | - private final VariableNameInjector mVariableNameInjector; |
| | - |
| | - private final RBootstrapController mRBootstrapController; |
| | - |
| | - private final DocumentStatistics mStatistics; |
| | - |
| | - @SuppressWarnings( {"FieldCanBeLocal", "unused"} ) |
| | - private final TypesetterInstaller mInstallWizard; |
| | - |
| | - /** |
| | - * 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; |
| | - mSpellChecker = createSpellChecker(); |
| | - mEditorSpeller = createTextEditorSpellChecker( mSpellChecker ); |
| | - mPreview = new HtmlPreview( workspace ); |
| | - mStatistics = new DocumentStatistics( workspace ); |
| | - mTextEditor.set( createMarkdownEditor( DOCUMENT_DEFAULT ) ); |
| | - mDefinitionEditor = createActiveDefinitionEditor( mTextEditor ); |
| | - mVariableNameInjector = new VariableNameInjector( mWorkspace ); |
| | - mRBootstrapController = new RBootstrapController( |
| | - mWorkspace, this::getDefinitions ); |
| | - |
| | - open( collect( 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: Open file names must be persisted before closing all. |
| | - mWorkspace.save(); |
| | - |
| | - if( closeAll() ) { |
| | - Platform.exit(); |
| | - terminate( 0 ); |
| | - } |
| | - |
| | - event.consume(); |
| | - } ) ); |
| | - |
| | - register( this ); |
| | - initAutosave( workspace ); |
| | - |
| | - restoreSession(); |
| | - runLater( this::restoreFocus ); |
| | - |
| | - mInstallWizard = new TypesetterInstaller( workspace ); |
| | - } |
| | - |
| | - /** |
| | - * Called when spellchecking can be run. This will reload the dictionary |
| | - * into memory once, and then re-use it for all the existing text editors. |
| | - * |
| | - * @param event The event to process, having a populated word-frequency map. |
| | - */ |
| | - @Subscribe |
| | - public void handle( final LexiconLoadedEvent event ) { |
| | - final var lexicon = event.getLexicon(); |
| | - |
| | - try { |
| | - final var checker = SymSpellSpeller.forLexicon( lexicon ); |
| | - mSpellChecker.set( checker ); |
| | - } catch( final Exception ex ) { |
| | - clue( ex ); |
| | - } |
| | - } |
| | - |
| | - @Subscribe |
| | - public void handle( final TextEditorFocusEvent event ) { |
| | - mTextEditor.set( event.get() ); |
| | - } |
| | - |
| | - @Subscribe |
| | - public void handle( final TextDefinitionFocusEvent event ) { |
| | - mDefinitionEditor.set( event.get() ); |
| | - } |
| | - |
| | - /** |
| | - * Typically called when a file name is clicked in the preview panel. |
| | - * |
| | - * @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 = getTextEditor().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(); |
| | - } |
| | - } |
| | - |
| | - final var mediaType = MediaTypeExtension.fromFile( eventFile ); |
| | - |
| | - runLater( () -> { |
| | - // Open text files locally. |
| | - if( mediaType.isType( TEXT ) ) { |
| | - open( eventFile ); |
| | - } |
| | - else { |
| | - try { |
| | - // Delegate opening all other file types to the operating system. |
| | - getDesktop().open( eventFile ); |
| | - } catch( final Exception ex ) { |
| | - clue( ex ); |
| | - } |
| | - } |
| | - } ); |
| | - } |
| | - |
| | - @Subscribe |
| | - public void handle( final CaretNavigationEvent event ) { |
| | - runLater( () -> { |
| | - final var textArea = getTextEditor(); |
| | - textArea.moveTo( event.getOffset() ); |
| | - textArea.requestFocus(); |
| | - } ); |
| | - } |
| | - |
| | - @Subscribe |
| | - public void handle( final InsertDefinitionEvent<String> event ) { |
| | - final var leaf = event.getLeaf(); |
| | - final var editor = mTextEditor.get(); |
| | - |
| | - mVariableNameInjector.insert( editor, leaf ); |
| | - } |
| | - |
| | - private void initAutosave( final Workspace workspace ) { |
| | - final var rate = workspace.integerProperty( KEY_EDITOR_AUTOSAVE ); |
| | - |
| | - rate.addListener( |
| | - ( c, o, n ) -> { |
| | - final var taskRef = mSaveTask.get(); |
| | - |
| | - // Prevent multiple autosaves from running. |
| | - if( taskRef != null ) { |
| | - taskRef.cancel( false ); |
| | - } |
| | - |
| | - initAutosave( rate ); |
| | - } |
| | - ); |
| | - |
| | - // Start the save listener (avoids duplicating some code). |
| | - initAutosave( rate ); |
| | - } |
| | - |
| | - private void initAutosave( final IntegerProperty rate ) { |
| | - mSaveTask.set( |
| | - mSaver.scheduleAtFixedRate( |
| | - () -> { |
| | - if( getTextEditor().isModified() ) { |
| | - // Ensure the modified indicator is cleared by running on EDT. |
| | - runLater( this::save ); |
| | - } |
| | - }, 0, rate.intValue(), SECONDS |
| | - ) |
| | - ); |
| | - } |
| | - |
| | - /** |
| | - * TODO: Load divider positions from exported settings, see |
| | - * {@link #collect(SetProperty)} 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. |
| | - * This will exit early if the given file is not a regular file (i.e., a |
| | - * directory). |
| | - * |
| | - * @param inputFile The file to open. |
| | - */ |
| | - private void open( final File inputFile ) { |
| | - // Prevent opening directories (a non-existent "untitled.md" is fine). |
| | - if( !inputFile.isFile() && inputFile.exists() ) { |
| | - return; |
| | - } |
| | - |
| | - final var tab = createTab( inputFile ); |
| | - final var node = tab.getContent(); |
| | - final var mediaType = MediaType.valueFrom( inputFile ); |
| | - final var tabPane = obtainTabPane( mediaType ); |
| | - |
| | - tab.setTooltip( createTooltip( inputFile ) ); |
| | - 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 |
| | - ); |
| | - } |
| | - |
| | - if( inputFile.isFile() ) { |
| | - getRecentFiles().add( inputFile.getAbsolutePath() ); |
| | - } |
| | - } |
| | - |
| | - /** |
| | - * Gives focus to the most recently edited document and attempts to move |
| | - * the caret to the most recently known offset into said document. |
| | - */ |
| | - private void restoreSession() { |
| | - final var workspace = getWorkspace(); |
| | - final var file = workspace.fileProperty( KEY_UI_RECENT_DOCUMENT ); |
| | - final var offset = workspace.integerProperty( KEY_UI_RECENT_OFFSET ); |
| | - |
| | - for( final var pane : mTabPanes ) { |
| | - for( final var tab : pane.getTabs() ) { |
| | - final var tooltip = tab.getTooltip(); |
| | - |
| | - if( tooltip != null ) { |
| | - final var tabName = tooltip.getText(); |
| | - final var fileName = file.getValue().toString(); |
| | - |
| | - if( tabName.equalsIgnoreCase( fileName ) ) { |
| | - final var node = tab.getContent(); |
| | - |
| | - pane.getSelectionModel().select( tab ); |
| | - node.requestFocus(); |
| | - |
| | - if( node instanceof TextEditor editor ) { |
| | - editor.moveTo( offset.getValue() ); |
| | - } |
| | - |
| | - break; |
| | - } |
| | - } |
| | - } |
| | - } |
| | - } |
| | - |
| | - /** |
| | - * Sets the focus to the middle pane, which contains the text editor tabs. |
| | - */ |
| | - private void restoreFocus() { |
| | - // Work around a bug where focusing directly on the middle pane results |
| | - // in the R engine not loading variables properly. |
| | - mTabPanes.get( 0 ).requestFocus(); |
| | - |
| | - // This is the only line that should be required. |
| | - mTabPanes.get( 1 ).requestFocus(); |
| | - } |
| | - |
| | - /** |
| | - * 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. |
| | - */ |
| | +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.ScheduledExecutorService; |
| | +import java.util.concurrent.ScheduledFuture; |
| | +import java.util.concurrent.atomic.AtomicBoolean; |
| | +import java.util.concurrent.atomic.AtomicReference; |
| | +import java.util.function.Consumer; |
| | +import java.util.function.Function; |
| | +import java.util.stream.Collectors; |
| | + |
| | +import static com.keenwrite.ExportFormat.NONE; |
| | +import static com.keenwrite.Launcher.terminate; |
| | +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.io.MediaType.TypeName.TEXT; |
| | +import static com.keenwrite.preferences.AppKeys.*; |
| | +import static com.keenwrite.processors.IdentityProcessor.IDENTITY; |
| | +import static com.keenwrite.processors.ProcessorContext.Mutator; |
| | +import static com.keenwrite.processors.ProcessorContext.builder; |
| | +import static com.keenwrite.processors.ProcessorFactory.createProcessors; |
| | +import static java.awt.Desktop.getDesktop; |
| | +import static java.util.concurrent.Executors.newFixedThreadPool; |
| | +import static java.util.concurrent.Executors.newScheduledThreadPool; |
| | +import static java.util.concurrent.TimeUnit.SECONDS; |
| | +import static java.util.stream.Collectors.groupingBy; |
| | +import static javafx.application.Platform.exit; |
| | +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.ENTER; |
| | +import static javafx.scene.input.KeyCode.SPACE; |
| | +import static javafx.scene.input.KeyCombination.ALT_DOWN; |
| | +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 {@link 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, UNDEFINED |
| | + ); |
| | + |
| | + private final ScheduledExecutorService mSaver = newScheduledThreadPool( 1 ); |
| | + private final AtomicReference<ScheduledFuture<?>> mSaveTask = |
| | + new AtomicReference<>(); |
| | + |
| | + /** |
| | + * 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 List<TabPane> mTabPanes = new ArrayList<>(); |
| | + |
| | + /** |
| | + * 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> mTextEditor = |
| | + 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> mDefinitionEditor; |
| | + |
| | + private final ObjectProperty<SpellChecker> mSpellChecker; |
| | + |
| | + private final TextEditorSpellChecker mEditorSpeller; |
| | + |
| | + /** |
| | + * Called when the definition data is changed. |
| | + */ |
| | + private final EventHandler<TreeModificationEvent<Event>> mTreeHandler = |
| | + event -> { |
| | + process( getTextEditor() ); |
| | + save( getTextDefinition() ); |
| | + }; |
| | + |
| | + /** |
| | + * 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; |
| | + |
| | + private final VariableNameInjector mVariableNameInjector; |
| | + |
| | + private final RBootstrapController mRBootstrapController; |
| | + |
| | + private final DocumentStatistics mStatistics; |
| | + |
| | + @SuppressWarnings( {"FieldCanBeLocal", "unused"} ) |
| | + private final TypesetterInstaller mInstallWizard; |
| | + |
| | + /** |
| | + * 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; |
| | + mSpellChecker = createSpellChecker(); |
| | + mEditorSpeller = createTextEditorSpellChecker( mSpellChecker ); |
| | + mPreview = new HtmlPreview( workspace ); |
| | + mStatistics = new DocumentStatistics( workspace ); |
| | + mTextEditor.set( createMarkdownEditor( DOCUMENT_DEFAULT ) ); |
| | + mDefinitionEditor = createActiveDefinitionEditor( mTextEditor ); |
| | + mVariableNameInjector = new VariableNameInjector( mWorkspace ); |
| | + mRBootstrapController = new RBootstrapController( |
| | + mWorkspace, this::getDefinitions ); |
| | + |
| | + open( collect( 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: Open file names must be persisted before closing all. |
| | + mWorkspace.save(); |
| | + |
| | + if( closeAll() ) { |
| | + exit(); |
| | + terminate( 0 ); |
| | + } |
| | + |
| | + event.consume(); |
| | + } ) ); |
| | + |
| | + register( this ); |
| | + initAutosave( workspace ); |
| | + |
| | + restoreSession(); |
| | + runLater( this::restoreFocus ); |
| | + |
| | + mInstallWizard = new TypesetterInstaller( workspace ); |
| | + } |
| | + |
| | + /** |
| | + * Called when spellchecking can be run. This will reload the dictionary |
| | + * into memory once, and then re-use it for all the existing text editors. |
| | + * |
| | + * @param event The event to process, having a populated word-frequency map. |
| | + */ |
| | + @Subscribe |
| | + public void handle( final LexiconLoadedEvent event ) { |
| | + final var lexicon = event.getLexicon(); |
| | + |
| | + try { |
| | + final var checker = SymSpellSpeller.forLexicon( lexicon ); |
| | + mSpellChecker.set( checker ); |
| | + } catch( final Exception ex ) { |
| | + clue( ex ); |
| | + } |
| | + } |
| | + |
| | + @Subscribe |
| | + public void handle( final TextEditorFocusEvent event ) { |
| | + mTextEditor.set( event.get() ); |
| | + } |
| | + |
| | + @Subscribe |
| | + public void handle( final TextDefinitionFocusEvent event ) { |
| | + mDefinitionEditor.set( event.get() ); |
| | + } |
| | + |
| | + /** |
| | + * Typically called when a file name is clicked in the preview panel. |
| | + * |
| | + * @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 = getTextEditor().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(); |
| | + } |
| | + } |
| | + |
| | + final var mediaType = MediaTypeExtension.fromFile( eventFile ); |
| | + |
| | + runLater( () -> { |
| | + // Open text files locally. |
| | + if( mediaType.isType( TEXT ) ) { |
| | + open( eventFile ); |
| | + } |
| | + else { |
| | + try { |
| | + // Delegate opening all other file types to the operating system. |
| | + getDesktop().open( eventFile ); |
| | + } catch( final Exception ex ) { |
| | + clue( ex ); |
| | + } |
| | + } |
| | + } ); |
| | + } |
| | + |
| | + @Subscribe |
| | + public void handle( final CaretNavigationEvent event ) { |
| | + runLater( () -> { |
| | + final var textArea = getTextEditor(); |
| | + textArea.moveTo( event.getOffset() ); |
| | + textArea.requestFocus(); |
| | + } ); |
| | + } |
| | + |
| | + @Subscribe |
| | + public void handle( final InsertDefinitionEvent<String> event ) { |
| | + final var leaf = event.getLeaf(); |
| | + final var editor = mTextEditor.get(); |
| | + |
| | + mVariableNameInjector.insert( editor, leaf ); |
| | + } |
| | + |
| | + private void initAutosave( final Workspace workspace ) { |
| | + final var rate = workspace.integerProperty( KEY_EDITOR_AUTOSAVE ); |
| | + |
| | + rate.addListener( |
| | + ( c, o, n ) -> { |
| | + final var taskRef = mSaveTask.get(); |
| | + |
| | + // Prevent multiple autosaves from running. |
| | + if( taskRef != null ) { |
| | + taskRef.cancel( false ); |
| | + } |
| | + |
| | + initAutosave( rate ); |
| | + } |
| | + ); |
| | + |
| | + // Start the save listener (avoids duplicating some code). |
| | + initAutosave( rate ); |
| | + } |
| | + |
| | + private void initAutosave( final IntegerProperty rate ) { |
| | + mSaveTask.set( |
| | + mSaver.scheduleAtFixedRate( |
| | + () -> { |
| | + if( getTextEditor().isModified() ) { |
| | + // Ensure the modified indicator is cleared by running on EDT. |
| | + runLater( this::save ); |
| | + } |
| | + }, 0, rate.intValue(), SECONDS |
| | + ) |
| | + ); |
| | + } |
| | + |
| | + /** |
| | + * TODO: Load divider positions from exported settings, see |
| | + * {@link #collect(SetProperty)} 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. |
| | + * This will exit early if the given file is not a regular file (i.e., a |
| | + * directory). |
| | + * |
| | + * @param inputFile The file to open. |
| | + */ |
| | + private void open( final File inputFile ) { |
| | + // Prevent opening directories (a non-existent "untitled.md" is fine). |
| | + if( !inputFile.isFile() && inputFile.exists() ) { |
| | + return; |
| | + } |
| | + |
| | + final var tab = createTab( inputFile ); |
| | + final var node = tab.getContent(); |
| | + final var mediaType = MediaType.valueFrom( inputFile ); |
| | + final var tabPane = obtainTabPane( mediaType ); |
| | + |
| | + tab.setTooltip( createTooltip( inputFile ) ); |
| | + 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 |
| | + ); |
| | + } |
| | + |
| | + if( inputFile.isFile() ) { |
| | + getRecentFiles().add( inputFile.getAbsolutePath() ); |
| | + } |
| | + } |
| | + |
| | + /** |
| | + * Gives focus to the most recently edited document and attempts to move |
| | + * the caret to the most recently known offset into said document. |
| | + */ |
| | + private void restoreSession() { |
| | + final var workspace = getWorkspace(); |
| | + final var file = workspace.fileProperty( KEY_UI_RECENT_DOCUMENT ); |
| | + final var offset = workspace.integerProperty( KEY_UI_RECENT_OFFSET ); |
| | + |
| | + for( final var pane : mTabPanes ) { |
| | + for( final var tab : pane.getTabs() ) { |
| | + final var tooltip = tab.getTooltip(); |
| | + |
| | + if( tooltip != null ) { |
| | + final var tabName = tooltip.getText(); |
| | + final var fileName = file.getValue().toString(); |
| | + |
| | + if( tabName.equalsIgnoreCase( fileName ) ) { |
| | + final var node = tab.getContent(); |
| | + |
| | + pane.getSelectionModel().select( tab ); |
| | + node.requestFocus(); |
| | + |
| | + if( node instanceof TextEditor editor ) { |
| | + runLater( () -> editor.moveTo( offset.getValue() ) ); |
| | + } |
| | + |
| | + break; |
| | + } |
| | + } |
| | + } |
| | + } |
| | + } |
| | + |
| | + /** |
| | + * Sets the focus to the middle pane, which contains the text editor tabs. |
| | + */ |
| | + private void restoreFocus() { |
| | + // Work around a bug where focusing directly on the middle pane results |
| | + // in the R engine not loading variables properly. |
| | + mTabPanes.get( 0 ).requestFocus(); |
| | + |
| | + // This is the only line that should be required. |
| | + mTabPanes.get( 1 ).requestFocus(); |
| | + } |
| | + |
| | + /** |
| | + * 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. |
| | + */ |
| | + @SuppressWarnings( "unused" ) |
| | public void newDefinitionEditor() { |
| | open( DEFINITION_DEFAULT ); |