| mStatistics = new DocumentStatistics( workspace ); | ||
| - mTextEditor.addListener( ( c, o, n ) -> { | ||
| - if( o != null ) { | ||
| - removeProcessor( o ); | ||
| - } | ||
| - | ||
| - if( n != null ) { | ||
| - mPreview.setBaseUri( n.getPath() ); | ||
| - updateProcessors( n ); | ||
| - process( n ); | ||
| - } | ||
| - } ); | ||
| - | ||
| - mTextEditor.set( createMarkdownEditor( DOCUMENT_DEFAULT ) ); | ||
| - mDefinitionEditor.set( createDefinitionEditor( workspace ) ); | ||
| - mVariableNameInjector = new VariableNameInjector( workspace ); | ||
| - mRBootstrapController = new RBootstrapController( | ||
| - workspace, mDefinitionEditor.get()::getDefinitions | ||
| - ); | ||
| - | ||
| - // If the user modifies the definitions, re-process the variables. | ||
| - mDefinitionEditor.addListener( ( c, o, n ) -> { | ||
| - final var textEditor = getTextEditor(); | ||
| - | ||
| - if( textEditor.isMediaType( TEXT_R_MARKDOWN ) ) { | ||
| - mRBootstrapController.update(); | ||
| - } | ||
| - | ||
| - process( textEditor ); | ||
| - } ); | ||
| - | ||
| - 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 = toFile( Path.of( parentPath, eventUri.getPath() ) ); | ||
| - } | ||
| - } | ||
| - | ||
| - 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 auto-saves 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 mediaType = fromFilename( inputFile ); | ||
| - | ||
| - // Only allow opening text files. | ||
| - if( !mediaType.isType( TEXT ) ) { | ||
| - return; | ||
| - } | ||
| - | ||
| - final var tab = createTab( inputFile ); | ||
| - final var node = tab.getContent(); | ||
| - 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.get().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 a document file name that doesn't | ||
| - * clash with an existing document. | ||
| - */ | ||
| - public void newTextEditor() { | ||
| - final String key = "file.default.document."; | ||
| - final String prefix = Constants.get( STR."\{key}prefix" ); | ||
| - final String suffix = Constants.get( STR."\{key}suffix" ); | ||
| - | ||
| - File file = new File( STR."\{prefix}.\{suffix}" ); | ||
| - int i = 0; | ||
| - | ||
| - while( file.exists() && i++ < 100 ) { | ||
| - file = new File( STR."\{prefix}-\{i}.\{suffix}" ); | ||
| - } | ||
| - | ||
| - open( file ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Opens a new definition editor document using the default definition | ||
| - * file name. | ||
| - */ | ||
| - @SuppressWarnings( "unused" ) | ||
| - 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() { | ||
| - iterateEditors( this::save ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * 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( getTextEditor() ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Saves the active {@link TextEditor} under a new name. | ||
| - * | ||
| - * @param files The new active editor {@link File} reference, must contain | ||
| - * at least one element. | ||
| - */ | ||
| - public void saveAs( final List<File> files ) { | ||
| - assert files != null; | ||
| - assert !files.isEmpty(); | ||
| - final var editor = getTextEditor(); | ||
| - final var tab = getTab( editor ); | ||
| - final var file = files.get( 0 ); | ||
| - | ||
| - // If the file type has changed, refresh the processors. | ||
| - final var mediaType = fromFilename( file ); | ||
| - final var typeChanged = !editor.isMediaType( mediaType ); | ||
| - | ||
| - if( typeChanged ) { | ||
| - removeProcessor( editor ); | ||
| - } | ||
| - | ||
| - editor.rename( file ); | ||
| - tab.ifPresent( t -> { | ||
| - t.setText( editor.getFilename() ); | ||
| - t.setTooltip( createTooltip( file ) ); | ||
| - } ); | ||
| - | ||
| - if( typeChanged ) { | ||
| - updateProcessors( editor ); | ||
| - process( editor ); | ||
| - } | ||
| - | ||
| - 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 tabPane : mTabPanes ) { | ||
| - final var tabIterator = tabPane.getTabs().iterator(); | ||
| - | ||
| - while( tabIterator.hasNext() ) { | ||
| - final var tab = tabIterator.next(); | ||
| - final var resource = tab.getContent(); | ||
| - | ||
| - // The definition panes auto-save, so being specific here prevents | ||
| - // closing the definitions in the situation where the user wants to | ||
| - // continue editing (i.e., possibly save unsaved work). | ||
| - if( !(resource instanceof TextEditor) ) { | ||
| - continue; | ||
| - } | ||
| - | ||
| - if( canClose( (TextEditor) resource ) ) { | ||
| - tabIterator.remove(); | ||
| - close( tab ); | ||
| - } | ||
| - else { | ||
| - closable = false; | ||
| - } | ||
| - } | ||
| - } | ||
| - | ||
| - return closable; | ||
| - } | ||
| - | ||
| - /** | ||
| - * Calls the tab's {@link Tab#getOnClosed()} handler to carry out a close | ||
| - * event. | ||
| - * | ||
| - * @param tab The {@link Tab} that was closed. | ||
| - */ | ||
| - private void close( final Tab tab ) { | ||
| - assert tab != null; | ||
| - | ||
| - final var handler = tab.getOnClosed(); | ||
| - | ||
| - if( handler != null ) { | ||
| - handler.handle( new ActionEvent() ); | ||
| - } | ||
| - } | ||
| - | ||
| - /** | ||
| - * Closes the active tab; delegates to {@link #canClose(TextResource)}. | ||
| - */ | ||
| - public void close() { | ||
| - final var editor = getTextEditor(); | ||
| - | ||
| - if( canClose( editor ) ) { | ||
| - close( editor ); | ||
| - removeProcessor( editor ); | ||
| - } | ||
| - } | ||
| - | ||
| - /** | ||
| - * Closes the given {@link TextResource}. This must not be called from within | ||
| - * a loop that iterates over the tab panes using {@code forEach}, lest a | ||
| - * concurrent modification exception be thrown. | ||
| - * | ||
| - * @param resource The {@link TextResource} to close, without confirming with | ||
| - * the user. | ||
| - */ | ||
| - private void close( final TextResource resource ) { | ||
| - getTab( resource ).ifPresent( | ||
| - tab -> { | ||
| - close( tab ); | ||
| - tab.getTabPane().getTabs().remove( tab ); | ||
| - } | ||
| - ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Answers whether the given {@link TextResource} may be closed. | ||
| - * | ||
| - * @param editor The {@link TextResource} to try closing. | ||
| - * @return {@code true} when the editor may be closed; {@code false} when | ||
| - * the user has requested to keep the editor open. | ||
| - */ | ||
| - private boolean canClose( final TextResource editor ) { | ||
| - final var editorTab = getTab( editor ); | ||
| - final var canClose = new AtomicBoolean( true ); | ||
| - | ||
| - if( editor.isModified() ) { | ||
| - final var filename = new StringBuilder(); | ||
| - editorTab.ifPresent( tab -> filename.append( tab.getText() ) ); | ||
| - | ||
| - final var message = sNotifier.createNotification( | ||
| - Messages.get( "Alert.file.close.title" ), | ||
| - Messages.get( "Alert.file.close.text" ), | ||
| - filename.toString() | ||
| - ); | ||
| - | ||
| - final var dialog = sNotifier.createConfirmation( getWindow(), message ); | ||
| - | ||
| - dialog.showAndWait().ifPresent( | ||
| - save -> canClose.set( save == YES ? editor.save() : save == NO ) | ||
| - ); | ||
| - } | ||
| - | ||
| - return canClose.get(); | ||
| - } | ||
| - | ||
| - private void iterateEditors( final Consumer<TextEditor> consumer ) { | ||
| - mTabPanes.forEach( | ||
| - tp -> tp.getTabs().forEach( tab -> { | ||
| - final var node = tab.getContent(); | ||
| - | ||
| - if( node instanceof final TextEditor editor ) { | ||
| - consumer.accept( editor ); | ||
| - } | ||
| - } ) | ||
| - ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Adds the HTML preview tab to its own, singular tab pane. | ||
| - */ | ||
| - public void viewPreview() { | ||
| - addTab( mPreview, TEXT_HTML, "Pane.preview.title" ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Adds the document outline tab to its own, singular tab pane. | ||
| - */ | ||
| - public void viewOutline() { | ||
| - addTab( mOutline, APP_DOCUMENT_OUTLINE, "Pane.outline.title" ); | ||
| - } | ||
| - | ||
| - public void viewStatistics() { | ||
| - addTab( mStatistics, APP_DOCUMENT_STATISTICS, "Pane.statistics.title" ); | ||
| - } | ||
| - | ||
| - public void viewFiles() { | ||
| - try { | ||
| - final var factory = new FilePickerFactory( getWorkspace() ); | ||
| - final var fileManager = factory.createModeless(); | ||
| - addTab( fileManager, APP_FILE_MANAGER, "Pane.files.title" ); | ||
| - } catch( final Exception ex ) { | ||
| - clue( ex ); | ||
| - } | ||
| - } | ||
| - | ||
| - public void viewRefresh() { | ||
| - mPreview.refresh(); | ||
| - Engine.clear(); | ||
| - mRBootstrapController.update(); | ||
| - } | ||
| - | ||
| - private void addTab( | ||
| - final Node node, final MediaType mediaType, final String key ) { | ||
| - final var tabPane = obtainTabPane( mediaType ); | ||
| - | ||
| - for( final var tab : tabPane.getTabs() ) { | ||
| - if( tab.getContent() == node ) { | ||
| - return; | ||
| - } | ||
| - } | ||
| - | ||
| - tabPane.getTabs().add( createTab( get( key ), node ) ); | ||
| - addTabPane( tabPane ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * 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 TextResource editor ) { | ||
| - return mTabPanes.stream() | ||
| - .flatMap( pane -> pane.getTabs().stream() ) | ||
| - .filter( tab -> editor.equals( tab.getContent() ) ) | ||
| - .findFirst(); | ||
| - } | ||
| - | ||
| - private TextDefinition createDefinitionEditor( final File file ) { | ||
| - final var editor = new DefinitionEditor( file, createTreeTransformer() ); | ||
| - | ||
| - editor.addTreeChangeHandler( mTreeHandler ); | ||
| - | ||
| - return editor; | ||
| - } | ||
| - | ||
| - /** | ||
| - * Creates a new {@link DefinitionEditor} wrapped in a listener that | ||
| - * is used to detect when the active {@link DefinitionEditor} has changed. | ||
| - * Upon changing, the variables are interpolated and the active text editor | ||
| - * is refreshed. | ||
| - * | ||
| - * @param workspace Has the most recently edited definitions file name. | ||
| - * @return A newly configured property that represents the active | ||
| - * {@link DefinitionEditor}, never {@code null}. | ||
| - */ | ||
| - private TextDefinition createDefinitionEditor( | ||
| - final Workspace workspace ) { | ||
| - final var fileProperty = workspace.fileProperty( KEY_UI_RECENT_DEFINITION ); | ||
| - final var filename = fileProperty.get(); | ||
| - final SetProperty<String> recent = workspace.setsProperty( | ||
| - KEY_UI_RECENT_OPEN_PATH | ||
| - ); | ||
| - | ||
| - // Open the most recently used YAML definition file. | ||
| - for( final var recentFile : recent.get() ) { | ||
| - if( recentFile.endsWith( filename.toString() ) ) { | ||
| - return createDefinitionEditor( new File( recentFile ) ); | ||
| - } | ||
| - } | ||
| - | ||
| - return createDefaultDefinitionEditor(); | ||
| - } | ||
| - | ||
| - private TextDefinition createDefaultDefinitionEditor() { | ||
| - final var transformer = createTreeTransformer(); | ||
| - return new DefinitionEditor( transformer ); | ||
| - } | ||
| - | ||
| - private TreeTransformer createTreeTransformer() { | ||
| - return new YamlTreeTransformer(); | ||
| - } | ||
| - | ||
| - private Tab createTab( final String filename, final Node node ) { | ||
| - return new DetachableTab( filename, node ); | ||
| - } | ||
| - | ||
| - private Tab createTab( final File file ) { | ||
| - final var r = createTextResource( file ); | ||
| - final var filename = r.getFilename(); | ||
| - final var tab = createTab( filename, r.getNode() ); | ||
| - | ||
| - r.modifiedProperty().addListener( | ||
| - ( c, o, n ) -> tab.setText( filename + (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() ) | ||
| - ); | ||
| - | ||
| - // When closing a tab, give focus to the newly revealed tab. | ||
| - tab.selectedProperty().addListener( ( c, o, n ) -> { | ||
| - if( n != null && n ) { | ||
| - final var pane = tab.getTabPane(); | ||
| - | ||
| - if( pane != null ) { | ||
| - pane.requestFocus(); | ||
| - } | ||
| - } | ||
| - } ); | ||
| - | ||
| - tab.tabPaneProperty().addListener( ( cPane, oPane, nPane ) -> { | ||
| - if( nPane != null ) { | ||
| - nPane.focusedProperty().addListener( ( c, o, n ) -> { | ||
| - if( n != null && n ) { | ||
| - final var selected = nPane.getSelectionModel().getSelectedItem(); | ||
| - final var node = selected.getContent(); | ||
| - node.requestFocus(); | ||
| - } | ||
| - } ); | ||
| - } | ||
| - } ); | ||
| - | ||
| - 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> collect( 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. The order that | ||
| - // the elements are listed in the enumeration for media types determines | ||
| - // what files are loaded first. Variable definitions come before all other | ||
| - // plain text documents. | ||
| - final var bins = paths | ||
| - .stream() | ||
| - .collect( | ||
| - groupingBy( | ||
| - path -> bin.apply( fromFilename( path ) ), | ||
| - () -> new TreeMap<>( Enum::compareTo ), | ||
| - Collectors.toList() | ||
| - ) | ||
| - ); | ||
| - | ||
| - bins.putIfAbsent( TEXT_YAML, List.of( DEFINITION_DEFAULT.toString() ) ); | ||
| - bins.putIfAbsent( TEXT_PLAIN, List.of( DOCUMENT_DEFAULT.toString() ) ); | ||
| - | ||
| - final var result = new LinkedList<File>(); | ||
| - | ||
| - // Ensure that the same types are listed together (keep insertion order). | ||
| - bins.forEach( ( mediaType, files ) -> result.addAll( | ||
| - files.stream().map( File::new ).toList() ) | ||
| - ); | ||
| - | ||
| - return result; | ||
| - } | ||
| - | ||
| - /** | ||
| - * 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 ) { | ||
| - // Ensure processing does not run on the JavaFX thread, which frees the | ||
| - // text editor immediately for caret movement. The preview will have a | ||
| - // slight delay when catching up to the caret position. | ||
| - final var task = new Task<Void>() { | ||
| - @Override | ||
| - public Void call() { | ||
| - try { | ||
| - final var p = mProcessors.getOrDefault( editor, IDENTITY ); | ||
| - p.apply( editor == null ? "" : editor.getText() ); | ||
| - } catch( final Exception ex ) { | ||
| - clue( ex ); | ||
| - } | ||
| - | ||
| - return null; | ||
| - } | ||
| - }; | ||
| - | ||
| - // TODO: Each time the editor successfully runs the processor, the task is | ||
| - // considered successful. Due to the rapid-fire nature of processing | ||
| - // (e.g., keyboard navigation, fast typing), it isn't necessary to | ||
| - // scroll each time. | ||
| - // The algorithm: | ||
| - // 1. Peek at the oldest time. | ||
| - // 2. If the difference between the oldest time and current time exceeds | ||
| - // 250 milliseconds, then invoke the scrolling. | ||
| - // 3. Insert the current time into the circular queue. | ||
| - task.setOnSucceeded( | ||
| - e -> invokeLater( () -> mPreview.scrollTo( CARET_ID ) ) | ||
| - ); | ||
| - | ||
| - // Prevents multiple process requests from executing simultaneously (due | ||
| - // to having a restricted queue size). | ||
| - sExecutor.execute( task ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Lazily creates a {@link TabPane} configured to listen for tab select | ||
| - * events. 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 TabPane} that will handle tab docking. | ||
| - */ | ||
| - private TabPane obtainTabPane( final MediaType mediaType ) { | ||
| - for( final var pane : mTabPanes ) { | ||
| - for( final var tab : pane.getTabs() ) { | ||
| - final var node = tab.getContent(); | ||
| - | ||
| - if( node instanceof TextResource r && r.supports( mediaType ) ) { | ||
| - return pane; | ||
| - } | ||
| - } | ||
| - } | ||
| - | ||
| - final var pane = createTabPane(); | ||
| - mTabPanes.add( pane ); | ||
| - return pane; | ||
| - } | ||
| - | ||
| - /** | ||
| - * Creates an initialized {@link TabPane} instance. | ||
| - * | ||
| - * @return A new {@link TabPane} with all listeners configured. | ||
| - */ | ||
| - private TabPane createTabPane() { | ||
| - final var tabPane = new DetachableTabPane(); | ||
| - | ||
| - initStageOwnerFactory( tabPane ); | ||
| - initTabListener( 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 TabPane} to configure. | ||
| - */ | ||
| - private void initTabListener( final TabPane 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(); | ||
| - } | ||
| - } | ||
| - } | ||
| - } | ||
| - ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * 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 = mPreview.getVerticalScrollBar(); | ||
| - final var handler = new ScrollEventHandler( scrollPane, scrollBar ); | ||
| - | ||
| - handler.enabledProperty().bind( tab.selectedProperty() ); | ||
| - } | ||
| - | ||
| - private void addTabPane( final int index, final TabPane tabPane ) { | ||
| - final var items = getItems(); | ||
| - | ||
| - if( !items.contains( tabPane ) ) { | ||
| - items.add( index, tabPane ); | ||
| - } | ||
| - } | ||
| - | ||
| - private void addTabPane( final TabPane tabPane ) { | ||
| - addTabPane( getItems().size(), tabPane ); | ||
| - } | ||
| - | ||
| - private GenericBuilder<Mutator, ProcessorContext> processorContextBuilder() { | ||
| - final var w = getWorkspace(); | ||
| - | ||
| - return builder() | ||
| - .with( Mutator::setDefinitions, this::getDefinitions ) | ||
| - .with( Mutator::setLocale, w::getLocale ) | ||
| - .with( Mutator::setMetadata, w::getMetadata ) | ||
| - .with( Mutator::setThemeDir, w::getThemesPath ) | ||
| - .with( Mutator::setCacheDir, | ||
| - () -> w.getFile( KEY_CACHE_DIR ) ) | ||
| - .with( Mutator::setImageDir, | ||
| - () -> w.getFile( KEY_IMAGE_DIR ) ) | ||
| - .with( Mutator::setImageOrder, | ||
| - () -> w.getString( KEY_IMAGE_ORDER ) ) | ||
| - .with( Mutator::setImageServer, | ||
| - () -> w.getString( KEY_IMAGE_SERVER ) ) | ||
| - .with( Mutator::setFontDir, | ||
| - () -> w.getFile( KEY_TYPESET_CONTEXT_FONTS_DIR ) ) | ||
| - .with( Mutator::setCaret, | ||
| - () -> getTextEditor().getCaret() ) | ||
| - .with( Mutator::setSigilBegan, | ||
| - () -> w.getString( KEY_DEF_DELIM_BEGAN ) ) | ||
| - .with( Mutator::setSigilEnded, | ||
| - () -> w.getString( KEY_DEF_DELIM_ENDED ) ) | ||
| - .with( Mutator::setRScript, | ||
| - () -> w.getString( KEY_R_SCRIPT ) ) | ||
| - .with( Mutator::setRWorkingDir, | ||
| - () -> w.getFile( KEY_R_DIR ).toPath() ) | ||
| - .with( Mutator::setCurlQuotes, | ||
| - () -> w.getBoolean( KEY_TYPESET_TYPOGRAPHY_QUOTES ) ) | ||
| - .with( Mutator::setAutoRemove, | ||
| - () -> w.getBoolean( KEY_TYPESET_CONTEXT_CLEAN ) ); | ||
| - } | ||
| - | ||
| - public ProcessorContext createProcessorContext() { | ||
| - return createProcessorContextBuilder( NONE ).build(); | ||
| - } | ||
| - | ||
| - private GenericBuilder<Mutator, ProcessorContext> createProcessorContextBuilder( | ||
| - final ExportFormat format ) { | ||
| - final var textEditor = getTextEditor(); | ||
| - final var sourcePath = textEditor.getPath(); | ||
| - | ||
| - return processorContextBuilder() | ||
| - .with( Mutator::setSourcePath, sourcePath ) | ||
| - .with( Mutator::setExportFormat, format ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * @param targetPath Used when exporting to a PDF file (binary). | ||
| - * @param format Used when processors export to a new text format. | ||
| - * @return A new {@link ProcessorContext} to use when creating an instance of | ||
| - * {@link Processor}. | ||
| - */ | ||
| - public ProcessorContext createProcessorContext( | ||
| - final Path targetPath, final ExportFormat format ) { | ||
| - assert targetPath != null; | ||
| - assert format != null; | ||
| - | ||
| - return createProcessorContextBuilder( format ) | ||
| - .with( Mutator::setTargetPath, targetPath ) | ||
| - .build(); | ||
| - } | ||
| - | ||
| - /** | ||
| - * @param sourcePath Used by {@link ProcessorFactory} to determine | ||
| - * {@link Processor} type to create based on file type. | ||
| - * @return A new {@link ProcessorContext} to use when creating an instance of | ||
| - * {@link Processor}. | ||
| - */ | ||
| - private ProcessorContext createProcessorContext( final Path sourcePath ) { | ||
| - return processorContextBuilder() | ||
| - .with( Mutator::setSourcePath, sourcePath ) | ||
| - .with( Mutator::setExportFormat, NONE ) | ||
| - .build(); | ||
| - } | ||
| - | ||
| - private TextResource createTextResource( final File file ) { | ||
| - if( fromFilename( file ) == TEXT_YAML ) { | ||
| - final var editor = createDefinitionEditor( file ); | ||
| - mDefinitionEditor.set( editor ); | ||
| - return editor; | ||
| - } | ||
| - else { | ||
| - final var editor = createMarkdownEditor( file ); | ||
| - mTextEditor.set( editor ); | ||
| - return editor; | ||
| - } | ||
| - } | ||
| - | ||
| - /** | ||
| - * Creates an instance of {@link MarkdownEditor} that listens for both | ||
| - * caret change events and text change events. Text change events must | ||
| - * take priority over caret change events because it's possible to change | ||
| - * the text without moving the caret (e.g., delete selected text). | ||
| - * | ||
| - * @param inputFile The file containing contents for the text editor. | ||
| - * @return A non-null text editor. | ||
| - */ | ||
| - private MarkdownEditor createMarkdownEditor( final File inputFile ) { | ||
| - final var editor = new MarkdownEditor( inputFile, getWorkspace() ); | ||
| - | ||
| - // Listener for editor modifications or caret position changes. | ||
| - editor.addDirtyListener( ( c, o, n ) -> { | ||
| - if( n ) { | ||
| - // Reset the status bar after changing the text. | ||
| - clue(); | ||
| - | ||
| - // Processing the text may update the status bar. | ||
| - process( editor ); | ||
| - | ||
| - // Update the caret position in the status bar. | ||
| - CaretMovedEvent.fire( editor.getCaret() ); | ||
| - } | ||
| - } ); | ||
| - | ||
| - editor.addEventListener( | ||
| - keyPressed( SPACE, CONTROL_DOWN ), this::autoinsert | ||
| - ); | ||
| - | ||
| - editor.addEventListener( | ||
| - keyPressed( ENTER, ALT_DOWN ), event -> mEditorSpeller.autofix( editor ) | ||
| - ); | ||
| - | ||
| - final var textArea = editor.getTextArea(); | ||
| - | ||
| - // Spell check when the paragraph changes. | ||
| - textArea | ||
| - .plainTextChanges() | ||
| - .filter( p -> !p.isIdentity() ) | ||
| - .subscribe( change -> mEditorSpeller.checkParagraph( textArea, change ) ); | ||
| - | ||
| - // Store the caret position to restore it after restarting the application. | ||
| - textArea.caretPositionProperty().addListener( | ||
| - ( c, o, n ) -> | ||
| - getWorkspace().integerProperty( KEY_UI_RECENT_OFFSET ).setValue( n ) | ||
| - ); | ||
| - | ||
| - // Check the entire document after the spellchecker is initialized (with | ||
| - // a valid lexicon) so that only the current paragraph need be scanned | ||
| - // while editing. (Technically, only the most recently modified word must | ||
| - // be scanned.) | ||
| - mSpellChecker.addListener( | ||
| - ( c, o, n ) -> runLater( | ||
| - () -> iterateEditors( mEditorSpeller::checkDocument ) | ||
| - ) | ||
| - ); | ||
| - | ||
| - // Check the entire document after it has been loaded. | ||
| - mEditorSpeller.checkDocument( editor ); | ||
| - | ||
| - return editor; | ||
| - } | ||
| - | ||
| - /** | ||
| - * Creates a processor for an editor, provided one doesn't already exist. | ||
| - * | ||
| - * @param editor The editor that potentially requires an associated processor. | ||
| - */ | ||
| - private void updateProcessors( final TextEditor editor ) { | ||
| - final var path = editor.getFile().toPath(); | ||
| - | ||
| - mProcessors.computeIfAbsent( | ||
| - editor, p -> createProcessors( | ||
| - createProcessorContext( path ), | ||
| - createHtmlPreviewProcessor() | ||
| - ) | ||
| - ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Removes a processor for an editor. This is required because a file may | ||
| - * change type while editing (e.g., from plain Markdown to R Markdown). | ||
| - * In the case that an editor's type changes, its associated processor must | ||
| - * be changed accordingly. | ||
| - * | ||
| - * @param editor The editor that potentially requires an associated processor. | ||
| - */ | ||
| - private void removeProcessor( final TextEditor editor ) { | ||
| - mProcessors.remove( editor ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Creates a {@link Processor} capable of rendering an HTML document onto | ||
| - * a GUI widget. | ||
| - * | ||
| - * @return The {@link Processor} for rendering an HTML document. | ||
| - */ | ||
| - private Processor<String> createHtmlPreviewProcessor() { | ||
| - return new HtmlPreviewProcessor( getPreview() ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Creates a spellchecker that accepts all words as correct. This allows | ||
| - * the spellchecker property to be initialized to a known valid value. | ||
| - * | ||
| - * @return A wrapped {@link PermissiveSpeller}. | ||
| - */ | ||
| - private ObjectProperty<SpellChecker> createSpellChecker() { | ||
| - return new SimpleObjectProperty<>( new PermissiveSpeller() ); | ||
| - } | ||
| - | ||
| - private TextEditorSpellChecker createTextEditorSpellChecker( | ||
| - final ObjectProperty<SpellChecker> spellChecker ) { | ||
| - return new TextEditorSpellChecker( spellChecker ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Delegates to {@link #autoinsert()}. | ||
| - * | ||
| - * @param keyEvent Ignored. | ||
| - */ | ||
| - private void autoinsert( final KeyEvent keyEvent ) { | ||
| + mTextEditor.addListener( ( _, o, n ) -> { | ||
| + if( o != null ) { | ||
| + removeProcessor( o ); | ||
| + } | ||
| + | ||
| + if( n != null ) { | ||
| + mPreview.setBaseUri( n.getPath() ); | ||
| + updateProcessors( n ); | ||
| + process( n ); | ||
| + } | ||
| + } ); | ||
| + | ||
| + mTextEditor.set( createMarkdownEditor( DOCUMENT_DEFAULT ) ); | ||
| + mDefinitionEditor.set( createDefinitionEditor( workspace ) ); | ||
| + mVariableNameInjector = new VariableNameInjector( workspace ); | ||
| + mRBootstrapController = new RBootstrapController( | ||
| + workspace, mDefinitionEditor.get()::getDefinitions | ||
| + ); | ||
| + | ||
| + // If the user modifies the definitions, re-process the variables. | ||
| + mDefinitionEditor.addListener( ( _, _, _ ) -> { | ||
| + final var textEditor = getTextEditor(); | ||
| + | ||
| + if( textEditor.isMediaType( TEXT_R_MARKDOWN ) ) { | ||
| + mRBootstrapController.update(); | ||
| + } | ||
| + | ||
| + process( textEditor ); | ||
| + } ); | ||
| + | ||
| + 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 = toFile( Path.of( parentPath, eventUri.getPath() ) ); | ||
| + } | ||
| + } | ||
| + | ||
| + 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( | ||
| + ( _, _, _ ) -> { | ||
| + final var taskRef = mSaveTask.get(); | ||
| + | ||
| + // Prevent multiple auto-saves 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 mediaType = fromFilename( inputFile ); | ||
| + | ||
| + // Only allow opening text files. | ||
| + if( !mediaType.isType( TEXT ) ) { | ||
| + return; | ||
| + } | ||
| + | ||
| + final var tab = createTab( inputFile ); | ||
| + final var node = tab.getContent(); | ||
| + 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.get().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 a document file name that doesn't | ||
| + * clash with an existing document. | ||
| + */ | ||
| + public void newTextEditor() { | ||
| + final String key = "file.default.document."; | ||
| + final String prefix = Constants.get( STR."\{key}prefix" ); | ||
| + final String suffix = Constants.get( STR."\{key}suffix" ); | ||
| + | ||
| + File file = new File( STR."\{prefix}.\{suffix}" ); | ||
| + int i = 0; | ||
| + | ||
| + while( file.exists() && i++ < 100 ) { | ||
| + file = new File( STR."\{prefix}-\{i}.\{suffix}" ); | ||
| + } | ||
| + | ||
| + open( file ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Opens a new definition editor document using the default definition | ||
| + * file name. | ||
| + */ | ||
| + @SuppressWarnings( "unused" ) | ||
| + 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() { | ||
| + iterateEditors( this::save ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * 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( getTextEditor() ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Saves the active {@link TextEditor} under a new name. | ||
| + * | ||
| + * @param files The new active editor {@link File} reference, must contain | ||
| + * at least one element. | ||
| + */ | ||
| + public void saveAs( final List<File> files ) { | ||
| + assert files != null; | ||
| + assert !files.isEmpty(); | ||
| + final var editor = getTextEditor(); | ||
| + final var tab = getTab( editor ); | ||
| + final var file = files.getFirst(); | ||
| + | ||
| + // If the file type has changed, refresh the processors. | ||
| + final var mediaType = fromFilename( file ); | ||
| + final var typeChanged = !editor.isMediaType( mediaType ); | ||
| + | ||
| + if( typeChanged ) { | ||
| + removeProcessor( editor ); | ||
| + } | ||
| + | ||
| + editor.rename( file ); | ||
| + tab.ifPresent( t -> { | ||
| + t.setText( editor.getFilename() ); | ||
| + t.setTooltip( createTooltip( file ) ); | ||
| + } ); | ||
| + | ||
| + if( typeChanged ) { | ||
| + updateProcessors( editor ); | ||
| + process( editor ); | ||
| + } | ||
| + | ||
| + 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 tabPane : mTabPanes ) { | ||
| + final var tabIterator = tabPane.getTabs().iterator(); | ||
| + | ||
| + while( tabIterator.hasNext() ) { | ||
| + final var tab = tabIterator.next(); | ||
| + final var resource = tab.getContent(); | ||
| + | ||
| + // The definition panes auto-save, so being specific here prevents | ||
| + // closing the definitions in the situation where the user wants to | ||
| + // continue editing (i.e., possibly save unsaved work). | ||
| + if( !(resource instanceof TextEditor) ) { | ||
| + continue; | ||
| + } | ||
| + | ||
| + if( canClose( (TextEditor) resource ) ) { | ||
| + tabIterator.remove(); | ||
| + close( tab ); | ||
| + } | ||
| + else { | ||
| + closable = false; | ||
| + } | ||
| + } | ||
| + } | ||
| + | ||
| + return closable; | ||
| + } | ||
| + | ||
| + /** | ||
| + * Calls the tab's {@link Tab#getOnClosed()} handler to carry out a close | ||
| + * event. | ||
| + * | ||
| + * @param tab The {@link Tab} that was closed. | ||
| + */ | ||
| + private void close( final Tab tab ) { | ||
| + assert tab != null; | ||
| + | ||
| + final var handler = tab.getOnClosed(); | ||
| + | ||
| + if( handler != null ) { | ||
| + handler.handle( new ActionEvent() ); | ||
| + } | ||
| + } | ||
| + | ||
| + /** | ||
| + * Closes the active tab; delegates to {@link #canClose(TextResource)}. | ||
| + */ | ||
| + public void close() { | ||
| + final var editor = getTextEditor(); | ||
| + | ||
| + if( canClose( editor ) ) { | ||
| + close( editor ); | ||
| + removeProcessor( editor ); | ||
| + } | ||
| + } | ||
| + | ||
| + /** | ||
| + * Closes the given {@link TextResource}. This must not be called from within | ||
| + * a loop that iterates over the tab panes using {@code forEach}, lest a | ||
| + * concurrent modification exception be thrown. | ||
| + * | ||
| + * @param resource The {@link TextResource} to close, without confirming with | ||
| + * the user. | ||
| + */ | ||
| + private void close( final TextResource resource ) { | ||
| + getTab( resource ).ifPresent( | ||
| + tab -> { | ||
| + close( tab ); | ||
| + tab.getTabPane().getTabs().remove( tab ); | ||
| + } | ||
| + ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Answers whether the given {@link TextResource} may be closed. | ||
| + * | ||
| + * @param editor The {@link TextResource} to try closing. | ||
| + * @return {@code true} when the editor may be closed; {@code false} when | ||
| + * the user has requested to keep the editor open. | ||
| + */ | ||
| + private boolean canClose( final TextResource editor ) { | ||
| + final var editorTab = getTab( editor ); | ||
| + final var canClose = new AtomicBoolean( true ); | ||
| + | ||
| + if( editor.isModified() ) { | ||
| + final var filename = new StringBuilder(); | ||
| + editorTab.ifPresent( tab -> filename.append( tab.getText() ) ); | ||
| + | ||
| + final var message = sNotifier.createNotification( | ||
| + Messages.get( "Alert.file.close.title" ), | ||
| + Messages.get( "Alert.file.close.text" ), | ||
| + filename.toString() | ||
| + ); | ||
| + | ||
| + final var dialog = sNotifier.createConfirmation( getWindow(), message ); | ||
| + | ||
| + dialog.showAndWait().ifPresent( | ||
| + save -> canClose.set( save == YES ? editor.save() : save == NO ) | ||
| + ); | ||
| + } | ||
| + | ||
| + return canClose.get(); | ||
| + } | ||
| + | ||
| + private void iterateEditors( final Consumer<TextEditor> consumer ) { | ||
| + mTabPanes.forEach( | ||
| + tp -> tp.getTabs().forEach( tab -> { | ||
| + final var node = tab.getContent(); | ||
| + | ||
| + if( node instanceof final TextEditor editor ) { | ||
| + consumer.accept( editor ); | ||
| + } | ||
| + } ) | ||
| + ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Adds the HTML preview tab to its own, singular tab pane. | ||
| + */ | ||
| + public void viewPreview() { | ||
| + addTab( mPreview, TEXT_HTML, "Pane.preview.title" ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Adds the document outline tab to its own, singular tab pane. | ||
| + */ | ||
| + public void viewOutline() { | ||
| + addTab( mOutline, APP_DOCUMENT_OUTLINE, "Pane.outline.title" ); | ||
| + } | ||
| + | ||
| + public void viewStatistics() { | ||
| + addTab( mStatistics, APP_DOCUMENT_STATISTICS, "Pane.statistics.title" ); | ||
| + } | ||
| + | ||
| + public void viewFiles() { | ||
| + try { | ||
| + final var factory = new FilePickerFactory( getWorkspace() ); | ||
| + final var fileManager = factory.createModeless(); | ||
| + addTab( fileManager, APP_FILE_MANAGER, "Pane.files.title" ); | ||
| + } catch( final Exception ex ) { | ||
| + clue( ex ); | ||
| + } | ||
| + } | ||
| + | ||
| + public void viewRefresh() { | ||
| + mPreview.refresh(); | ||
| + Engine.clear(); | ||
| + mRBootstrapController.update(); | ||
| + } | ||
| + | ||
| + private void addTab( | ||
| + final Node node, final MediaType mediaType, final String key ) { | ||
| + final var tabPane = obtainTabPane( mediaType ); | ||
| + | ||
| + for( final var tab : tabPane.getTabs() ) { | ||
| + if( tab.getContent() == node ) { | ||
| + return; | ||
| + } | ||
| + } | ||
| + | ||
| + tabPane.getTabs().add( createTab( get( key ), node ) ); | ||
| + addTabPane( tabPane ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * 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 TextResource editor ) { | ||
| + return mTabPanes.stream() | ||
| + .flatMap( pane -> pane.getTabs().stream() ) | ||
| + .filter( tab -> editor.equals( tab.getContent() ) ) | ||
| + .findFirst(); | ||
| + } | ||
| + | ||
| + private TextDefinition createDefinitionEditor( final File file ) { | ||
| + final var editor = new DefinitionEditor( file, createTreeTransformer() ); | ||
| + | ||
| + editor.addTreeChangeHandler( mTreeHandler ); | ||
| + | ||
| + return editor; | ||
| + } | ||
| + | ||
| + /** | ||
| + * Creates a new {@link DefinitionEditor} wrapped in a listener that | ||
| + * is used to detect when the active {@link DefinitionEditor} has changed. | ||
| + * Upon changing, the variables are interpolated and the active text editor | ||
| + * is refreshed. | ||
| + * | ||
| + * @param workspace Has the most recently edited definitions file name. | ||
| + * @return A newly configured property that represents the active | ||
| + * {@link DefinitionEditor}, never {@code null}. | ||
| + */ | ||
| + private TextDefinition createDefinitionEditor( | ||
| + final Workspace workspace ) { | ||
| + final var fileProperty = workspace.fileProperty( KEY_UI_RECENT_DEFINITION ); | ||
| + final var filename = fileProperty.get(); | ||
| + final SetProperty<String> recent = workspace.setsProperty( | ||
| + KEY_UI_RECENT_OPEN_PATH | ||
| + ); | ||
| + | ||
| + // Open the most recently used YAML definition file. | ||
| + for( final var recentFile : recent.get() ) { | ||
| + if( recentFile.endsWith( filename.toString() ) ) { | ||
| + return createDefinitionEditor( new File( recentFile ) ); | ||
| + } | ||
| + } | ||
| + | ||
| + return createDefaultDefinitionEditor(); | ||
| + } | ||
| + | ||
| + private TextDefinition createDefaultDefinitionEditor() { | ||
| + final var transformer = createTreeTransformer(); | ||
| + return new DefinitionEditor( transformer ); | ||
| + } | ||
| + | ||
| + private TreeTransformer createTreeTransformer() { | ||
| + return new YamlTreeTransformer(); | ||
| + } | ||
| + | ||
| + private Tab createTab( final String filename, final Node node ) { | ||
| + return new DetachableTab( filename, node ); | ||
| + } | ||
| + | ||
| + private Tab createTab( final File file ) { | ||
| + final var r = createTextResource( file ); | ||
| + final var filename = r.getFilename(); | ||
| + final var tab = createTab( filename, r.getNode() ); | ||
| + | ||
| + r.modifiedProperty().addListener( | ||
| + ( _, _, n ) -> tab.setText( filename + (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() ) | ||
| + ); | ||
| + | ||
| + // When closing a tab, give focus to the newly revealed tab. | ||
| + tab.selectedProperty().addListener( ( _, _, n ) -> { | ||
| + if( n != null && n ) { | ||
| + final var pane = tab.getTabPane(); | ||
| + | ||
| + if( pane != null ) { | ||
| + pane.requestFocus(); | ||
| + } | ||
| + } | ||
| + } ); | ||
| + | ||
| + tab.tabPaneProperty().addListener( ( _, _, nPane ) -> { | ||
| + if( nPane != null ) { | ||
| + nPane.focusedProperty().addListener( ( _, _, n ) -> { | ||
| + if( n != null && n ) { | ||
| + final var selected = nPane.getSelectionModel().getSelectedItem(); | ||
| + final var node = selected.getContent(); | ||
| + node.requestFocus(); | ||
| + } | ||
| + } ); | ||
| + } | ||
| + } ); | ||
| + | ||
| + 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> collect( 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. The order that | ||
| + // the elements are listed in the enumeration for media types determines | ||
| + // what files are loaded first. Variable definitions come before all other | ||
| + // plain text documents. | ||
| + final var bins = paths | ||
| + .stream() | ||
| + .collect( | ||
| + groupingBy( | ||
| + path -> bin.apply( fromFilename( path ) ), | ||
| + () -> new TreeMap<>( Enum::compareTo ), | ||
| + Collectors.toList() | ||
| + ) | ||
| + ); | ||
| + | ||
| + bins.putIfAbsent( TEXT_YAML, List.of( DEFINITION_DEFAULT.toString() ) ); | ||
| + bins.putIfAbsent( TEXT_PLAIN, List.of( DOCUMENT_DEFAULT.toString() ) ); | ||
| + | ||
| + final var result = new LinkedList<File>(); | ||
| + | ||
| + // Ensure that the same types are listed together (keep insertion order). | ||
| + bins.forEach( ( _, files ) -> result.addAll( | ||
| + files.stream().map( File::new ).toList() ) | ||
| + ); | ||
| + | ||
| + return result; | ||
| + } | ||
| + | ||
| + /** | ||
| + * 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 ) { | ||
| + // Ensure processing does not run on the JavaFX thread, which frees the | ||
| + // text editor immediately for caret movement. The preview will have a | ||
| + // slight delay when catching up to the caret position. | ||
| + final var task = new Task<Void>() { | ||
| + @Override | ||
| + public Void call() { | ||
| + try { | ||
| + final var p = mProcessors.getOrDefault( editor, IDENTITY ); | ||
| + p.apply( editor == null ? "" : editor.getText() ); | ||
| + } catch( final Exception ex ) { | ||
| + clue( ex ); | ||
| + } | ||
| + | ||
| + return null; | ||
| + } | ||
| + }; | ||
| + | ||
| + // TODO: Each time the editor successfully runs the processor, the task is | ||
| + // considered successful. Due to the rapid-fire nature of processing | ||
| + // (e.g., keyboard navigation, fast typing), it isn't necessary to | ||
| + // scroll each time. | ||
| + // The algorithm: | ||
| + // 1. Peek at the oldest time. | ||
| + // 2. If the difference between the oldest time and current time exceeds | ||
| + // 250 milliseconds, then invoke the scrolling. | ||
| + // 3. Insert the current time into the circular queue. | ||
| + task.setOnSucceeded( | ||
| + _ -> invokeLater( () -> mPreview.scrollTo( CARET_ID ) ) | ||
| + ); | ||
| + | ||
| + // Prevents multiple process requests from executing simultaneously (due | ||
| + // to having a restricted queue size). | ||
| + sExecutor.execute( task ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Lazily creates a {@link TabPane} configured to listen for tab select | ||
| + * events. 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 TabPane} that will handle tab docking. | ||
| + */ | ||
| + private TabPane obtainTabPane( final MediaType mediaType ) { | ||
| + for( final var pane : mTabPanes ) { | ||
| + for( final var tab : pane.getTabs() ) { | ||
| + final var node = tab.getContent(); | ||
| + | ||
| + if( node instanceof TextResource r && r.supports( mediaType ) ) { | ||
| + return pane; | ||
| + } | ||
| + } | ||
| + } | ||
| + | ||
| + final var pane = createTabPane(); | ||
| + mTabPanes.add( pane ); | ||
| + return pane; | ||
| + } | ||
| + | ||
| + /** | ||
| + * Creates an initialized {@link TabPane} instance. | ||
| + * | ||
| + * @return A new {@link TabPane} with all listeners configured. | ||
| + */ | ||
| + private TabPane createTabPane() { | ||
| + final var tabPane = new DetachableTabPane(); | ||
| + | ||
| + initStageOwnerFactory( tabPane ); | ||
| + initTabListener( 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 TabPane} to configure. | ||
| + */ | ||
| + private void initTabListener( final TabPane 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(); | ||
| + } | ||
| + } | ||
| + } | ||
| + } | ||
| + ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * 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 = mPreview.getVerticalScrollBar(); | ||
| + final var handler = new ScrollEventHandler( scrollPane, scrollBar ); | ||
| + | ||
| + handler.enabledProperty().bind( tab.selectedProperty() ); | ||
| + } | ||
| + | ||
| + private void addTabPane( final int index, final TabPane tabPane ) { | ||
| + final var items = getItems(); | ||
| + | ||
| + if( !items.contains( tabPane ) ) { | ||
| + items.add( index, tabPane ); | ||
| + } | ||
| + } | ||
| + | ||
| + private void addTabPane( final TabPane tabPane ) { | ||
| + addTabPane( getItems().size(), tabPane ); | ||
| + } | ||
| + | ||
| + private GenericBuilder<Mutator, ProcessorContext> processorContextBuilder() { | ||
| + final var w = getWorkspace(); | ||
| + | ||
| + return builder() | ||
| + .with( Mutator::setDefinitions, this::getDefinitions ) | ||
| + .with( Mutator::setLocale, w::getLocale ) | ||
| + .with( Mutator::setMetadata, w::getMetadata ) | ||
| + .with( Mutator::setThemeDir, w::getThemesPath ) | ||
| + .with( Mutator::setCacheDir, | ||
| + () -> w.getFile( KEY_CACHE_DIR ) ) | ||
| + .with( Mutator::setImageDir, | ||
| + () -> w.getFile( KEY_IMAGE_DIR ) ) | ||
| + .with( Mutator::setImageOrder, | ||
| + () -> w.getString( KEY_IMAGE_ORDER ) ) | ||
| + .with( Mutator::setImageServer, | ||
| + () -> w.getString( KEY_IMAGE_SERVER ) ) | ||
| + .with( Mutator::setCaret, | ||
| + () -> getTextEditor().getCaret() ) | ||
| + .with( Mutator::setSigilBegan, | ||
| + () -> w.getString( KEY_DEF_DELIM_BEGAN ) ) | ||
| + .with( Mutator::setSigilEnded, | ||
| + () -> w.getString( KEY_DEF_DELIM_ENDED ) ) | ||
| + .with( Mutator::setRScript, | ||
| + () -> w.getString( KEY_R_SCRIPT ) ) | ||
| + .with( Mutator::setRWorkingDir, | ||
| + () -> w.getFile( KEY_R_DIR ).toPath() ) | ||
| + .with( Mutator::setFontDir, | ||
| + () -> w.getFile( KEY_TYPESET_CONTEXT_FONTS_DIR ) ) | ||
| + .with( Mutator::setEnableMode, | ||
| + () -> w.getString( KEY_TYPESET_MODES_ENABLED ) ) | ||
| + .with( Mutator::setCurlQuotes, | ||
| + () -> w.getBoolean( KEY_TYPESET_TYPOGRAPHY_QUOTES ) ) | ||
| + .with( Mutator::setAutoRemove, | ||
| + () -> w.getBoolean( KEY_TYPESET_CONTEXT_CLEAN ) ); | ||
| + } | ||
| + | ||
| + public ProcessorContext createProcessorContext() { | ||
| + return createProcessorContextBuilder( NONE ).build(); | ||
| + } | ||
| + | ||
| + private GenericBuilder<Mutator, ProcessorContext> createProcessorContextBuilder( | ||
| + final ExportFormat format ) { | ||
| + final var textEditor = getTextEditor(); | ||
| + final var sourcePath = textEditor.getPath(); | ||
| + | ||
| + return processorContextBuilder() | ||
| + .with( Mutator::setSourcePath, sourcePath ) | ||
| + .with( Mutator::setExportFormat, format ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * @param targetPath Used when exporting to a PDF file (binary). | ||
| + * @param format Used when processors export to a new text format. | ||
| + * @return A new {@link ProcessorContext} to use when creating an instance of | ||
| + * {@link Processor}. | ||
| + */ | ||
| + public ProcessorContext createProcessorContext( | ||
| + final Path targetPath, final ExportFormat format ) { | ||
| + assert targetPath != null; | ||
| + assert format != null; | ||
| + | ||
| + return createProcessorContextBuilder( format ) | ||
| + .with( Mutator::setTargetPath, targetPath ) | ||
| + .build(); | ||
| + } | ||
| + | ||
| + /** | ||
| + * @param sourcePath Used by {@link ProcessorFactory} to determine | ||
| + * {@link Processor} type to create based on file type. | ||
| + * @return A new {@link ProcessorContext} to use when creating an instance of | ||
| + * {@link Processor}. | ||
| + */ | ||
| + private ProcessorContext createProcessorContext( final Path sourcePath ) { | ||
| + return processorContextBuilder() | ||
| + .with( Mutator::setSourcePath, sourcePath ) | ||
| + .with( Mutator::setExportFormat, NONE ) | ||
| + .build(); | ||
| + } | ||
| + | ||
| + private TextResource createTextResource( final File file ) { | ||
| + if( fromFilename( file ) == TEXT_YAML ) { | ||
| + final var editor = createDefinitionEditor( file ); | ||
| + mDefinitionEditor.set( editor ); | ||
| + return editor; | ||
| + } | ||
| + else { | ||
| + final var editor = createMarkdownEditor( file ); | ||
| + mTextEditor.set( editor ); | ||
| + return editor; | ||
| + } | ||
| + } | ||
| + | ||
| + /** | ||
| + * Creates an instance of {@link MarkdownEditor} that listens for both | ||
| + * caret change events and text change events. Text change events must | ||
| + * take priority over caret change events because it's possible to change | ||
| + * the text without moving the caret (e.g., delete selected text). | ||
| + * | ||
| + * @param inputFile The file containing contents for the text editor. | ||
| + * @return A non-null text editor. | ||
| + */ | ||
| + private MarkdownEditor createMarkdownEditor( final File inputFile ) { | ||
| + final var editor = new MarkdownEditor( inputFile, getWorkspace() ); | ||
| + | ||
| + // Listener for editor modifications or caret position changes. | ||
| + editor.addDirtyListener( ( _, _, n ) -> { | ||
| + if( n ) { | ||
| + // Reset the status bar after changing the text. | ||
| + clue(); | ||
| + | ||
| + // Processing the text may update the status bar. | ||
| + process( editor ); | ||
| + | ||
| + // Update the caret position in the status bar. | ||
| + CaretMovedEvent.fire( editor.getCaret() ); | ||
| + } | ||
| + } ); | ||
| + | ||
| + editor.addEventListener( | ||
| + keyPressed( SPACE, CONTROL_DOWN ), this::autoinsert | ||
| + ); | ||
| + | ||
| + editor.addEventListener( | ||
| + keyPressed( ENTER, ALT_DOWN ), _ -> mEditorSpeller.autofix( editor ) | ||
| + ); | ||
| + | ||
| + final var textArea = editor.getTextArea(); | ||
| + | ||
| + // Spell check when the paragraph changes. | ||
| + textArea | ||
| + .plainTextChanges() | ||
| + .filter( p -> !p.isIdentity() ) | ||
| + .subscribe( change -> mEditorSpeller.checkParagraph( textArea, change ) ); | ||
| + | ||
| + // Store the caret position to restore it after restarting the application. | ||
| + textArea.caretPositionProperty().addListener( | ||
| + ( _, _, n ) -> | ||
| + getWorkspace().integerProperty( KEY_UI_RECENT_OFFSET ).setValue( n ) | ||
| + ); | ||
| + | ||
| + // Check the entire document after the spellchecker is initialized (with | ||
| + // a valid lexicon) so that only the current paragraph need be scanned | ||
| + // while editing. (Technically, only the most recently modified word must | ||
| + // be scanned.) | ||
| + mSpellChecker.addListener( | ||
| + ( _, _, _ ) -> runLater( | ||
| + () -> iterateEditors( mEditorSpeller::checkDocument ) | ||
| + ) | ||
| + ); | ||
| + | ||
| + // Check the entire document after it has been loaded. | ||
| + mEditorSpeller.checkDocument( editor ); | ||
| + | ||
| + return editor; | ||
| + } | ||
| + | ||
| + /** | ||
| + * Creates a processor for an editor, provided one doesn't already exist. | ||
| + * | ||
| + * @param editor The editor that potentially requires an associated processor. | ||
| + */ | ||
| + private void updateProcessors( final TextEditor editor ) { | ||
| + final var path = editor.getFile().toPath(); | ||
| + | ||
| + mProcessors.computeIfAbsent( | ||
| + editor, _ -> createProcessors( | ||
| + createProcessorContext( path ), | ||
| + createHtmlPreviewProcessor() | ||
| + ) | ||
| + ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Removes a processor for an editor. This is required because a file may | ||
| + * change type while editing (e.g., from plain Markdown to R Markdown). | ||
| + * In the case that an editor's type changes, its associated processor must | ||
| + * be changed accordingly. | ||
| + * | ||
| + * @param editor The editor that potentially requires an associated processor. | ||
| + */ | ||
| + private void removeProcessor( final TextEditor editor ) { | ||
| + mProcessors.remove( editor ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Creates a {@link Processor} capable of rendering an HTML document onto | ||
| + * a GUI widget. | ||
| + * | ||
| + * @return The {@link Processor} for rendering an HTML document. | ||
| + */ | ||
| + private Processor<String> createHtmlPreviewProcessor() { | ||
| + return new HtmlPreviewProcessor( getPreview() ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Creates a spellchecker that accepts all words as correct. This allows | ||
| + * the spellchecker property to be initialized to a known valid value. | ||
| + * | ||
| + * @return A wrapped {@link PermissiveSpeller}. | ||
| + */ | ||
| + private ObjectProperty<SpellChecker> createSpellChecker() { | ||
| + return new SimpleObjectProperty<>( new PermissiveSpeller() ); | ||
| + } | ||
| + | ||
| + private TextEditorSpellChecker createTextEditorSpellChecker( | ||
| + final ObjectProperty<SpellChecker> spellChecker ) { | ||
| + return new TextEditorSpellChecker( spellChecker ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Delegates to {@link #autoinsert()}. | ||
| + * | ||
| + * @param ignored Ignored. | ||
| + */ | ||
| + private void autoinsert( final KeyEvent ignored ) { | ||
| autoinsert(); | ||
| } |
| public final class Arguments implements Callable<Integer> { | ||
| @CommandLine.Option( | ||
| - names = {"--all"}, | ||
| + names = { "--all" }, | ||
| description = | ||
| "Concatenate files before processing (${DEFAULT-VALUE})", | ||
| defaultValue = "false" | ||
| ) | ||
| private boolean mConcatenate; | ||
| @CommandLine.Option( | ||
| - names = {"--keep-files"}, | ||
| + names = { "--keep-files" }, | ||
| description = | ||
| "Retain temporary build files (${DEFAULT-VALUE})", | ||
| defaultValue = "false" | ||
| ) | ||
| private boolean mKeepFiles; | ||
| @CommandLine.Option( | ||
| - names = {"-c", "--chapters"}, | ||
| + names = { "-c", "--chapters" }, | ||
| description = | ||
| "Export chapter ranges, no spaces (e.g., -3,5-9,15-)", | ||
| paramLabel = "String" | ||
| ) | ||
| private String mChapters; | ||
| @CommandLine.Option( | ||
| - names = {"--curl-quotes"}, | ||
| + names = { "--curl-quotes" }, | ||
| description = | ||
| "Replace straight quotes with curly quotes (${DEFAULT-VALUE})", | ||
| defaultValue = "true" | ||
| ) | ||
| private boolean mCurlQuotes; | ||
| @CommandLine.Option( | ||
| - names = {"-d", "--debug"}, | ||
| + names = { "-d", "--debug" }, | ||
| description = | ||
| "Enable logging to the console (${DEFAULT-VALUE})", | ||
| paramLabel = "Boolean", | ||
| defaultValue = "false" | ||
| ) | ||
| private boolean mDebug; | ||
| @CommandLine.Option( | ||
| - names = {"-i", "--input"}, | ||
| + names = { "-i", "--input" }, | ||
| description = | ||
| "Source document file path", | ||
| paramLabel = "PATH", | ||
| defaultValue = "stdin", | ||
| required = true | ||
| ) | ||
| private Path mSourcePath; | ||
| @CommandLine.Option( | ||
| - names = {"--font-dir"}, | ||
| + names = { "--font-dir" }, | ||
| description = | ||
| "Directory to specify additional fonts", | ||
| paramLabel = "String" | ||
| ) | ||
| private File mFontDir; | ||
| @CommandLine.Option( | ||
| - names = {"--format-subtype"}, | ||
| + names = { "--mode" }, | ||
| + description = | ||
| + "Enable one or more modes when typesetting", | ||
| + paramLabel = "String" | ||
| + ) | ||
| + private String mEnableMode; | ||
| + | ||
| + @CommandLine.Option( | ||
| + names = { "--format-subtype" }, | ||
| description = | ||
| "Export TeX subtype for HTML formats: svg, delimited", | ||
| paramLabel = "String", | ||
| defaultValue = "svg" | ||
| ) | ||
| private String mFormatSubtype; | ||
| @CommandLine.Option( | ||
| - names = {"--cache-dir"}, | ||
| + names = { "--cache-dir" }, | ||
| description = | ||
| "Directory to store remote resources", | ||
| paramLabel = "DIR" | ||
| ) | ||
| private File mCachesDir; | ||
| @CommandLine.Option( | ||
| - names = {"--image-dir"}, | ||
| + names = { "--image-dir" }, | ||
| description = | ||
| "Directory containing images", | ||
| paramLabel = "DIR" | ||
| ) | ||
| private File mImagesDir; | ||
| @CommandLine.Option( | ||
| - names = {"--image-order"}, | ||
| + names = { "--image-order" }, | ||
| description = | ||
| "Comma-separated image order (${DEFAULT-VALUE})", | ||
| paramLabel = "String", | ||
| defaultValue = "svg,pdf,png,jpg,tiff" | ||
| ) | ||
| private String mImageOrder; | ||
| @CommandLine.Option( | ||
| - names = {"--image-server"}, | ||
| + names = { "--image-server" }, | ||
| description = | ||
| "SVG diagram rendering service (${DEFAULT-VALUE})", | ||
| paramLabel = "String", | ||
| defaultValue = DIAGRAM_SERVER_NAME | ||
| ) | ||
| private String mImageServer; | ||
| @CommandLine.Option( | ||
| - names = {"--locale"}, | ||
| + names = { "--locale" }, | ||
| description = | ||
| "Set localization (${DEFAULT-VALUE})", | ||
| paramLabel = "String", | ||
| defaultValue = "en" | ||
| ) | ||
| private String mLocale; | ||
| @CommandLine.Option( | ||
| - names = {"-m", "--metadata"}, | ||
| + names = { "-m", "--metadata" }, | ||
| description = | ||
| "Map metadata keys to values, variable names allowed", | ||
| paramLabel = "key=value" | ||
| ) | ||
| private Map<String, String> mMetadata; | ||
| @CommandLine.Option( | ||
| - names = {"-o", "--output"}, | ||
| + names = { "-o", "--output" }, | ||
| description = | ||
| "Destination document file path", | ||
| paramLabel = "PATH", | ||
| defaultValue = "stdout", | ||
| required = true | ||
| ) | ||
| private Path mTargetPath; | ||
| @CommandLine.Option( | ||
| - names = {"-q", "--quiet"}, | ||
| + names = { "-q", "--quiet" }, | ||
| description = | ||
| "Suppress all status messages (${DEFAULT-VALUE})", | ||
| defaultValue = "false" | ||
| ) | ||
| private boolean mQuiet; | ||
| @CommandLine.Option( | ||
| - names = {"--r-dir"}, | ||
| + names = { "--r-dir" }, | ||
| description = | ||
| "R working directory", | ||
| paramLabel = "DIR" | ||
| ) | ||
| private Path mRWorkingDir; | ||
| @CommandLine.Option( | ||
| - names = {"--r-script"}, | ||
| + names = { "--r-script" }, | ||
| description = | ||
| "R bootstrap script file path", | ||
| paramLabel = "PATH" | ||
| ) | ||
| private Path mRScriptPath; | ||
| @CommandLine.Option( | ||
| - names = {"-s", "--set"}, | ||
| + names = { "-s", "--set" }, | ||
| description = | ||
| "Set (or override) a document variable value", | ||
| paramLabel = "key=value" | ||
| ) | ||
| private Map<String, String> mOverrides; | ||
| @CommandLine.Option( | ||
| - names = {"--sigil-opening"}, | ||
| + names = { "--sigil-opening" }, | ||
| description = | ||
| "Starting sigil for variable names (${DEFAULT-VALUE})", | ||
| paramLabel = "String", | ||
| defaultValue = "{{" | ||
| ) | ||
| private String mSigilBegan; | ||
| @CommandLine.Option( | ||
| - names = {"--sigil-closing"}, | ||
| + names = { "--sigil-closing" }, | ||
| description = | ||
| "Ending sigil for variable names (${DEFAULT-VALUE})", | ||
| paramLabel = "String", | ||
| defaultValue = "}}" | ||
| ) | ||
| private String mSigilEnded; | ||
| @CommandLine.Option( | ||
| - names = {"--theme-dir"}, | ||
| + names = { "--theme-dir" }, | ||
| description = | ||
| "Theme directory", | ||
| paramLabel = "DIR" | ||
| ) | ||
| private Path mThemesDir; | ||
| @CommandLine.Option( | ||
| - names = {"-v", "--variables"}, | ||
| + names = { "-v", "--variables" }, | ||
| description = | ||
| "Variables file path", | ||
| .with( Mutator::setImageOrder, () -> mImageOrder ) | ||
| .with( Mutator::setFontDir, () -> mFontDir ) | ||
| + .with( Mutator::setEnableMode, () -> mEnableMode ) | ||
| .with( Mutator::setExportFormat, format ) | ||
| .with( Mutator::setDefinitions, () -> definitions ) | ||
| final var jsonNode = node.getValue(); | ||
| - final var keyName = parent + "." + node.getKey(); | ||
| + final var keyName = STR."\{parent}.\{node.getKey()}"; | ||
| if( jsonNode.isValueNode() ) { | ||
| final var buttonBar = new HBox(); | ||
| buttonBar.getChildren().addAll( | ||
| - createButton( "create", e -> createDefinition() ), | ||
| - createButton( "rename", e -> renameDefinition() ), | ||
| - createButton( "delete", e -> deleteDefinitions() ) | ||
| - ); | ||
| - buttonBar.setAlignment( CENTER ); | ||
| - buttonBar.setSpacing( UI_CONTROL_SPACING ); | ||
| - setTop( buttonBar ); | ||
| - setCenter( mTreeView ); | ||
| - setAlignment( buttonBar, TOP_CENTER ); | ||
| - | ||
| - mEncoding = open( mFile ); | ||
| - updateDefinitions( getDefinitions(), getTreeView().getRoot() ); | ||
| - | ||
| - // After the file is opened, watch for changes, not before. Otherwise, | ||
| - // upon saving, users will be prompted to save a file that hasn't had | ||
| - // any modifications (from their perspective). | ||
| - addTreeChangeHandler( event -> { | ||
| - mModified.set( true ); | ||
| - updateDefinitions( getDefinitions(), getTreeView().getRoot() ); | ||
| - } ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Replaces the given list of variable definitions with a flat hierarchy | ||
| - * of the converted {@link TreeView} root. | ||
| - * | ||
| - * @param definitions The definition map to update. | ||
| - * @param root The values to flatten then insert into the map. | ||
| - */ | ||
| - private void updateDefinitions( | ||
| - final Map<String, String> definitions, | ||
| - final TreeItem<String> root ) { | ||
| - definitions.clear(); | ||
| - definitions.putAll( TreeItemMapper.convert( root ) ); | ||
| - Engine.clear(); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Returns the variable definitions. | ||
| - * | ||
| - * @return The definition map. | ||
| - */ | ||
| - @Override | ||
| - public Map<String, String> getDefinitions() { | ||
| - return mDefinitions; | ||
| - } | ||
| - | ||
| - @Override | ||
| - public void setText( final String document ) { | ||
| - final var foster = mTreeTransformer.transform( document ); | ||
| - final var biological = getTreeRoot(); | ||
| - | ||
| - for( final var child : foster.getChildren() ) { | ||
| - biological.getChildren().add( child ); | ||
| - } | ||
| - | ||
| - getTreeView().refresh(); | ||
| - } | ||
| - | ||
| - @Override | ||
| - public String getText() { | ||
| - final var result = new StringBuilder( 32768 ); | ||
| - | ||
| - try { | ||
| - result.append( mTreeTransformer.transform( getTreeView().getRoot() ) ); | ||
| - | ||
| - final var problem = isTreeWellFormed(); | ||
| - problem.ifPresent( node -> clue( "yaml.error.tree.form", node ) ); | ||
| - } catch( final Exception ex ) { | ||
| - // Catch errors while checking for a well-formed tree (e.g., stack smash). | ||
| - // Also catch any transformation exceptions (e.g., Json processing). | ||
| - clue( ex ); | ||
| - } | ||
| - | ||
| - return result.toString(); | ||
| - } | ||
| - | ||
| - @Override | ||
| - public File getFile() { | ||
| - return mFile; | ||
| - } | ||
| - | ||
| - @Override | ||
| - public void rename( final File file ) { | ||
| - mFile = file; | ||
| - } | ||
| - | ||
| - @Override | ||
| - public Charset getEncoding() { | ||
| - return mEncoding; | ||
| - } | ||
| - | ||
| - @Override | ||
| - public Node getNode() { | ||
| - return this; | ||
| - } | ||
| - | ||
| - @Override | ||
| - public ReadOnlyBooleanProperty modifiedProperty() { | ||
| - return mModified; | ||
| - } | ||
| - | ||
| - @Override | ||
| - public void clearModifiedProperty() { | ||
| - mModified.setValue( false ); | ||
| - } | ||
| - | ||
| - private Button createButton( | ||
| - final String msgKey, final EventHandler<ActionEvent> eventHandler ) { | ||
| - final var keyPrefix = Constants.ACTION_PREFIX + "definition." + msgKey; | ||
| - final var button = new Button( get( keyPrefix + ".text" ) ); | ||
| - final var graphic = createGraphic( get( keyPrefix + ".icon" ) ); | ||
| - | ||
| - button.setOnAction( eventHandler ); | ||
| - button.setGraphic( graphic ); | ||
| - button.setTooltip( new Tooltip( get( keyPrefix + ".tooltip" ) ) ); | ||
| - | ||
| - return button; | ||
| - } | ||
| - | ||
| - /** | ||
| - * Informs the caller of whenever any {@link TreeItem} in the {@link TreeView} | ||
| - * is modified. The modifications include: item value changes, item additions, | ||
| - * and item removals. | ||
| - * <p> | ||
| - * Safe to call multiple times; if a handler is already registered, the | ||
| - * old handler is used. | ||
| - * </p> | ||
| - * | ||
| - * @param handler The handler to call whenever any {@link TreeItem} changes. | ||
| - */ | ||
| - public void addTreeChangeHandler( | ||
| - final EventHandler<TreeItem.TreeModificationEvent<Event>> handler ) { | ||
| - final var root = getTreeView().getRoot(); | ||
| - root.addEventHandler( valueChangedEvent(), handler ); | ||
| - root.addEventHandler( childrenModificationEvent(), handler ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Answers whether the {@link TreeItem}s in the {@link TreeView} are suitably | ||
| - * well-formed for export. A tree is considered well-formed if the following | ||
| - * conditions are met: | ||
| - * | ||
| - * <ul> | ||
| - * <li>The root node contains at least one child node having a leaf.</li> | ||
| - * <li>There are no leaf nodes with sibling leaf nodes.</li> | ||
| - * </ul> | ||
| - * | ||
| - * @return {@code null} if the document is well-formed, otherwise the | ||
| - * problematic child {@link TreeItem}. | ||
| - */ | ||
| - public Optional<TreeItem<String>> isTreeWellFormed() { | ||
| - final var root = getTreeView().getRoot(); | ||
| - | ||
| - for( final var child : root.getChildren() ) { | ||
| - final var problemChild = isWellFormed( child ); | ||
| - | ||
| - if( child.isLeaf() || problemChild != null ) { | ||
| - return Optional.ofNullable( problemChild ); | ||
| - } | ||
| - } | ||
| - | ||
| - return Optional.empty(); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Determines whether the document is well-formed by ensuring that | ||
| - * child branches do not contain multiple leaves. | ||
| - * | ||
| - * @param item The subtree to check for well-formedness. | ||
| - * @return {@code null} when the tree is well-formed, otherwise the | ||
| - * problematic {@link TreeItem}. | ||
| - */ | ||
| - private TreeItem<String> isWellFormed( final TreeItem<String> item ) { | ||
| - int childLeafs = 0; | ||
| - int childBranches = 0; | ||
| - | ||
| - for( final var child : item.getChildren() ) { | ||
| - if( child.isLeaf() ) { | ||
| - childLeafs++; | ||
| - } | ||
| - else { | ||
| - childBranches++; | ||
| - } | ||
| - | ||
| - final var problemChild = isWellFormed( child ); | ||
| - | ||
| - if( problemChild != null ) { | ||
| - return problemChild; | ||
| - } | ||
| - } | ||
| - | ||
| - return ((childBranches > 0 && childLeafs == 0) || | ||
| - (childBranches == 0 && childLeafs <= 1)) ? null : item; | ||
| - } | ||
| - | ||
| - @Override | ||
| - public DefinitionTreeItem<String> findLeafExact( final String text ) { | ||
| - return getTreeRoot().findLeafExact( text ); | ||
| - } | ||
| - | ||
| - @Override | ||
| - public DefinitionTreeItem<String> findLeafContains( final String text ) { | ||
| - return getTreeRoot().findLeafContains( text ); | ||
| - } | ||
| - | ||
| - @Override | ||
| - public DefinitionTreeItem<String> findLeafContainsNoCase( | ||
| - final String text ) { | ||
| - return getTreeRoot().findLeafContainsNoCase( text ); | ||
| - } | ||
| - | ||
| - @Override | ||
| - public DefinitionTreeItem<String> findLeafStartsWith( final String text ) { | ||
| - return getTreeRoot().findLeafStartsWith( text ); | ||
| - } | ||
| - | ||
| - public void select( final TreeItem<String> item ) { | ||
| - getSelectionModel().clearSelection(); | ||
| - getSelectionModel().select( getTreeView().getRow( item ) ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Collapses the tree, recursively. | ||
| - */ | ||
| - public void collapse() { | ||
| - collapse( getTreeRoot().getChildren() ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Collapses the tree, recursively. | ||
| - * | ||
| - * @param <T> The type of tree item to expand (usually String). | ||
| - * @param nodes The nodes to collapse. | ||
| - */ | ||
| - private <T> void collapse( final ObservableList<TreeItem<T>> nodes ) { | ||
| - for( final var node : nodes ) { | ||
| - node.setExpanded( false ); | ||
| - collapse( node.getChildren() ); | ||
| - } | ||
| - } | ||
| - | ||
| - /** | ||
| - * @return {@code true} when the user is editing a {@link TreeItem}. | ||
| - */ | ||
| - private boolean isEditingTreeItem() { | ||
| - return getTreeView().editingItemProperty().getValue() != null; | ||
| - } | ||
| - | ||
| - /** | ||
| - * Changes to edit mode for the selected item. | ||
| - */ | ||
| - @Override | ||
| - public void renameDefinition() { | ||
| - getTreeView().edit( getSelectedItem() ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Removes all selected items from the {@link TreeView}. | ||
| - */ | ||
| - @Override | ||
| - public void deleteDefinitions() { | ||
| - for( final var item : getSelectedItems() ) { | ||
| - final var parent = item.getParent(); | ||
| - | ||
| - if( parent != null ) { | ||
| - parent.getChildren().remove( item ); | ||
| - } | ||
| - } | ||
| - } | ||
| - | ||
| - /** | ||
| - * Deletes the selected item. | ||
| - */ | ||
| - private void deleteSelectedItem() { | ||
| - final var c = getSelectedItem(); | ||
| - getSiblings( c ).remove( c ); | ||
| - } | ||
| - | ||
| - private void insertSelectedItem() { | ||
| - if( getSelectedItem() instanceof DefinitionTreeItem<String> node ) { | ||
| - if( node.isLeaf() ) { | ||
| - InsertDefinitionEvent.fire( node ); | ||
| - } | ||
| - } | ||
| - } | ||
| - | ||
| - /** | ||
| - * Adds a new item under the selected item (or root if nothing is selected). | ||
| - * There are a few conditions to consider: when adding to the root, | ||
| - * when adding to a leaf, and when adding to a non-leaf. Items added to the | ||
| - * root must contain two items: a key and a value. | ||
| - */ | ||
| - @Override | ||
| - public void createDefinition() { | ||
| - final var value = createDefinitionTreeItem(); | ||
| - getSelectedItem().getChildren().add( value ); | ||
| - expand( value ); | ||
| - select( value ); | ||
| - } | ||
| - | ||
| - private ContextMenu createContextMenu() { | ||
| - final var menu = new ContextMenu(); | ||
| - final var items = menu.getItems(); | ||
| - | ||
| - addMenuItem( items, ACTION_PREFIX + "definition.create.text" ) | ||
| - .setOnAction( e -> createDefinition() ); | ||
| - addMenuItem( items, ACTION_PREFIX + "definition.rename.text" ) | ||
| - .setOnAction( e -> renameDefinition() ); | ||
| - addMenuItem( items, ACTION_PREFIX + "definition.delete.text" ) | ||
| - .setOnAction( e -> deleteSelectedItem() ); | ||
| - addMenuItem( items, ACTION_PREFIX + "definition.insert.text" ) | ||
| - .setOnAction( e -> insertSelectedItem() ); | ||
| - | ||
| - return menu; | ||
| - } | ||
| - | ||
| - /** | ||
| - * Executes hot-keys for edits to the definition tree. | ||
| - * | ||
| - * @param event Contains the key code of the key that was pressed. | ||
| - */ | ||
| - private void keyEventFilter( final KeyEvent event ) { | ||
| - if( !isEditingTreeItem() ) { | ||
| - switch( event.getCode() ) { | ||
| - case ENTER -> { | ||
| - expand( getSelectedItem() ); | ||
| - event.consume(); | ||
| - } | ||
| - | ||
| - case DELETE -> deleteDefinitions(); | ||
| - case INSERT -> createDefinition(); | ||
| - | ||
| - case R -> { | ||
| - if( event.isControlDown() ) { | ||
| - renameDefinition(); | ||
| - } | ||
| - } | ||
| - | ||
| - default -> { } | ||
| - } | ||
| - | ||
| - for( final var handler : getKeyEventHandlers() ) { | ||
| - handler.handle( event ); | ||
| - } | ||
| - } | ||
| - } | ||
| - | ||
| - /** | ||
| - * Called when the editor's input focus changes. This will fire an event | ||
| - * for subscribers. | ||
| - * | ||
| - * @param ignored Not used. | ||
| - * @param o The old input focus property value. | ||
| - * @param n The new input focus property value. | ||
| - */ | ||
| - private void focused( | ||
| - final ObservableValue<? extends Boolean> ignored, | ||
| - final Boolean o, | ||
| - final Boolean n ) { | ||
| - if( n != null && n ) { | ||
| - TextDefinitionFocusEvent.fire( this ); | ||
| - } | ||
| - } | ||
| - | ||
| - /** | ||
| - * Adds a menu item to a list of menu items. | ||
| - * | ||
| - * @param items The list of menu items to append to. | ||
| - * @param labelKey The resource bundle key name for the menu item's label. | ||
| - * @return The menu item added to the list of menu items. | ||
| - */ | ||
| - private MenuItem addMenuItem( | ||
| - final List<MenuItem> items, final String labelKey ) { | ||
| - final MenuItem menuItem = createMenuItem( labelKey ); | ||
| - items.add( menuItem ); | ||
| - return menuItem; | ||
| - } | ||
| - | ||
| - private MenuItem createMenuItem( final String labelKey ) { | ||
| - return new MenuItem( get( labelKey ) ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Creates a new {@link TreeItem} that is intended to be the root-level item | ||
| - * added to the {@link TreeView}. This allows the root item to be | ||
| - * distinguished from the other items so that reference keys do not include | ||
| - * "Definition" as part of their name. | ||
| - * | ||
| - * @return A new {@link TreeItem}, never {@code null}. | ||
| - */ | ||
| - private RootTreeItem<String> createRootTreeItem() { | ||
| - return new RootTreeItem<>( get( "Pane.definition.node.root.title" ) ); | ||
| - } | ||
| - | ||
| - private DefinitionTreeItem<String> createDefinitionTreeItem() { | ||
| - return new DefinitionTreeItem<>( get( "Definition.menu.add.default" ) ); | ||
| - } | ||
| - | ||
| - @Override | ||
| - public void requestFocus() { | ||
| - getTreeView().requestFocus(); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Expands the node to the root, recursively. | ||
| - * | ||
| - * @param <T> The type of tree item to expand (usually String). | ||
| - * @param node The node to expand. | ||
| - */ | ||
| - @Override | ||
| - public <T> void expand( final TreeItem<T> node ) { | ||
| - if( node != null ) { | ||
| - expand( node.getParent() ); | ||
| - node.setExpanded( !node.isLeaf() ); | ||
| - } | ||
| - } | ||
| - | ||
| - /** | ||
| - * Answers whether there are any definitions in the tree. | ||
| - * | ||
| - * @return {@code true} when there are no definitions; {@code false} when | ||
| - * there's at least one definition. | ||
| - */ | ||
| - @Override | ||
| - public boolean isEmpty() { | ||
| - return getTreeRoot().isEmpty(); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Returns the actively selected item in the tree. | ||
| - * | ||
| - * @return The selected item, or the tree root item if no item is selected. | ||
| - */ | ||
| - public TreeItem<String> getSelectedItem() { | ||
| - final var item = getSelectionModel().getSelectedItem(); | ||
| - return item == null ? getTreeRoot() : item; | ||
| - } | ||
| - | ||
| - /** | ||
| - * Returns the {@link TreeView} that contains the definition hierarchy. | ||
| - * | ||
| - * @return A non-null instance. | ||
| - */ | ||
| - private TreeView<String> getTreeView() { | ||
| - return mTreeView; | ||
| - } | ||
| - | ||
| - /** | ||
| - * Returns the root of the tree. | ||
| - * | ||
| - * @return The first node added to the definition tree. | ||
| - */ | ||
| - private DefinitionTreeItem<String> getTreeRoot() { | ||
| - return mTreeRoot; | ||
| - } | ||
| - | ||
| - private ObservableList<TreeItem<String>> getSiblings( | ||
| - final TreeItem<String> item ) { | ||
| - final var root = getTreeView().getRoot(); | ||
| - final var parent = (item == null || item == root) ? root : item.getParent(); | ||
| + createButton( "create", _ -> createDefinition() ), | ||
| + createButton( "rename", _ -> renameDefinition() ), | ||
| + createButton( "delete", _ -> deleteDefinitions() ) | ||
| + ); | ||
| + buttonBar.setAlignment( CENTER ); | ||
| + buttonBar.setSpacing( UI_CONTROL_SPACING ); | ||
| + setTop( buttonBar ); | ||
| + setCenter( mTreeView ); | ||
| + setAlignment( buttonBar, TOP_CENTER ); | ||
| + | ||
| + mEncoding = open( mFile ); | ||
| + updateDefinitions( getDefinitions(), getTreeView().getRoot() ); | ||
| + | ||
| + // After the file is opened, watch for changes, not before. Otherwise, | ||
| + // upon saving, users will be prompted to save a file that hasn't had | ||
| + // any modifications (from their perspective). | ||
| + addTreeChangeHandler( _ -> { | ||
| + mModified.set( true ); | ||
| + updateDefinitions( getDefinitions(), getTreeView().getRoot() ); | ||
| + } ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Replaces the given list of variable definitions with a flat hierarchy | ||
| + * of the converted {@link TreeView} root. | ||
| + * | ||
| + * @param definitions The definition map to update. | ||
| + * @param root The values to flatten then insert into the map. | ||
| + */ | ||
| + private void updateDefinitions( | ||
| + final Map<String, String> definitions, | ||
| + final TreeItem<String> root ) { | ||
| + definitions.clear(); | ||
| + definitions.putAll( TreeItemMapper.convert( root ) ); | ||
| + Engine.clear(); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Returns the variable definitions. | ||
| + * | ||
| + * @return The definition map. | ||
| + */ | ||
| + @Override | ||
| + public Map<String, String> getDefinitions() { | ||
| + return mDefinitions; | ||
| + } | ||
| + | ||
| + @Override | ||
| + public void setText( final String document ) { | ||
| + final var foster = mTreeTransformer.transform( document ); | ||
| + final var biological = getTreeRoot(); | ||
| + | ||
| + for( final var child : foster.getChildren() ) { | ||
| + biological.getChildren().add( child ); | ||
| + } | ||
| + | ||
| + getTreeView().refresh(); | ||
| + } | ||
| + | ||
| + @Override | ||
| + public String getText() { | ||
| + final var result = new StringBuilder( 32768 ); | ||
| + | ||
| + try { | ||
| + result.append( mTreeTransformer.transform( getTreeView().getRoot() ) ); | ||
| + | ||
| + final var problem = isTreeWellFormed(); | ||
| + problem.ifPresent( node -> clue( "yaml.error.tree.form", node ) ); | ||
| + } catch( final Exception ex ) { | ||
| + // Catch errors while checking for a well-formed tree (e.g., stack smash). | ||
| + // Also catch any transformation exceptions (e.g., Json processing). | ||
| + clue( ex ); | ||
| + } | ||
| + | ||
| + return result.toString(); | ||
| + } | ||
| + | ||
| + @Override | ||
| + public File getFile() { | ||
| + return mFile; | ||
| + } | ||
| + | ||
| + @Override | ||
| + public void rename( final File file ) { | ||
| + mFile = file; | ||
| + } | ||
| + | ||
| + @Override | ||
| + public Charset getEncoding() { | ||
| + return mEncoding; | ||
| + } | ||
| + | ||
| + @Override | ||
| + public Node getNode() { | ||
| + return this; | ||
| + } | ||
| + | ||
| + @Override | ||
| + public ReadOnlyBooleanProperty modifiedProperty() { | ||
| + return mModified; | ||
| + } | ||
| + | ||
| + @Override | ||
| + public void clearModifiedProperty() { | ||
| + mModified.setValue( false ); | ||
| + } | ||
| + | ||
| + private Button createButton( | ||
| + final String msgKey, final EventHandler<ActionEvent> eventHandler ) { | ||
| + final var keyPrefix = STR."\{Constants.ACTION_PREFIX}definition.\{msgKey}"; | ||
| + final var button = new Button( get( STR."\{keyPrefix}.text" ) ); | ||
| + final var graphic = createGraphic( get( STR."\{keyPrefix}.icon" ) ); | ||
| + | ||
| + button.setOnAction( eventHandler ); | ||
| + button.setGraphic( graphic ); | ||
| + button.setTooltip( new Tooltip( get( STR."\{keyPrefix}.tooltip" ) ) ); | ||
| + | ||
| + return button; | ||
| + } | ||
| + | ||
| + /** | ||
| + * Informs the caller of whenever any {@link TreeItem} in the {@link TreeView} | ||
| + * is modified. The modifications include: item value changes, item additions, | ||
| + * and item removals. | ||
| + * <p> | ||
| + * Safe to call multiple times; if a handler is already registered, the | ||
| + * old handler is used. | ||
| + * </p> | ||
| + * | ||
| + * @param handler The handler to call whenever any {@link TreeItem} changes. | ||
| + */ | ||
| + public void addTreeChangeHandler( | ||
| + final EventHandler<TreeItem.TreeModificationEvent<Event>> handler ) { | ||
| + final var root = getTreeView().getRoot(); | ||
| + root.addEventHandler( valueChangedEvent(), handler ); | ||
| + root.addEventHandler( childrenModificationEvent(), handler ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Answers whether the {@link TreeItem}s in the {@link TreeView} are suitably | ||
| + * well-formed for export. A tree is considered well-formed if the following | ||
| + * conditions are met: | ||
| + * | ||
| + * <ul> | ||
| + * <li>The root node contains at least one child node having a leaf.</li> | ||
| + * <li>There are no leaf nodes with sibling leaf nodes.</li> | ||
| + * </ul> | ||
| + * | ||
| + * @return {@code null} if the document is well-formed, otherwise the | ||
| + * problematic child {@link TreeItem}. | ||
| + */ | ||
| + public Optional<TreeItem<String>> isTreeWellFormed() { | ||
| + final var root = getTreeView().getRoot(); | ||
| + | ||
| + for( final var child : root.getChildren() ) { | ||
| + final var problemChild = isWellFormed( child ); | ||
| + | ||
| + if( child.isLeaf() || problemChild != null ) { | ||
| + return Optional.ofNullable( problemChild ); | ||
| + } | ||
| + } | ||
| + | ||
| + return Optional.empty(); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Determines whether the document is well-formed by ensuring that | ||
| + * child branches do not contain multiple leaves. | ||
| + * | ||
| + * @param item The subtree to check for well-formedness. | ||
| + * @return {@code null} when the tree is well-formed, otherwise the | ||
| + * problematic {@link TreeItem}. | ||
| + */ | ||
| + private TreeItem<String> isWellFormed( final TreeItem<String> item ) { | ||
| + int childLeafs = 0; | ||
| + int childBranches = 0; | ||
| + | ||
| + for( final var child : item.getChildren() ) { | ||
| + if( child.isLeaf() ) { | ||
| + childLeafs++; | ||
| + } | ||
| + else { | ||
| + childBranches++; | ||
| + } | ||
| + | ||
| + final var problemChild = isWellFormed( child ); | ||
| + | ||
| + if( problemChild != null ) { | ||
| + return problemChild; | ||
| + } | ||
| + } | ||
| + | ||
| + return ((childBranches > 0 && childLeafs == 0) || | ||
| + (childBranches == 0 && childLeafs <= 1)) | ||
| + ? null | ||
| + : item; | ||
| + } | ||
| + | ||
| + @Override | ||
| + public DefinitionTreeItem<String> findLeafExact( final String text ) { | ||
| + return getTreeRoot().findLeafExact( text ); | ||
| + } | ||
| + | ||
| + @Override | ||
| + public DefinitionTreeItem<String> findLeafContains( final String text ) { | ||
| + return getTreeRoot().findLeafContains( text ); | ||
| + } | ||
| + | ||
| + @Override | ||
| + public DefinitionTreeItem<String> findLeafContainsNoCase( | ||
| + final String text ) { | ||
| + return getTreeRoot().findLeafContainsNoCase( text ); | ||
| + } | ||
| + | ||
| + @Override | ||
| + public DefinitionTreeItem<String> findLeafStartsWith( final String text ) { | ||
| + return getTreeRoot().findLeafStartsWith( text ); | ||
| + } | ||
| + | ||
| + public void select( final TreeItem<String> item ) { | ||
| + getSelectionModel().clearSelection(); | ||
| + getSelectionModel().select( getTreeView().getRow( item ) ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Collapses the tree, recursively. | ||
| + */ | ||
| + public void collapse() { | ||
| + collapse( getTreeRoot().getChildren() ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Collapses the tree, recursively. | ||
| + * | ||
| + * @param <T> The type of tree item to expand (usually String). | ||
| + * @param nodes The nodes to collapse. | ||
| + */ | ||
| + private <T> void collapse( final ObservableList<TreeItem<T>> nodes ) { | ||
| + for( final var node : nodes ) { | ||
| + node.setExpanded( false ); | ||
| + collapse( node.getChildren() ); | ||
| + } | ||
| + } | ||
| + | ||
| + /** | ||
| + * @return {@code true} when the user is editing a {@link TreeItem}. | ||
| + */ | ||
| + private boolean isEditingTreeItem() { | ||
| + return getTreeView().editingItemProperty().getValue() != null; | ||
| + } | ||
| + | ||
| + /** | ||
| + * Changes to edit mode for the selected item. | ||
| + */ | ||
| + @Override | ||
| + public void renameDefinition() { | ||
| + getTreeView().edit( getSelectedItem() ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Removes all selected items from the {@link TreeView}. | ||
| + */ | ||
| + @Override | ||
| + public void deleteDefinitions() { | ||
| + for( final var item : getSelectedItems() ) { | ||
| + final var parent = item.getParent(); | ||
| + | ||
| + if( parent != null ) { | ||
| + parent.getChildren().remove( item ); | ||
| + } | ||
| + } | ||
| + } | ||
| + | ||
| + /** | ||
| + * Deletes the selected item. | ||
| + */ | ||
| + private void deleteSelectedItem() { | ||
| + final var c = getSelectedItem(); | ||
| + getSiblings( c ).remove( c ); | ||
| + } | ||
| + | ||
| + private void insertSelectedItem() { | ||
| + if( getSelectedItem() instanceof DefinitionTreeItem<String> node ) { | ||
| + if( node.isLeaf() ) { | ||
| + InsertDefinitionEvent.fire( node ); | ||
| + } | ||
| + } | ||
| + } | ||
| + | ||
| + /** | ||
| + * Adds a new item under the selected item (or root if nothing is selected). | ||
| + * There are a few conditions to consider: when adding to the root, | ||
| + * when adding to a leaf, and when adding to a non-leaf. Items added to the | ||
| + * root must contain two items: a key and a value. | ||
| + */ | ||
| + @Override | ||
| + public void createDefinition() { | ||
| + final var value = createDefinitionTreeItem(); | ||
| + getSelectedItem().getChildren().add( value ); | ||
| + expand( value ); | ||
| + select( value ); | ||
| + } | ||
| + | ||
| + private ContextMenu createContextMenu() { | ||
| + final var menu = new ContextMenu(); | ||
| + final var items = menu.getItems(); | ||
| + | ||
| + addMenuItem( items, STR."\{ACTION_PREFIX}definition.create.text" ) | ||
| + .setOnAction( _ -> createDefinition() ); | ||
| + addMenuItem( items, STR."\{ACTION_PREFIX}definition.rename.text" ) | ||
| + .setOnAction( _ -> renameDefinition() ); | ||
| + addMenuItem( items, STR."\{ACTION_PREFIX}definition.delete.text" ) | ||
| + .setOnAction( _ -> deleteSelectedItem() ); | ||
| + addMenuItem( items, STR."\{ACTION_PREFIX}definition.insert.text" ) | ||
| + .setOnAction( _ -> insertSelectedItem() ); | ||
| + | ||
| + return menu; | ||
| + } | ||
| + | ||
| + /** | ||
| + * Executes hot-keys for edits to the definition tree. | ||
| + * | ||
| + * @param event Contains the key code of the key that was pressed. | ||
| + */ | ||
| + private void keyEventFilter( final KeyEvent event ) { | ||
| + if( !isEditingTreeItem() ) { | ||
| + switch( event.getCode() ) { | ||
| + case ENTER -> { | ||
| + expand( getSelectedItem() ); | ||
| + event.consume(); | ||
| + } | ||
| + | ||
| + case DELETE -> deleteDefinitions(); | ||
| + case INSERT -> createDefinition(); | ||
| + | ||
| + case R -> { | ||
| + if( event.isControlDown() ) { | ||
| + renameDefinition(); | ||
| + } | ||
| + } | ||
| + | ||
| + default -> {} | ||
| + } | ||
| + | ||
| + for( final var handler : getKeyEventHandlers() ) { | ||
| + handler.handle( event ); | ||
| + } | ||
| + } | ||
| + } | ||
| + | ||
| + /** | ||
| + * Called when the editor's input focus changes. This will fire an event | ||
| + * for subscribers. | ||
| + * | ||
| + * @param ignored Not used. | ||
| + * @param o The old input focus property value. | ||
| + * @param n The new input focus property value. | ||
| + */ | ||
| + private void focused( | ||
| + final ObservableValue<? extends Boolean> ignored, | ||
| + final Boolean o, | ||
| + final Boolean n ) { | ||
| + if( n != null && n ) { | ||
| + TextDefinitionFocusEvent.fire( this ); | ||
| + } | ||
| + } | ||
| + | ||
| + /** | ||
| + * Adds a menu item to a list of menu items. | ||
| + * | ||
| + * @param items The list of menu items to append to. | ||
| + * @param labelKey The resource bundle key name for the menu item's label. | ||
| + * @return The menu item added to the list of menu items. | ||
| + */ | ||
| + private MenuItem addMenuItem( | ||
| + final List<MenuItem> items, final String labelKey ) { | ||
| + final MenuItem menuItem = createMenuItem( labelKey ); | ||
| + items.add( menuItem ); | ||
| + return menuItem; | ||
| + } | ||
| + | ||
| + private MenuItem createMenuItem( final String labelKey ) { | ||
| + return new MenuItem( get( labelKey ) ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Creates a new {@link TreeItem} that is intended to be the root-level item | ||
| + * added to the {@link TreeView}. This allows the root item to be | ||
| + * distinguished from the other items so that reference keys do not include | ||
| + * "Definition" as part of their name. | ||
| + * | ||
| + * @return A new {@link TreeItem}, never {@code null}. | ||
| + */ | ||
| + private RootTreeItem<String> createRootTreeItem() { | ||
| + return new RootTreeItem<>( get( "Pane.definition.node.root.title" ) ); | ||
| + } | ||
| + | ||
| + private DefinitionTreeItem<String> createDefinitionTreeItem() { | ||
| + return new DefinitionTreeItem<>( get( "Definition.menu.add.default" ) ); | ||
| + } | ||
| + | ||
| + @Override | ||
| + public void requestFocus() { | ||
| + getTreeView().requestFocus(); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Expands the node to the root, recursively. | ||
| + * | ||
| + * @param <T> The type of tree item to expand (usually String). | ||
| + * @param node The node to expand. | ||
| + */ | ||
| + @Override | ||
| + public <T> void expand( final TreeItem<T> node ) { | ||
| + if( node != null ) { | ||
| + expand( node.getParent() ); | ||
| + node.setExpanded( !node.isLeaf() ); | ||
| + } | ||
| + } | ||
| + | ||
| + /** | ||
| + * Answers whether there are any definitions in the tree. | ||
| + * | ||
| + * @return {@code true} when there are no definitions; {@code false} when | ||
| + * there's at least one definition. | ||
| + */ | ||
| + @Override | ||
| + public boolean isEmpty() { | ||
| + return getTreeRoot().isEmpty(); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Returns the actively selected item in the tree. | ||
| + * | ||
| + * @return The selected item, or the tree root item if no item is selected. | ||
| + */ | ||
| + public TreeItem<String> getSelectedItem() { | ||
| + final var item = getSelectionModel().getSelectedItem(); | ||
| + return item == null ? getTreeRoot() : item; | ||
| + } | ||
| + | ||
| + /** | ||
| + * Returns the {@link TreeView} that contains the definition hierarchy. | ||
| + * | ||
| + * @return A non-null instance. | ||
| + */ | ||
| + private TreeView<String> getTreeView() { | ||
| + return mTreeView; | ||
| + } | ||
| + | ||
| + /** | ||
| + * Returns the root of the tree. | ||
| + * | ||
| + * @return The first node added to the definition tree. | ||
| + */ | ||
| + private DefinitionTreeItem<String> getTreeRoot() { | ||
| + return mTreeRoot; | ||
| + } | ||
| + | ||
| + private ObservableList<TreeItem<String>> getSiblings( | ||
| + final TreeItem<String> item ) { | ||
| + final var root = getTreeView().getRoot(); | ||
| + final var parent = (item == null || item == root) | ||
| + ? root | ||
| + : item.getParent(); | ||
| return parent.getChildren(); |
| public static final Key KEY_TYPESET_TYPOGRAPHY = key( KEY_TYPESET, "typography" ); | ||
| public static final Key KEY_TYPESET_TYPOGRAPHY_QUOTES = key( KEY_TYPESET_TYPOGRAPHY, "quotes" ); | ||
| + public static final Key KEY_TYPESET_MODES = key( KEY_TYPESET, "modes" ); | ||
| + public static final Key KEY_TYPESET_MODES_ENABLED = key( KEY_TYPESET_MODES, "enabled" ); | ||
| //@formatter:on | ||
| final var control = new SimpleFontControl( "Change" ); | ||
| - control.fontSizeProperty().addListener( ( c, o, n ) -> { | ||
| + control.fontSizeProperty().addListener( ( _, _, n ) -> { | ||
| if( n != null ) { | ||
| fontSize.set( n.doubleValue() ); | ||
| Setting.of( title( KEY_TYPESET_TYPOGRAPHY_QUOTES ), | ||
| booleanProperty( KEY_TYPESET_TYPOGRAPHY_QUOTES ) ) | ||
| + ), | ||
| + Group.of( | ||
| + get( KEY_TYPESET_MODES ), | ||
| + Setting.of( label( KEY_TYPESET_MODES_ENABLED ) ), | ||
| + Setting.of( title( KEY_TYPESET_MODES_ENABLED ), | ||
| + stringProperty( KEY_TYPESET_MODES_ENABLED ) ) | ||
| ) | ||
| ), | ||
| final var view = preferences.getView(); | ||
| final var nodes = view.getChildrenUnmodifiable(); | ||
| - final var master = (MasterDetailPane) nodes.get( 0 ); | ||
| + final var master = (MasterDetailPane) nodes.getFirst(); | ||
| final var detail = (NavigationView) master.getDetailNode(); | ||
| final var pane = (DialogPane) view.getParent(); | ||
| detail.setOnKeyReleased( key -> { | ||
| switch( key.getCode() ) { | ||
| case ENTER -> ((Button) pane.lookupButton( OK )).fire(); | ||
| case ESCAPE -> ((Button) pane.lookupButton( CANCEL )).fire(); | ||
| - default -> { } | ||
| + default -> {} | ||
| } | ||
| } ); | ||
| private void initSaveEventHandler( final PreferencesFx preferences ) { | ||
| preferences.addEventHandler( | ||
| - EVENT_PREFERENCES_SAVED, event -> mWorkspace.save() | ||
| + EVENT_PREFERENCES_SAVED, _ -> mWorkspace.save() | ||
| ); | ||
| } | ||
| private Node label( final Key key, final String... values ) { | ||
| - return new Label( get( key.toString() + ".desc", (Object[]) values ) ); | ||
| + return new Label( get( STR."\{key.toString()}.desc", (Object[]) values ) ); | ||
| } | ||
| private String title( final Key key ) { | ||
| - return get( key.toString() + ".title" ); | ||
| + return get( STR."\{key.toString()}.title" ); | ||
| } | ||
| entry( KEY_TYPESET_CONTEXT_THEME_SELECTION, asStringProperty( "boschet" ) ), | ||
| entry( KEY_TYPESET_CONTEXT_CHAPTERS, asStringProperty( "" ) ), | ||
| - entry( KEY_TYPESET_TYPOGRAPHY_QUOTES, asBooleanProperty( true ) ) | ||
| - //@formatter:on | ||
| - ); | ||
| - | ||
| - /** | ||
| - * Sets of configuration values, all the same type (e.g., file names), | ||
| - * where the key name doesn't change per set. | ||
| - */ | ||
| - private final Map<Key, SetProperty<?>> mSets = Map.ofEntries( | ||
| - entry( | ||
| - KEY_UI_RECENT_OPEN_PATH, | ||
| - createSetProperty( new HashSet<String>() ) | ||
| - ) | ||
| - ); | ||
| - | ||
| - /** | ||
| - * Lists of configuration values, such as key-value pairs where both the | ||
| - * key name and the value must be preserved per list. | ||
| - */ | ||
| - private final Map<Key, ListProperty<?>> mLists = Map.ofEntries( | ||
| - entry( | ||
| - KEY_DOC_META, | ||
| - createListProperty( new LinkedList<Entry<String, String>>() ) | ||
| - ) | ||
| - ); | ||
| - | ||
| - /** | ||
| - * Helps instantiate {@link Property} instances for XML configuration items. | ||
| - */ | ||
| - private static final Map<Class<?>, Function<String, Object>> UNMARSHALL = | ||
| - Map.of( | ||
| - LocaleProperty.class, LocaleProperty::parseLocale, | ||
| - SimpleBooleanProperty.class, Boolean::parseBoolean, | ||
| - SimpleIntegerProperty.class, Integer::parseInt, | ||
| - SimpleDoubleProperty.class, Double::parseDouble, | ||
| - SimpleFloatProperty.class, Float::parseFloat, | ||
| - SimpleStringProperty.class, String::new, | ||
| - SimpleObjectProperty.class, String::new, | ||
| - SkinProperty.class, String::new, | ||
| - FileProperty.class, File::new | ||
| - ); | ||
| - | ||
| - /** | ||
| - * The asymmetry with respect to {@link #UNMARSHALL} is because most objects | ||
| - * can simply call {@link Object#toString()} to convert the value to a string. | ||
| - */ | ||
| - private static final Map<Class<?>, Function<String, Object>> MARSHALL = | ||
| - Map.of( | ||
| - LocaleProperty.class, LocaleProperty::toLanguageTag | ||
| - ); | ||
| - | ||
| - /** | ||
| - * Converts the given {@link Property} value to a string. | ||
| - * | ||
| - * @param property The {@link Property} to convert. | ||
| - * @return A string representation of the given property, or the empty | ||
| - * string if no conversion was possible. | ||
| - */ | ||
| - private static String marshall( final Property<?> property ) { | ||
| - final var v = property.getValue(); | ||
| - | ||
| - return v == null | ||
| - ? "" | ||
| - : MARSHALL | ||
| - .getOrDefault( property.getClass(), __ -> property.getValue() ) | ||
| - .apply( v.toString() ) | ||
| - .toString(); | ||
| - } | ||
| - | ||
| - private static Object unmarshall( | ||
| - final Property<?> property, final Object configValue ) { | ||
| - final var v = configValue.toString(); | ||
| - | ||
| - return UNMARSHALL | ||
| - .getOrDefault( property.getClass(), value -> property.getValue() ) | ||
| - .apply( v ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Creates an instance of {@link ObservableList} that is based on a | ||
| - * modifiable observable array list for the given items. | ||
| - * | ||
| - * @param items The items to wrap in an observable list. | ||
| - * @param <E> The type of items to add to the list. | ||
| - * @return An observable property that can have its contents modified. | ||
| - */ | ||
| - public static <E> ObservableList<E> listProperty( final Set<E> items ) { | ||
| - return new SimpleListProperty<>( observableArrayList( items ) ); | ||
| - } | ||
| - | ||
| - private static <E> SetProperty<E> createSetProperty( final Set<E> set ) { | ||
| - return new SimpleSetProperty<>( observableSet( set ) ); | ||
| - } | ||
| - | ||
| - private static <E> ListProperty<E> createListProperty( final List<E> list ) { | ||
| - return new SimpleListProperty<>( observableArrayList( list ) ); | ||
| - } | ||
| - | ||
| - private static StringProperty asStringProperty( final String value ) { | ||
| - return new SimpleStringProperty( value ); | ||
| - } | ||
| - | ||
| - private static BooleanProperty asBooleanProperty() { | ||
| - return new SimpleBooleanProperty(); | ||
| - } | ||
| - | ||
| - /** | ||
| - * @param value Default value. | ||
| - */ | ||
| - @SuppressWarnings( "SameParameterValue" ) | ||
| - private static BooleanProperty asBooleanProperty( final boolean value ) { | ||
| - return new SimpleBooleanProperty( value ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * @param value Default value. | ||
| - */ | ||
| - @SuppressWarnings( "SameParameterValue" ) | ||
| - private static IntegerProperty asIntegerProperty( final int value ) { | ||
| - return new SimpleIntegerProperty( value ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * @param value Default value. | ||
| - */ | ||
| - private static DoubleProperty asDoubleProperty( final double value ) { | ||
| - return new SimpleDoubleProperty( value ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * @param value Default value. | ||
| - */ | ||
| - private static FileProperty asFileProperty( final File value ) { | ||
| - return new FileProperty( value ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * @param value Default value. | ||
| - */ | ||
| - @SuppressWarnings( "SameParameterValue" ) | ||
| - private static LocaleProperty asLocaleProperty( final Locale value ) { | ||
| - return new LocaleProperty( value ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * @param value Default value. | ||
| - */ | ||
| - @SuppressWarnings( "SameParameterValue" ) | ||
| - private static SkinProperty asSkinProperty( final String value ) { | ||
| - return new SkinProperty( value ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Creates a new {@link Workspace} that will attempt to load the users' | ||
| - * preferences. If the configuration file cannot be loaded, the workspace | ||
| - * settings returns default values. | ||
| - */ | ||
| - public Workspace() { | ||
| - load(); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Attempts to load the app's configuration file. | ||
| - */ | ||
| - private void load() { | ||
| - final var store = createXmlStore(); | ||
| - store.load( FILE_PREFERENCES ); | ||
| - | ||
| - mValues.keySet().forEach( key -> { | ||
| - try { | ||
| - final var storeValue = store.getValue( key ); | ||
| - final var property = valuesProperty( key ); | ||
| - final var unmarshalled = unmarshall( property, storeValue ); | ||
| - | ||
| - property.setValue( unmarshalled ); | ||
| - } catch( final NoSuchElementException ex ) { | ||
| - // When no configuration (item), use the default value. | ||
| - clue( ex ); | ||
| - } | ||
| - } ); | ||
| - | ||
| - mSets.keySet().forEach( key -> { | ||
| - final var set = store.getSet( key ); | ||
| - final SetProperty<String> property = setsProperty( key ); | ||
| - | ||
| - property.setValue( observableSet( set ) ); | ||
| - } ); | ||
| - | ||
| - mLists.keySet().forEach( key -> { | ||
| - final var map = store.getMap( key ); | ||
| - final ListProperty<Entry<String, String>> property = listsProperty( key ); | ||
| - final var list = map | ||
| - .entrySet() | ||
| - .stream() | ||
| - .toList(); | ||
| - | ||
| - property.setValue( observableArrayList( list ) ); | ||
| - } ); | ||
| - | ||
| - WorkspaceLoadedEvent.fire( this ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Saves the current workspace. | ||
| - */ | ||
| - public void save() { | ||
| - final var store = createXmlStore(); | ||
| - | ||
| - try { | ||
| - // Update the string values to include the application version. | ||
| - valuesProperty( KEY_META_VERSION ).setValue( getVersion() ); | ||
| - | ||
| - mValues.forEach( ( k, v ) -> store.setValue( k, marshall( v ) ) ); | ||
| - mSets.forEach( store::setSet ); | ||
| - mLists.forEach( store::setMap ); | ||
| - | ||
| - store.save( FILE_PREFERENCES ); | ||
| - } catch( final Exception ex ) { | ||
| - clue( ex ); | ||
| - } | ||
| - } | ||
| - | ||
| - /** | ||
| - * Returns a value that represents a setting in the application that the user | ||
| - * may configure, either directly or indirectly. | ||
| - * | ||
| - * @param key The reference to the users' preference stored in deference | ||
| - * of app reëntrance. | ||
| - * @return An observable property to be persisted. | ||
| - */ | ||
| - @SuppressWarnings( "unchecked" ) | ||
| - public <T, U extends Property<T>> U valuesProperty( final Key key ) { | ||
| - assert key != null; | ||
| - return (U) mValues.get( key ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Returns a set of values that represent a setting in the application that | ||
| - * the user may configure, either directly or indirectly. The property | ||
| - * returned is backed by a {@link Set}. | ||
| - * | ||
| - * @param key The {@link Key} associated with a preference value. | ||
| - * @return An observable property to be persisted. | ||
| - */ | ||
| - @SuppressWarnings( "unchecked" ) | ||
| - public <T> SetProperty<T> setsProperty( final Key key ) { | ||
| - assert key != null; | ||
| - return (SetProperty<T>) mSets.get( key ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Returns a list of values that represent a setting in the application that | ||
| - * the user may configure, either directly or indirectly. The property | ||
| - * returned is backed by a mutable {@link List}. | ||
| - * | ||
| - * @param key The {@link Key} associated with a preference value. | ||
| - * @return An observable property to be persisted. | ||
| - */ | ||
| - @SuppressWarnings( "unchecked" ) | ||
| - public <K, V> ListProperty<Entry<K, V>> listsProperty( final Key key ) { | ||
| - assert key != null; | ||
| - return (ListProperty<Entry<K, V>>) mLists.get( key ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Returns the {@link String} {@link Property} associated with the given | ||
| - * {@link Key} from the internal list of preference values. The caller | ||
| - * must be sure that the given {@link Key} is associated with a {@link File} | ||
| - * {@link Property}. | ||
| - * | ||
| - * @param key The {@link Key} associated with a preference value. | ||
| - * @return The value associated with the given {@link Key}. | ||
| - */ | ||
| - public StringProperty stringProperty( final Key key ) { | ||
| - assert key != null; | ||
| - return valuesProperty( key ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Returns the {@link Boolean} {@link Property} associated with the given | ||
| - * {@link Key} from the internal list of preference values. The caller | ||
| - * must be sure that the given {@link Key} is associated with a {@link File} | ||
| - * {@link Property}. | ||
| - * | ||
| - * @param key The {@link Key} associated with a preference value. | ||
| - * @return The value associated with the given {@link Key}. | ||
| - */ | ||
| - public BooleanProperty booleanProperty( final Key key ) { | ||
| - assert key != null; | ||
| - return valuesProperty( key ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Returns the {@link Integer} {@link Property} associated with the given | ||
| - * {@link Key} from the internal list of preference values. The caller | ||
| - * must be sure that the given {@link Key} is associated with a {@link File} | ||
| - * {@link Property}. | ||
| - * | ||
| - * @param key The {@link Key} associated with a preference value. | ||
| - * @return The value associated with the given {@link Key}. | ||
| - */ | ||
| - public IntegerProperty integerProperty( final Key key ) { | ||
| - assert key != null; | ||
| - return valuesProperty( key ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Returns the {@link Double} {@link Property} associated with the given | ||
| - * {@link Key} from the internal list of preference values. The caller | ||
| - * must be sure that the given {@link Key} is associated with a {@link File} | ||
| - * {@link Property}. | ||
| - * | ||
| - * @param key The {@link Key} associated with a preference value. | ||
| - * @return The value associated with the given {@link Key}. | ||
| - */ | ||
| - public DoubleProperty doubleProperty( final Key key ) { | ||
| - assert key != null; | ||
| - return valuesProperty( key ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Returns the {@link File} {@link Property} associated with the given | ||
| - * {@link Key} from the internal list of preference values. The caller | ||
| - * must be sure that the given {@link Key} is associated with a {@link File} | ||
| - * {@link Property}. | ||
| - * | ||
| - * @param key The {@link Key} associated with a preference value. | ||
| - * @return The value associated with the given {@link Key}. | ||
| - */ | ||
| - public ObjectProperty<File> fileProperty( final Key key ) { | ||
| - assert key != null; | ||
| - return valuesProperty( key ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Returns the {@link Locale} {@link Property} associated with the given | ||
| - * {@link Key} from the internal list of preference values. The caller | ||
| - * must be sure that the given {@link Key} is associated with a {@link File} | ||
| - * {@link Property}. | ||
| - * | ||
| - * @param key The {@link Key} associated with a preference value. | ||
| - * @return The value associated with the given {@link Key}. | ||
| - */ | ||
| - public LocaleProperty localeProperty( final Key key ) { | ||
| - assert key != null; | ||
| - return valuesProperty( key ); | ||
| - } | ||
| - | ||
| - public ObjectProperty<String> skinProperty( final Key key ) { | ||
| - assert key != null; | ||
| - return valuesProperty( key ); | ||
| - } | ||
| - | ||
| - public String getString( final Key key ) { | ||
| - assert key != null; | ||
| - return stringProperty( key ).get(); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Returns the {@link Boolean} preference value associated with the given | ||
| - * {@link Key}. The caller must be sure that the given {@link Key} is | ||
| - * associated with a value that matches the return type. | ||
| - * | ||
| - * @param key The {@link Key} associated with a preference value. | ||
| - * @return The value associated with the given {@link Key}. | ||
| - */ | ||
| - public boolean getBoolean( final Key key ) { | ||
| - assert key != null; | ||
| - return booleanProperty( key ).get(); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Returns the {@link Integer} preference value associated with the given | ||
| - * {@link Key}. The caller must be sure that the given {@link Key} is | ||
| - * associated with a value that matches the return type. | ||
| - * | ||
| - * @param key The {@link Key} associated with a preference value. | ||
| - * @return The value associated with the given {@link Key}. | ||
| - */ | ||
| - @SuppressWarnings( "unused" ) | ||
| - public int getInteger( final Key key ) { | ||
| - assert key != null; | ||
| - return integerProperty( key ).get(); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Returns the {@link Double} preference value associated with the given | ||
| - * {@link Key}. The caller must be sure that the given {@link Key} is | ||
| - * associated with a value that matches the return type. | ||
| - * | ||
| - * @param key The {@link Key} associated with a preference value. | ||
| - * @return The value associated with the given {@link Key}. | ||
| - */ | ||
| - public double getDouble( final Key key ) { | ||
| - assert key != null; | ||
| - return doubleProperty( key ).get(); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Returns the {@link File} preference value associated with the given | ||
| - * {@link Key}. The caller must be sure that the given {@link Key} is | ||
| - * associated with a value that matches the return type. | ||
| - * | ||
| - * @param key The {@link Key} associated with a preference value. | ||
| - * @return The value associated with the given {@link Key}. | ||
| - */ | ||
| - public File getFile( final Key key ) { | ||
| - assert key != null; | ||
| - return fileProperty( key ).get(); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Returns the language locale setting for the | ||
| - * {@link AppKeys#KEY_LANGUAGE_LOCALE} key. | ||
| - * | ||
| - * @return The user's current locale setting. | ||
| - */ | ||
| - public Locale getLocale() { | ||
| - return localeProperty( KEY_LANGUAGE_LOCALE ).toLocale(); | ||
| - } | ||
| - | ||
| - @SuppressWarnings( "unchecked" ) | ||
| - public <K, V> Map<K, V> getMetadata() { | ||
| - final var metadata = listsProperty( KEY_DOC_META ); | ||
| - final HashMap<K, V> map; | ||
| - | ||
| - if( metadata != null ) { | ||
| - map = new HashMap<>( metadata.size() ); | ||
| - | ||
| - metadata.forEach( | ||
| - entry -> map.put( (K) entry.getKey(), (V) entry.getValue() ) | ||
| - ); | ||
| - } | ||
| - else { | ||
| - map = new HashMap<>(); | ||
| - } | ||
| - | ||
| - return map; | ||
| - } | ||
| - | ||
| - public Path getThemesPath() { | ||
| - final var dir = getFile( KEY_TYPESET_CONTEXT_THEMES_PATH ); | ||
| - final var name = getString( KEY_TYPESET_CONTEXT_THEME_SELECTION ); | ||
| - | ||
| - return Path.of( dir.toString(), name ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Delegates to {@link #listen(Key, ReadOnlyProperty, BooleanSupplier)}, | ||
| - * providing a value of {@code true} for the {@link BooleanSupplier} to | ||
| - * indicate the property changes always take effect. | ||
| - * | ||
| - * @param key The value to bind to the internal key property. | ||
| - * @param property The external property value that sets the internal value. | ||
| - */ | ||
| - public <T> void listen( final Key key, final ReadOnlyProperty<T> property ) { | ||
| - assert key != null; | ||
| - assert property != null; | ||
| - | ||
| - listen( key, property, () -> true ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Binds a read-only property to a value in the preferences. This allows | ||
| - * user interface properties to change and the preferences will be | ||
| - * synchronized automatically. | ||
| - * <p> | ||
| - * This calls {@link Platform#runLater(Runnable)} to ensure that all pending | ||
| - * application window states are finished before assessing whether property | ||
| - * changes should be applied. Without this, exiting the application while the | ||
| - * window is maximized would persist the window's maximum dimensions, | ||
| - * preventing restoration to its prior, non-maximum size. | ||
| - * | ||
| - * @param key The value to bind to the internal key property. | ||
| - * @param property The external property value that sets the internal value. | ||
| - * @param enabled Indicates whether property changes should be applied. | ||
| - */ | ||
| - public <T> void listen( | ||
| - final Key key, | ||
| - final ReadOnlyProperty<T> property, | ||
| - final BooleanSupplier enabled ) { | ||
| - assert key != null; | ||
| - assert property != null; | ||
| - assert enabled != null; | ||
| - | ||
| - property.addListener( | ||
| - ( c, o, n ) -> runLater( () -> { | ||
| + entry( KEY_TYPESET_TYPOGRAPHY_QUOTES, asBooleanProperty( true ) ), | ||
| + entry( KEY_TYPESET_MODES_ENABLED, asStringProperty( "" ) ) | ||
| + //@formatter:on | ||
| + ); | ||
| + | ||
| + /** | ||
| + * Sets of configuration values, all the same type (e.g., file names), | ||
| + * where the key name doesn't change per set. | ||
| + */ | ||
| + private final Map<Key, SetProperty<?>> mSets = Map.ofEntries( | ||
| + entry( | ||
| + KEY_UI_RECENT_OPEN_PATH, | ||
| + createSetProperty( new HashSet<String>() ) | ||
| + ) | ||
| + ); | ||
| + | ||
| + /** | ||
| + * Lists of configuration values, such as key-value pairs where both the | ||
| + * key name and the value must be preserved per list. | ||
| + */ | ||
| + private final Map<Key, ListProperty<?>> mLists = Map.ofEntries( | ||
| + entry( | ||
| + KEY_DOC_META, | ||
| + createListProperty( new LinkedList<Entry<String, String>>() ) | ||
| + ) | ||
| + ); | ||
| + | ||
| + /** | ||
| + * Helps instantiate {@link Property} instances for XML configuration items. | ||
| + */ | ||
| + private static final Map<Class<?>, Function<String, Object>> UNMARSHALL = | ||
| + Map.of( | ||
| + LocaleProperty.class, LocaleProperty::parseLocale, | ||
| + SimpleBooleanProperty.class, Boolean::parseBoolean, | ||
| + SimpleIntegerProperty.class, Integer::parseInt, | ||
| + SimpleDoubleProperty.class, Double::parseDouble, | ||
| + SimpleFloatProperty.class, Float::parseFloat, | ||
| + SimpleStringProperty.class, String::new, | ||
| + SimpleObjectProperty.class, String::new, | ||
| + SkinProperty.class, String::new, | ||
| + FileProperty.class, File::new | ||
| + ); | ||
| + | ||
| + /** | ||
| + * The asymmetry with respect to {@link #UNMARSHALL} is because most objects | ||
| + * can simply call {@link Object#toString()} to convert the value to a string. | ||
| + */ | ||
| + private static final Map<Class<?>, Function<String, Object>> MARSHALL = | ||
| + Map.of( | ||
| + LocaleProperty.class, LocaleProperty::toLanguageTag | ||
| + ); | ||
| + | ||
| + /** | ||
| + * Converts the given {@link Property} value to a string. | ||
| + * | ||
| + * @param property The {@link Property} to convert. | ||
| + * @return A string representation of the given property, or the empty | ||
| + * string if no conversion was possible. | ||
| + */ | ||
| + private static String marshall( final Property<?> property ) { | ||
| + final var v = property.getValue(); | ||
| + | ||
| + return v == null | ||
| + ? "" | ||
| + : MARSHALL | ||
| + .getOrDefault( property.getClass(), _ -> property.getValue() ) | ||
| + .apply( v.toString() ) | ||
| + .toString(); | ||
| + } | ||
| + | ||
| + private static Object unmarshall( | ||
| + final Property<?> property, final Object configValue ) { | ||
| + final var v = configValue.toString(); | ||
| + | ||
| + return UNMARSHALL | ||
| + .getOrDefault( property.getClass(), _ -> property.getValue() ) | ||
| + .apply( v ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Creates an instance of {@link ObservableList} that is based on a | ||
| + * modifiable observable array list for the given items. | ||
| + * | ||
| + * @param items The items to wrap in an observable list. | ||
| + * @param <E> The type of items to add to the list. | ||
| + * @return An observable property that can have its contents modified. | ||
| + */ | ||
| + public static <E> ObservableList<E> listProperty( final Set<E> items ) { | ||
| + return new SimpleListProperty<>( observableArrayList( items ) ); | ||
| + } | ||
| + | ||
| + private static <E> SetProperty<E> createSetProperty( final Set<E> set ) { | ||
| + return new SimpleSetProperty<>( observableSet( set ) ); | ||
| + } | ||
| + | ||
| + private static <E> ListProperty<E> createListProperty( final List<E> list ) { | ||
| + return new SimpleListProperty<>( observableArrayList( list ) ); | ||
| + } | ||
| + | ||
| + private static StringProperty asStringProperty( final String value ) { | ||
| + return new SimpleStringProperty( value ); | ||
| + } | ||
| + | ||
| + private static BooleanProperty asBooleanProperty() { | ||
| + return new SimpleBooleanProperty(); | ||
| + } | ||
| + | ||
| + /** | ||
| + * @param value Default value. | ||
| + */ | ||
| + @SuppressWarnings( "SameParameterValue" ) | ||
| + private static BooleanProperty asBooleanProperty( final boolean value ) { | ||
| + return new SimpleBooleanProperty( value ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * @param value Default value. | ||
| + */ | ||
| + @SuppressWarnings( "SameParameterValue" ) | ||
| + private static IntegerProperty asIntegerProperty( final int value ) { | ||
| + return new SimpleIntegerProperty( value ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * @param value Default value. | ||
| + */ | ||
| + private static DoubleProperty asDoubleProperty( final double value ) { | ||
| + return new SimpleDoubleProperty( value ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * @param value Default value. | ||
| + */ | ||
| + private static FileProperty asFileProperty( final File value ) { | ||
| + return new FileProperty( value ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * @param value Default value. | ||
| + */ | ||
| + @SuppressWarnings( "SameParameterValue" ) | ||
| + private static LocaleProperty asLocaleProperty( final Locale value ) { | ||
| + return new LocaleProperty( value ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * @param value Default value. | ||
| + */ | ||
| + @SuppressWarnings( "SameParameterValue" ) | ||
| + private static SkinProperty asSkinProperty( final String value ) { | ||
| + return new SkinProperty( value ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Creates a new {@link Workspace} that will attempt to load the users' | ||
| + * preferences. If the configuration file cannot be loaded, the workspace | ||
| + * settings returns default values. | ||
| + */ | ||
| + public Workspace() { | ||
| + load(); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Attempts to load the app's configuration file. | ||
| + */ | ||
| + private void load() { | ||
| + final var store = createXmlStore(); | ||
| + store.load( FILE_PREFERENCES ); | ||
| + | ||
| + mValues.keySet().forEach( key -> { | ||
| + try { | ||
| + final var storeValue = store.getValue( key ); | ||
| + final var property = valuesProperty( key ); | ||
| + final var unmarshalled = unmarshall( property, storeValue ); | ||
| + | ||
| + property.setValue( unmarshalled ); | ||
| + } catch( final NoSuchElementException ex ) { | ||
| + // When no configuration (item), use the default value. | ||
| + clue( ex ); | ||
| + } | ||
| + } ); | ||
| + | ||
| + mSets.keySet().forEach( key -> { | ||
| + final var set = store.getSet( key ); | ||
| + final SetProperty<String> property = setsProperty( key ); | ||
| + | ||
| + property.setValue( observableSet( set ) ); | ||
| + } ); | ||
| + | ||
| + mLists.keySet().forEach( key -> { | ||
| + final var map = store.getMap( key ); | ||
| + final ListProperty<Entry<String, String>> property = listsProperty( key ); | ||
| + final var list = map | ||
| + .entrySet() | ||
| + .stream() | ||
| + .toList(); | ||
| + | ||
| + property.setValue( observableArrayList( list ) ); | ||
| + } ); | ||
| + | ||
| + WorkspaceLoadedEvent.fire( this ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Saves the current workspace. | ||
| + */ | ||
| + public void save() { | ||
| + final var store = createXmlStore(); | ||
| + | ||
| + try { | ||
| + // Update the string values to include the application version. | ||
| + valuesProperty( KEY_META_VERSION ).setValue( getVersion() ); | ||
| + | ||
| + mValues.forEach( ( k, v ) -> store.setValue( k, marshall( v ) ) ); | ||
| + mSets.forEach( store::setSet ); | ||
| + mLists.forEach( store::setMap ); | ||
| + | ||
| + store.save( FILE_PREFERENCES ); | ||
| + } catch( final Exception ex ) { | ||
| + clue( ex ); | ||
| + } | ||
| + } | ||
| + | ||
| + /** | ||
| + * Returns a value that represents a setting in the application that the user | ||
| + * may configure, either directly or indirectly. | ||
| + * | ||
| + * @param key The reference to the users' preference stored in deference | ||
| + * of app reëntrance. | ||
| + * @return An observable property to be persisted. | ||
| + */ | ||
| + @SuppressWarnings( "unchecked" ) | ||
| + public <T, U extends Property<T>> U valuesProperty( final Key key ) { | ||
| + assert key != null; | ||
| + return (U) mValues.get( key ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Returns a set of values that represent a setting in the application that | ||
| + * the user may configure, either directly or indirectly. The property | ||
| + * returned is backed by a {@link Set}. | ||
| + * | ||
| + * @param key The {@link Key} associated with a preference value. | ||
| + * @return An observable property to be persisted. | ||
| + */ | ||
| + @SuppressWarnings( "unchecked" ) | ||
| + public <T> SetProperty<T> setsProperty( final Key key ) { | ||
| + assert key != null; | ||
| + return (SetProperty<T>) mSets.get( key ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Returns a list of values that represent a setting in the application that | ||
| + * the user may configure, either directly or indirectly. The property | ||
| + * returned is backed by a mutable {@link List}. | ||
| + * | ||
| + * @param key The {@link Key} associated with a preference value. | ||
| + * @return An observable property to be persisted. | ||
| + */ | ||
| + @SuppressWarnings( "unchecked" ) | ||
| + public <K, V> ListProperty<Entry<K, V>> listsProperty( final Key key ) { | ||
| + assert key != null; | ||
| + return (ListProperty<Entry<K, V>>) mLists.get( key ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Returns the {@link String} {@link Property} associated with the given | ||
| + * {@link Key} from the internal list of preference values. The caller | ||
| + * must be sure that the given {@link Key} is associated with a {@link File} | ||
| + * {@link Property}. | ||
| + * | ||
| + * @param key The {@link Key} associated with a preference value. | ||
| + * @return The value associated with the given {@link Key}. | ||
| + */ | ||
| + public StringProperty stringProperty( final Key key ) { | ||
| + assert key != null; | ||
| + return valuesProperty( key ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Returns the {@link Boolean} {@link Property} associated with the given | ||
| + * {@link Key} from the internal list of preference values. The caller | ||
| + * must be sure that the given {@link Key} is associated with a {@link File} | ||
| + * {@link Property}. | ||
| + * | ||
| + * @param key The {@link Key} associated with a preference value. | ||
| + * @return The value associated with the given {@link Key}. | ||
| + */ | ||
| + public BooleanProperty booleanProperty( final Key key ) { | ||
| + assert key != null; | ||
| + return valuesProperty( key ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Returns the {@link Integer} {@link Property} associated with the given | ||
| + * {@link Key} from the internal list of preference values. The caller | ||
| + * must be sure that the given {@link Key} is associated with a {@link File} | ||
| + * {@link Property}. | ||
| + * | ||
| + * @param key The {@link Key} associated with a preference value. | ||
| + * @return The value associated with the given {@link Key}. | ||
| + */ | ||
| + public IntegerProperty integerProperty( final Key key ) { | ||
| + assert key != null; | ||
| + return valuesProperty( key ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Returns the {@link Double} {@link Property} associated with the given | ||
| + * {@link Key} from the internal list of preference values. The caller | ||
| + * must be sure that the given {@link Key} is associated with a {@link File} | ||
| + * {@link Property}. | ||
| + * | ||
| + * @param key The {@link Key} associated with a preference value. | ||
| + * @return The value associated with the given {@link Key}. | ||
| + */ | ||
| + public DoubleProperty doubleProperty( final Key key ) { | ||
| + assert key != null; | ||
| + return valuesProperty( key ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Returns the {@link File} {@link Property} associated with the given | ||
| + * {@link Key} from the internal list of preference values. The caller | ||
| + * must be sure that the given {@link Key} is associated with a {@link File} | ||
| + * {@link Property}. | ||
| + * | ||
| + * @param key The {@link Key} associated with a preference value. | ||
| + * @return The value associated with the given {@link Key}. | ||
| + */ | ||
| + public ObjectProperty<File> fileProperty( final Key key ) { | ||
| + assert key != null; | ||
| + return valuesProperty( key ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Returns the {@link Locale} {@link Property} associated with the given | ||
| + * {@link Key} from the internal list of preference values. The caller | ||
| + * must be sure that the given {@link Key} is associated with a {@link File} | ||
| + * {@link Property}. | ||
| + * | ||
| + * @param key The {@link Key} associated with a preference value. | ||
| + * @return The value associated with the given {@link Key}. | ||
| + */ | ||
| + public LocaleProperty localeProperty( final Key key ) { | ||
| + assert key != null; | ||
| + return valuesProperty( key ); | ||
| + } | ||
| + | ||
| + public ObjectProperty<String> skinProperty( final Key key ) { | ||
| + assert key != null; | ||
| + return valuesProperty( key ); | ||
| + } | ||
| + | ||
| + public String getString( final Key key ) { | ||
| + assert key != null; | ||
| + return stringProperty( key ).get(); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Returns the {@link Boolean} preference value associated with the given | ||
| + * {@link Key}. The caller must be sure that the given {@link Key} is | ||
| + * associated with a value that matches the return type. | ||
| + * | ||
| + * @param key The {@link Key} associated with a preference value. | ||
| + * @return The value associated with the given {@link Key}. | ||
| + */ | ||
| + public boolean getBoolean( final Key key ) { | ||
| + assert key != null; | ||
| + return booleanProperty( key ).get(); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Returns the {@link Integer} preference value associated with the given | ||
| + * {@link Key}. The caller must be sure that the given {@link Key} is | ||
| + * associated with a value that matches the return type. | ||
| + * | ||
| + * @param key The {@link Key} associated with a preference value. | ||
| + * @return The value associated with the given {@link Key}. | ||
| + */ | ||
| + @SuppressWarnings( "unused" ) | ||
| + public int getInteger( final Key key ) { | ||
| + assert key != null; | ||
| + return integerProperty( key ).get(); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Returns the {@link Double} preference value associated with the given | ||
| + * {@link Key}. The caller must be sure that the given {@link Key} is | ||
| + * associated with a value that matches the return type. | ||
| + * | ||
| + * @param key The {@link Key} associated with a preference value. | ||
| + * @return The value associated with the given {@link Key}. | ||
| + */ | ||
| + public double getDouble( final Key key ) { | ||
| + assert key != null; | ||
| + return doubleProperty( key ).get(); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Returns the {@link File} preference value associated with the given | ||
| + * {@link Key}. The caller must be sure that the given {@link Key} is | ||
| + * associated with a value that matches the return type. | ||
| + * | ||
| + * @param key The {@link Key} associated with a preference value. | ||
| + * @return The value associated with the given {@link Key}. | ||
| + */ | ||
| + public File getFile( final Key key ) { | ||
| + assert key != null; | ||
| + return fileProperty( key ).get(); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Returns the language locale setting for the | ||
| + * {@link AppKeys#KEY_LANGUAGE_LOCALE} key. | ||
| + * | ||
| + * @return The user's current locale setting. | ||
| + */ | ||
| + public Locale getLocale() { | ||
| + return localeProperty( KEY_LANGUAGE_LOCALE ).toLocale(); | ||
| + } | ||
| + | ||
| + @SuppressWarnings( "unchecked" ) | ||
| + public <K, V> Map<K, V> getMetadata() { | ||
| + final var metadata = listsProperty( KEY_DOC_META ); | ||
| + final HashMap<K, V> map; | ||
| + | ||
| + if( metadata != null ) { | ||
| + map = new HashMap<>( metadata.size() ); | ||
| + | ||
| + metadata.forEach( | ||
| + entry -> map.put( (K) entry.getKey(), (V) entry.getValue() ) | ||
| + ); | ||
| + } | ||
| + else { | ||
| + map = new HashMap<>(); | ||
| + } | ||
| + | ||
| + return map; | ||
| + } | ||
| + | ||
| + public Path getThemesPath() { | ||
| + final var dir = getFile( KEY_TYPESET_CONTEXT_THEMES_PATH ); | ||
| + final var name = getString( KEY_TYPESET_CONTEXT_THEME_SELECTION ); | ||
| + | ||
| + return Path.of( dir.toString(), name ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Delegates to {@link #listen(Key, ReadOnlyProperty, BooleanSupplier)}, | ||
| + * providing a value of {@code true} for the {@link BooleanSupplier} to | ||
| + * indicate the property changes always take effect. | ||
| + * | ||
| + * @param key The value to bind to the internal key property. | ||
| + * @param property The external property value that sets the internal value. | ||
| + */ | ||
| + public <T> void listen( final Key key, final ReadOnlyProperty<T> property ) { | ||
| + assert key != null; | ||
| + assert property != null; | ||
| + | ||
| + listen( key, property, () -> true ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Binds a read-only property to a value in the preferences. This allows | ||
| + * user interface properties to change and the preferences will be | ||
| + * synchronized automatically. | ||
| + * <p> | ||
| + * This calls {@link Platform#runLater(Runnable)} to ensure that all pending | ||
| + * application window states are finished before assessing whether property | ||
| + * changes should be applied. Without this, exiting the application while the | ||
| + * window is maximized would persist the window's maximum dimensions, | ||
| + * preventing restoration to its prior, non-maximum size. | ||
| + * | ||
| + * @param key The value to bind to the internal key property. | ||
| + * @param property The external property value that sets the internal value. | ||
| + * @param enabled Indicates whether property changes should be applied. | ||
| + */ | ||
| + public <T> void listen( | ||
| + final Key key, | ||
| + final ReadOnlyProperty<T> property, | ||
| + final BooleanSupplier enabled ) { | ||
| + assert key != null; | ||
| + assert property != null; | ||
| + assert enabled != null; | ||
| + | ||
| + property.addListener( | ||
| + ( _, _, n ) -> runLater( () -> { | ||
| if( enabled.getAsBoolean() ) { | ||
| valuesProperty( key ).setValue( n ); |
| import static com.keenwrite.io.SysFile.normalize; | ||
| import static com.keenwrite.typesetting.Typesetter.Mutator; | ||
| +import static com.keenwrite.util.Strings.sanitize; | ||
| import static java.nio.charset.StandardCharsets.UTF_8; | ||
| import static java.nio.file.Files.deleteIfExists; | ||
| final var rWorkDir = normalize( context.getRWorkingDir() ); | ||
| clue( "Main.status.typeset.setting", "r-work", rWorkDir ); | ||
| + | ||
| + final var enableMode = sanitize( context.getEnableMode() ); | ||
| + clue( "Main.status.typeset.setting", "mode", enableMode ); | ||
| final var autoRemove = context.getAutoRemove(); | ||
| .with( Mutator::setCacheDir, cacheDir ) | ||
| .with( Mutator::setFontDir, fontDir ) | ||
| + .with( Mutator::setEnableMode, enableMode ) | ||
| .with( Mutator::setAutoRemove, autoRemove ) | ||
| .build(); | ||
| import static com.keenwrite.io.SysFile.toFile; | ||
| import static com.keenwrite.predicates.PredicateFactory.createFileTypePredicate; | ||
| - | ||
| -/** | ||
| - * Provides a context for configuring a chain of {@link Processor} instances. | ||
| - */ | ||
| -public final class ProcessorContext { | ||
| - | ||
| - private final Mutator mMutator; | ||
| - | ||
| - /** | ||
| - * Determines the file type from the path extension. This should only be | ||
| - * called when it is known that the file type won't be a definition file | ||
| - * (e.g., YAML or other definition source), but rather an editable file | ||
| - * (e.g., Markdown, R Markdown, etc.). | ||
| - * | ||
| - * @param path The path with a file name extension. | ||
| - * @return The FileType for the given path. | ||
| - */ | ||
| - private static FileType lookup( final Path path ) { | ||
| - assert path != null; | ||
| - | ||
| - final var prefix = GLOB_PREFIX_FILE; | ||
| - final var keys = sSettings.getKeys( prefix ); | ||
| - | ||
| - var found = false; | ||
| - var fileType = UNKNOWN; | ||
| - | ||
| - while( keys.hasNext() && !found ) { | ||
| - final var key = keys.next(); | ||
| - final var patterns = sSettings.getStringSettingList( key ); | ||
| - final var predicate = createFileTypePredicate( patterns ); | ||
| - | ||
| - if( predicate.test( toFile( path ) ) ) { | ||
| - // Remove the EXTENSIONS_PREFIX to get the file name extension mapped | ||
| - // to a standard name (as defined in the settings.properties file). | ||
| - final String suffix = key.replace( prefix + '.', "" ); | ||
| - fileType = FileType.from( suffix ); | ||
| - found = true; | ||
| - } | ||
| - } | ||
| - | ||
| - return fileType; | ||
| - } | ||
| - | ||
| - public boolean isExportFormat( final ExportFormat exportFormat ) { | ||
| - return mMutator.mExportFormat == exportFormat; | ||
| - } | ||
| - | ||
| - /** | ||
| - * Responsible for populating the instance variables required by the | ||
| - * context. | ||
| - */ | ||
| - public static class Mutator { | ||
| - private Path mSourcePath; | ||
| - private Path mTargetPath; | ||
| - private ExportFormat mExportFormat; | ||
| - private Supplier<Boolean> mConcatenate = () -> true; | ||
| - private Supplier<String> mChapters = () -> ""; | ||
| - | ||
| - private Supplier<Path> mThemeDir = USER_DIRECTORY::toPath; | ||
| - private Supplier<Locale> mLocale = () -> Locale.ENGLISH; | ||
| - | ||
| - private Supplier<Map<String, String>> mDefinitions = HashMap::new; | ||
| - private Supplier<Map<String, String>> mMetadata = HashMap::new; | ||
| - private Supplier<Caret> mCaret = () -> Caret.builder().build(); | ||
| - | ||
| - private Supplier<Path> mFontDir = () -> getFontDirectory().toPath(); | ||
| - | ||
| - private Supplier<Path> mImageDir = USER_DIRECTORY::toPath; | ||
| - private Supplier<String> mImageServer = () -> DIAGRAM_SERVER_NAME; | ||
| - private Supplier<String> mImageOrder = () -> PERSIST_IMAGES_DEFAULT; | ||
| - | ||
| - private Supplier<Path> mCacheDir = USER_CACHE_DIR::toPath; | ||
| - | ||
| - private Supplier<String> mSigilBegan = () -> DEF_DELIM_BEGAN_DEFAULT; | ||
| - private Supplier<String> mSigilEnded = () -> DEF_DELIM_ENDED_DEFAULT; | ||
| - | ||
| - private Supplier<Path> mRWorkingDir = USER_DIRECTORY::toPath; | ||
| - private Supplier<String> mRScript = () -> ""; | ||
| - | ||
| - private Supplier<Boolean> mCurlQuotes = () -> true; | ||
| - private Supplier<Boolean> mAutoRemove = () -> true; | ||
| - | ||
| - public void setSourcePath( final Path sourcePath ) { | ||
| - assert sourcePath != null; | ||
| - mSourcePath = sourcePath; | ||
| - } | ||
| - | ||
| - public void setTargetPath( final Path outputPath ) { | ||
| - assert outputPath != null; | ||
| - mTargetPath = outputPath; | ||
| - } | ||
| - | ||
| - public void setThemeDir( final Supplier<Path> themeDir ) { | ||
| - assert themeDir != null; | ||
| - mThemeDir = themeDir; | ||
| - } | ||
| - | ||
| - public void setCacheDir( final Supplier<File> cacheDir ) { | ||
| - assert cacheDir != null; | ||
| - | ||
| - mCacheDir = () -> { | ||
| - final var dir = cacheDir.get(); | ||
| - | ||
| - return (dir == null ? toFile( USER_DATA_DIR ) : dir).toPath(); | ||
| - }; | ||
| - } | ||
| - | ||
| - public void setImageDir( final Supplier<File> imageDir ) { | ||
| - assert imageDir != null; | ||
| - | ||
| - mImageDir = () -> { | ||
| - final var dir = imageDir.get(); | ||
| - | ||
| - return (dir == null ? USER_DIRECTORY : dir).toPath(); | ||
| - }; | ||
| - } | ||
| - | ||
| - public void setImageOrder( final Supplier<String> imageOrder ) { | ||
| - assert imageOrder != null; | ||
| - mImageOrder = imageOrder; | ||
| - } | ||
| - | ||
| - public void setImageServer( final Supplier<String> imageServer ) { | ||
| - assert imageServer != null; | ||
| - mImageServer = imageServer; | ||
| - } | ||
| - | ||
| - public void setFontDir( final Supplier<File> fontDir ) { | ||
| - assert fontDir != null; | ||
| - | ||
| - mFontDir = () -> { | ||
| - final var dir = fontDir.get(); | ||
| - | ||
| - return (dir == null ? USER_DIRECTORY : dir).toPath(); | ||
| - }; | ||
| - } | ||
| - | ||
| - public void setExportFormat( final ExportFormat exportFormat ) { | ||
| - assert exportFormat != null; | ||
| - mExportFormat = exportFormat; | ||
| - } | ||
| - | ||
| - public void setConcatenate( final Supplier<Boolean> concatenate ) { | ||
| - mConcatenate = concatenate; | ||
| - } | ||
| - | ||
| - public void setChapters( final Supplier<String> chapters ) { | ||
| - mChapters = chapters; | ||
| - } | ||
| - | ||
| - public void setLocale( final Supplier<Locale> locale ) { | ||
| - assert locale != null; | ||
| - mLocale = locale; | ||
| - } | ||
| - | ||
| - /** | ||
| - * Sets the list of fully interpolated key-value pairs to use when | ||
| - * substituting variable names back into the document as variable values. | ||
| - * This uses a {@link Callable} reference so that GUI and command-line | ||
| - * usage can insert their respective behaviours. That is, this method | ||
| - * prevents coupling the GUI to the CLI. | ||
| - * | ||
| - * @param supplier Defines how to retrieve the definitions. | ||
| - */ | ||
| - public void setDefinitions( final Supplier<Map<String, String>> supplier ) { | ||
| - assert supplier != null; | ||
| - mDefinitions = supplier; | ||
| - } | ||
| - | ||
| - /** | ||
| - * Sets metadata to use in the document header. These are made available | ||
| - * to the typesetting engine as {@code \documentvariable} values. | ||
| - * | ||
| - * @param metadata The key/value pairs to publish as document metadata. | ||
| - */ | ||
| - public void setMetadata( final Supplier<Map<String, String>> metadata ) { | ||
| - assert metadata != null; | ||
| - mMetadata = metadata.get() == null ? HashMap::new : metadata; | ||
| - } | ||
| - | ||
| - /** | ||
| - * Sets document variables to use when building the document. These | ||
| - * variables will override existing key/value pairs, or be added as | ||
| - * new key/value pairs if not already defined. This allows users to | ||
| - * inject variables into the document from the command-line, allowing | ||
| - * for dynamic assignment of in-text values when building documents. | ||
| - * | ||
| - * @param overrides The key/value pairs to add (or override) as variables. | ||
| - */ | ||
| - public void setOverrides( final Supplier<Map<String, String>> overrides ) { | ||
| - assert overrides != null; | ||
| - assert mDefinitions != null; | ||
| - assert mDefinitions.get() != null; | ||
| - | ||
| - final var map = overrides.get(); | ||
| - | ||
| - if( map != null ) { | ||
| - mDefinitions.get().putAll( map ); | ||
| - } | ||
| - } | ||
| - | ||
| - /** | ||
| - * Sets the source for deriving the {@link Caret}. Typically, this is | ||
| - * the text editor that has focus. | ||
| - * | ||
| - * @param caret The source for the currently active caret. | ||
| - */ | ||
| - public void setCaret( final Supplier<Caret> caret ) { | ||
| - assert caret != null; | ||
| - mCaret = caret; | ||
| - } | ||
| - | ||
| - public void setSigilBegan( final Supplier<String> sigilBegan ) { | ||
| - assert sigilBegan != null; | ||
| - mSigilBegan = sigilBegan; | ||
| - } | ||
| - | ||
| - public void setSigilEnded( final Supplier<String> sigilEnded ) { | ||
| - assert sigilEnded != null; | ||
| - mSigilEnded = sigilEnded; | ||
| - } | ||
| - | ||
| - public void setRWorkingDir( final Supplier<Path> rWorkingDir ) { | ||
| - assert rWorkingDir != null; | ||
| - | ||
| - mRWorkingDir = rWorkingDir; | ||
| - } | ||
| - | ||
| - public void setRScript( final Supplier<String> rScript ) { | ||
| - assert rScript != null; | ||
| - mRScript = rScript; | ||
| - } | ||
| - | ||
| - public void setCurlQuotes( final Supplier<Boolean> curlQuotes ) { | ||
| - assert curlQuotes != null; | ||
| - mCurlQuotes = curlQuotes; | ||
| - } | ||
| - | ||
| - public void setAutoRemove( final Supplier<Boolean> autoRemove ) { | ||
| - assert autoRemove != null; | ||
| - mAutoRemove = autoRemove; | ||
| - } | ||
| - | ||
| - private boolean isExportFormat( final ExportFormat format ) { | ||
| - return mExportFormat == format; | ||
| - } | ||
| - } | ||
| - | ||
| - public static GenericBuilder<Mutator, ProcessorContext> builder() { | ||
| - return GenericBuilder.of( Mutator::new, ProcessorContext::new ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Creates a new context for use by the {@link ProcessorFactory} when | ||
| - * instantiating new {@link Processor} instances. Although all the | ||
| - * parameters are required, not all {@link Processor} instances will use | ||
| - * all parameters. | ||
| - */ | ||
| - private ProcessorContext( final Mutator mutator ) { | ||
| - assert mutator != null; | ||
| - | ||
| - mMutator = mutator; | ||
| - } | ||
| - | ||
| - public Path getSourcePath() { | ||
| - return mMutator.mSourcePath; | ||
| - } | ||
| - | ||
| - /** | ||
| - * Answers what type of input document is to be processed. | ||
| - * | ||
| - * @return The input document's {@link MediaType}. | ||
| - */ | ||
| - public MediaType getSourceType() { | ||
| - return MediaTypeExtension.fromPath( mMutator.mSourcePath ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Fully qualified file name to use when exporting (e.g., document.pdf). | ||
| - * | ||
| - * @return Full path to a file name. | ||
| - */ | ||
| - public Path getTargetPath() { | ||
| - return mMutator.mTargetPath; | ||
| - } | ||
| - | ||
| - public ExportFormat getExportFormat() { | ||
| - return mMutator.mExportFormat; | ||
| - } | ||
| - | ||
| - public Locale getLocale() { | ||
| - return mMutator.mLocale.get(); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Returns the variable map of definitions, without interpolation. | ||
| - * | ||
| - * @return A map to help dereference variables. | ||
| - */ | ||
| - public Map<String, String> getDefinitions() { | ||
| - return mMutator.mDefinitions.get(); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Returns the variable map of definitions, with interpolation. | ||
| - * | ||
| - * @return A map to help dereference variables. | ||
| - */ | ||
| - public InterpolatingMap getInterpolatedDefinitions() { | ||
| - return new InterpolatingMap( | ||
| - createDefinitionKeyOperator(), getDefinitions() | ||
| - ).interpolate(); | ||
| - } | ||
| - | ||
| - public Map<String, String> getMetadata() { | ||
| - return mMutator.mMetadata.get(); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Returns the current caret position in the document being edited and is | ||
| - * always up-to-date. | ||
| - * | ||
| - * @return Caret position in the document. | ||
| - */ | ||
| - public Supplier<Caret> getCaret() { | ||
| - return mMutator.mCaret; | ||
| - } | ||
| - | ||
| - /** | ||
| - * Returns the directory that contains the file being edited. When | ||
| - * {@link Constants#DOCUMENT_DEFAULT} is created, the parent path is | ||
| - * {@code null}. This will get absolute path to the file before trying to | ||
| - * get te parent path, which should always be a valid path. In the unlikely | ||
| - * event that the base path cannot be determined by the path alone, the | ||
| - * default user directory is returned. This is necessary for the creation | ||
| - * of new files. | ||
| - * | ||
| - * @return Path to the directory containing a file being edited, or the | ||
| - * default user directory if the base path cannot be determined. | ||
| - */ | ||
| - public Path getBaseDir() { | ||
| - final var path = getSourcePath().toAbsolutePath().getParent(); | ||
| - return path == null ? DEFAULT_DIRECTORY : path; | ||
| - } | ||
| - | ||
| - FileType getSourceFileType() { | ||
| - return lookup( getSourcePath() ); | ||
| - } | ||
| - | ||
| - public Path getThemeDir() { | ||
| - return mMutator.mThemeDir.get(); | ||
| - } | ||
| - | ||
| - public Path getImageDir() { | ||
| - return mMutator.mImageDir.get(); | ||
| - } | ||
| - | ||
| - public Path getCacheDir() { | ||
| - return mMutator.mCacheDir.get(); | ||
| - } | ||
| - | ||
| - public Iterable<String> getImageOrder() { | ||
| - assert mMutator.mImageOrder != null; | ||
| - | ||
| - final var order = mMutator.mImageOrder.get(); | ||
| - final var token = order.contains( "," ) ? ',' : ' '; | ||
| - | ||
| - return Splitter.on( token ).split( token + order ); | ||
| - } | ||
| - | ||
| - public String getImageServer() { | ||
| - return mMutator.mImageServer.get(); | ||
| - } | ||
| - | ||
| - public Path getFontDir() { | ||
| - return mMutator.mFontDir.get(); | ||
| +import static com.keenwrite.processors.IdentityProcessor.IDENTITY; | ||
| +import static com.keenwrite.processors.text.TextReplacementFactory.replace; | ||
| + | ||
| +/** | ||
| + * Provides a context for configuring a chain of {@link Processor} instances. | ||
| + */ | ||
| +public final class ProcessorContext { | ||
| + | ||
| + private final Mutator mMutator; | ||
| + | ||
| + /** | ||
| + * Determines the file type from the path extension. This should only be | ||
| + * called when it is known that the file type won't be a definition file | ||
| + * (e.g., YAML or other definition source), but rather an editable file | ||
| + * (e.g., Markdown, R Markdown, etc.). | ||
| + * | ||
| + * @param path The path with a file name extension. | ||
| + * @return The FileType for the given path. | ||
| + */ | ||
| + private static FileType lookup( final Path path ) { | ||
| + assert path != null; | ||
| + | ||
| + final var prefix = GLOB_PREFIX_FILE; | ||
| + final var keys = sSettings.getKeys( prefix ); | ||
| + | ||
| + var found = false; | ||
| + var fileType = UNKNOWN; | ||
| + | ||
| + while( keys.hasNext() && !found ) { | ||
| + final var key = keys.next(); | ||
| + final var patterns = sSettings.getStringSettingList( key ); | ||
| + final var predicate = createFileTypePredicate( patterns ); | ||
| + | ||
| + if( predicate.test( toFile( path ) ) ) { | ||
| + // Remove the EXTENSIONS_PREFIX to get the file name extension mapped | ||
| + // to a standard name (as defined in the settings.properties file). | ||
| + final String suffix = key.replace( prefix + '.', "" ); | ||
| + fileType = FileType.from( suffix ); | ||
| + found = true; | ||
| + } | ||
| + } | ||
| + | ||
| + return fileType; | ||
| + } | ||
| + | ||
| + public boolean isExportFormat( final ExportFormat exportFormat ) { | ||
| + return mMutator.mExportFormat == exportFormat; | ||
| + } | ||
| + | ||
| + /** | ||
| + * Responsible for populating the instance variables required by the | ||
| + * context. | ||
| + */ | ||
| + public static class Mutator { | ||
| + private Path mSourcePath; | ||
| + private Path mTargetPath; | ||
| + private ExportFormat mExportFormat; | ||
| + private Supplier<Boolean> mConcatenate = () -> true; | ||
| + private Supplier<String> mChapters = () -> ""; | ||
| + | ||
| + private Supplier<Path> mThemeDir = USER_DIRECTORY::toPath; | ||
| + private Supplier<Locale> mLocale = () -> Locale.ENGLISH; | ||
| + | ||
| + private Supplier<Map<String, String>> mDefinitions = HashMap::new; | ||
| + private Supplier<Map<String, String>> mMetadata = HashMap::new; | ||
| + private Supplier<Caret> mCaret = () -> Caret.builder().build(); | ||
| + | ||
| + private Supplier<Path> mImageDir = USER_DIRECTORY::toPath; | ||
| + private Supplier<String> mImageServer = () -> DIAGRAM_SERVER_NAME; | ||
| + private Supplier<String> mImageOrder = () -> PERSIST_IMAGES_DEFAULT; | ||
| + private Supplier<Path> mCacheDir = USER_CACHE_DIR::toPath; | ||
| + private Supplier<Path> mFontDir = () -> getFontDirectory().toPath(); | ||
| + | ||
| + private Supplier<String> mEnableMode = () -> ""; | ||
| + | ||
| + private Supplier<String> mSigilBegan = () -> DEF_DELIM_BEGAN_DEFAULT; | ||
| + private Supplier<String> mSigilEnded = () -> DEF_DELIM_ENDED_DEFAULT; | ||
| + | ||
| + private Supplier<Path> mRWorkingDir = USER_DIRECTORY::toPath; | ||
| + private Supplier<String> mRScript = () -> ""; | ||
| + | ||
| + private Supplier<Boolean> mCurlQuotes = () -> true; | ||
| + private Supplier<Boolean> mAutoRemove = () -> true; | ||
| + | ||
| + public void setSourcePath( final Path sourcePath ) { | ||
| + assert sourcePath != null; | ||
| + mSourcePath = sourcePath; | ||
| + } | ||
| + | ||
| + public void setTargetPath( final Path outputPath ) { | ||
| + assert outputPath != null; | ||
| + mTargetPath = outputPath; | ||
| + } | ||
| + | ||
| + public void setThemeDir( final Supplier<Path> themeDir ) { | ||
| + assert themeDir != null; | ||
| + mThemeDir = themeDir; | ||
| + } | ||
| + | ||
| + public void setCacheDir( final Supplier<File> cacheDir ) { | ||
| + assert cacheDir != null; | ||
| + | ||
| + mCacheDir = () -> { | ||
| + final var dir = cacheDir.get(); | ||
| + | ||
| + return (dir == null ? toFile( USER_DATA_DIR ) : dir).toPath(); | ||
| + }; | ||
| + } | ||
| + | ||
| + public void setImageDir( final Supplier<File> imageDir ) { | ||
| + assert imageDir != null; | ||
| + | ||
| + mImageDir = () -> { | ||
| + final var dir = imageDir.get(); | ||
| + | ||
| + return (dir == null ? USER_DIRECTORY : dir).toPath(); | ||
| + }; | ||
| + } | ||
| + | ||
| + public void setImageOrder( final Supplier<String> imageOrder ) { | ||
| + assert imageOrder != null; | ||
| + mImageOrder = imageOrder; | ||
| + } | ||
| + | ||
| + public void setImageServer( final Supplier<String> imageServer ) { | ||
| + assert imageServer != null; | ||
| + mImageServer = imageServer; | ||
| + } | ||
| + | ||
| + public void setFontDir( final Supplier<File> fontDir ) { | ||
| + assert fontDir != null; | ||
| + | ||
| + mFontDir = () -> { | ||
| + final var dir = fontDir.get(); | ||
| + | ||
| + return (dir == null ? USER_DIRECTORY : dir).toPath(); | ||
| + }; | ||
| + } | ||
| + | ||
| + public void setEnableMode( final Supplier<String> enableMode ) { | ||
| + assert enableMode != null; | ||
| + mEnableMode = enableMode; | ||
| + } | ||
| + | ||
| + public void setExportFormat( final ExportFormat exportFormat ) { | ||
| + assert exportFormat != null; | ||
| + mExportFormat = exportFormat; | ||
| + } | ||
| + | ||
| + public void setConcatenate( final Supplier<Boolean> concatenate ) { | ||
| + mConcatenate = concatenate; | ||
| + } | ||
| + | ||
| + public void setChapters( final Supplier<String> chapters ) { | ||
| + mChapters = chapters; | ||
| + } | ||
| + | ||
| + public void setLocale( final Supplier<Locale> locale ) { | ||
| + assert locale != null; | ||
| + mLocale = locale; | ||
| + } | ||
| + | ||
| + /** | ||
| + * Sets the list of fully interpolated key-value pairs to use when | ||
| + * substituting variable names back into the document as variable values. | ||
| + * This uses a {@link Callable} reference so that GUI and command-line | ||
| + * usage can insert their respective behaviours. That is, this method | ||
| + * prevents coupling the GUI to the CLI. | ||
| + * | ||
| + * @param supplier Defines how to retrieve the definitions. | ||
| + */ | ||
| + public void setDefinitions( final Supplier<Map<String, String>> supplier ) { | ||
| + assert supplier != null; | ||
| + mDefinitions = supplier; | ||
| + } | ||
| + | ||
| + /** | ||
| + * Sets metadata to use in the document header. These are made available | ||
| + * to the typesetting engine as {@code \documentvariable} values. | ||
| + * | ||
| + * @param metadata The key/value pairs to publish as document metadata. | ||
| + */ | ||
| + public void setMetadata( final Supplier<Map<String, String>> metadata ) { | ||
| + assert metadata != null; | ||
| + mMetadata = metadata.get() == null ? HashMap::new : metadata; | ||
| + } | ||
| + | ||
| + /** | ||
| + * Sets document variables to use when building the document. These | ||
| + * variables will override existing key/value pairs, or be added as | ||
| + * new key/value pairs if not already defined. This allows users to | ||
| + * inject variables into the document from the command-line, allowing | ||
| + * for dynamic assignment of in-text values when building documents. | ||
| + * | ||
| + * @param overrides The key/value pairs to add (or override) as variables. | ||
| + */ | ||
| + public void setOverrides( final Supplier<Map<String, String>> overrides ) { | ||
| + assert overrides != null; | ||
| + assert mDefinitions != null; | ||
| + assert mDefinitions.get() != null; | ||
| + | ||
| + final var map = overrides.get(); | ||
| + | ||
| + if( map != null ) { | ||
| + mDefinitions.get().putAll( map ); | ||
| + } | ||
| + } | ||
| + | ||
| + /** | ||
| + * Sets the source for deriving the {@link Caret}. Typically, this is | ||
| + * the text editor that has focus. | ||
| + * | ||
| + * @param caret The source for the currently active caret. | ||
| + */ | ||
| + public void setCaret( final Supplier<Caret> caret ) { | ||
| + assert caret != null; | ||
| + mCaret = caret; | ||
| + } | ||
| + | ||
| + public void setSigilBegan( final Supplier<String> sigilBegan ) { | ||
| + assert sigilBegan != null; | ||
| + mSigilBegan = sigilBegan; | ||
| + } | ||
| + | ||
| + public void setSigilEnded( final Supplier<String> sigilEnded ) { | ||
| + assert sigilEnded != null; | ||
| + mSigilEnded = sigilEnded; | ||
| + } | ||
| + | ||
| + public void setRWorkingDir( final Supplier<Path> rWorkingDir ) { | ||
| + assert rWorkingDir != null; | ||
| + mRWorkingDir = rWorkingDir; | ||
| + } | ||
| + | ||
| + public void setRScript( final Supplier<String> rScript ) { | ||
| + assert rScript != null; | ||
| + mRScript = rScript; | ||
| + } | ||
| + | ||
| + public void setCurlQuotes( final Supplier<Boolean> curlQuotes ) { | ||
| + assert curlQuotes != null; | ||
| + mCurlQuotes = curlQuotes; | ||
| + } | ||
| + | ||
| + public void setAutoRemove( final Supplier<Boolean> autoRemove ) { | ||
| + assert autoRemove != null; | ||
| + mAutoRemove = autoRemove; | ||
| + } | ||
| + | ||
| + private boolean isExportFormat( final ExportFormat format ) { | ||
| + return mExportFormat == format; | ||
| + } | ||
| + } | ||
| + | ||
| + public static GenericBuilder<Mutator, ProcessorContext> builder() { | ||
| + return GenericBuilder.of( Mutator::new, ProcessorContext::new ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Creates a new context for use by the {@link ProcessorFactory} when | ||
| + * instantiating new {@link Processor} instances. Although all the | ||
| + * parameters are required, not all {@link Processor} instances will use | ||
| + * all parameters. | ||
| + */ | ||
| + private ProcessorContext( final Mutator mutator ) { | ||
| + assert mutator != null; | ||
| + | ||
| + mMutator = mutator; | ||
| + } | ||
| + | ||
| + public Path getSourcePath() { | ||
| + return mMutator.mSourcePath; | ||
| + } | ||
| + | ||
| + /** | ||
| + * Answers what type of input document is to be processed. | ||
| + * | ||
| + * @return The input document's {@link MediaType}. | ||
| + */ | ||
| + public MediaType getSourceType() { | ||
| + return MediaTypeExtension.fromPath( mMutator.mSourcePath ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Fully qualified file name to use when exporting (e.g., document.pdf). | ||
| + * | ||
| + * @return Full path to a file name. | ||
| + */ | ||
| + public Path getTargetPath() { | ||
| + return mMutator.mTargetPath; | ||
| + } | ||
| + | ||
| + public ExportFormat getExportFormat() { | ||
| + return mMutator.mExportFormat; | ||
| + } | ||
| + | ||
| + public Locale getLocale() { | ||
| + return mMutator.mLocale.get(); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Returns the variable map of definitions, without interpolation. | ||
| + * | ||
| + * @return A map to help dereference variables. | ||
| + */ | ||
| + public Map<String, String> getDefinitions() { | ||
| + return mMutator.mDefinitions.get(); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Returns the variable map of definitions, with interpolation. | ||
| + * | ||
| + * @return A map to help dereference variables. | ||
| + */ | ||
| + public InterpolatingMap getInterpolatedDefinitions() { | ||
| + return new InterpolatingMap( | ||
| + createDefinitionKeyOperator(), getDefinitions() | ||
| + ).interpolate(); | ||
| + } | ||
| + | ||
| + public Map<String, String> getMetadata() { | ||
| + return mMutator.mMetadata.get(); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Returns the current caret position in the document being edited and is | ||
| + * always up-to-date. | ||
| + * | ||
| + * @return Caret position in the document. | ||
| + */ | ||
| + public Supplier<Caret> getCaret() { | ||
| + return mMutator.mCaret; | ||
| + } | ||
| + | ||
| + /** | ||
| + * Returns the directory that contains the file being edited. When | ||
| + * {@link Constants#DOCUMENT_DEFAULT} is created, the parent path is | ||
| + * {@code null}. This will get absolute path to the file before trying to | ||
| + * get te parent path, which should always be a valid path. In the unlikely | ||
| + * event that the base path cannot be determined by the path alone, the | ||
| + * default user directory is returned. This is necessary for the creation | ||
| + * of new files. | ||
| + * | ||
| + * @return Path to the directory containing a file being edited, or the | ||
| + * default user directory if the base path cannot be determined. | ||
| + */ | ||
| + public Path getBaseDir() { | ||
| + final var path = getSourcePath().toAbsolutePath().getParent(); | ||
| + return path == null ? DEFAULT_DIRECTORY : path; | ||
| + } | ||
| + | ||
| + FileType getSourceFileType() { | ||
| + return lookup( getSourcePath() ); | ||
| + } | ||
| + | ||
| + public Path getThemeDir() { | ||
| + return mMutator.mThemeDir.get(); | ||
| + } | ||
| + | ||
| + public Path getImageDir() { | ||
| + return mMutator.mImageDir.get(); | ||
| + } | ||
| + | ||
| + public Path getCacheDir() { | ||
| + return mMutator.mCacheDir.get(); | ||
| + } | ||
| + | ||
| + public Iterable<String> getImageOrder() { | ||
| + assert mMutator.mImageOrder != null; | ||
| + | ||
| + final var order = mMutator.mImageOrder.get(); | ||
| + final var token = order.contains( "," ) ? ',' : ' '; | ||
| + | ||
| + return Splitter.on( token ).split( token + order ); | ||
| + } | ||
| + | ||
| + public String getImageServer() { | ||
| + return mMutator.mImageServer.get(); | ||
| + } | ||
| + | ||
| + public Path getFontDir() { | ||
| + return mMutator.mFontDir.get(); | ||
| + } | ||
| + | ||
| + public String getEnableMode() { | ||
| + final var processor = new VariableProcessor( IDENTITY, this ); | ||
| + final var haystack = mMutator.mEnableMode.get(); | ||
| + final var needles = processor.getDefinitions(); | ||
| + | ||
| + return replace( haystack, needles ); | ||
| } | ||
| private static final String TYPESETTER_VERSION = | ||
| - TYPESETTER_EXE + " --version > /dev/null"; | ||
| + STR."\{TYPESETTER_EXE} --version > /dev/null"; | ||
| public GuestTypesetter( final Mutator mutator ) { | ||
| manager.run( | ||
| input -> gobble( input, s -> exitCode.append( s.trim() ) ), | ||
| - TYPESETTER_VERSION + "; echo $?" | ||
| + STR."\{TYPESETTER_VERSION}; echo $?" | ||
| ); | ||
| * ({@link GuestTypesetter}). | ||
| */ | ||
| +@SuppressWarnings( "SpellCheckingInspection" ) | ||
| public class Typesetter { | ||
| /** | ||
| private Path mCacheDir = USER_CACHE_DIR.toPath(); | ||
| private Path mFontDir = getFontDirectory().toPath(); | ||
| + private String mEnableMode = ""; | ||
| private boolean mAutoRemove; | ||
| public void setFontDir( final Path fontDir ) { | ||
| mFontDir = fontDir; | ||
| + } | ||
| + | ||
| + public void setEnableMode( final String enableMode ) { | ||
| + mEnableMode = enableMode; | ||
| } | ||
| public Path getFontDir() { | ||
| return mFontDir; | ||
| + } | ||
| + | ||
| + public String getEnableMode() { | ||
| + return mEnableMode; | ||
| } | ||
| final var outputPath = getTargetPath(); | ||
| - final var prefix = "Main.status.typeset"; | ||
| + final var prefix = "Main.status.typeset."; | ||
| - clue( prefix + ".began", outputPath ); | ||
| + clue( STR."\{prefix}began", outputPath ); | ||
| final var time = currentTimeMillis(); | ||
| final var success = typesetter.call(); | ||
| - final var suffix = success ? ".success" : ".failure"; | ||
| + final var suffix = success ? "success" : "failure"; | ||
| - clue( prefix + ".ended" + suffix, outputPath, since( time ) ); | ||
| + clue( STR."\{prefix}ended.\{suffix}", outputPath, since( time ) ); | ||
| } | ||
| /** | ||
| - * Generates the command-line arguments used to invoke the typesetter. | ||
| + * Generates command-line arguments used to invoke the typesetter. | ||
| */ | ||
| @SuppressWarnings( "SpellCheckingInspection" ) | ||
| args.add( format( "--result='%s'", targetPath ) ); | ||
| args.add( sourcePath ); | ||
| + | ||
| + final var enableMode = getEnableMode(); | ||
| + args.add( format( "--mode=%s", enableMode ) ); | ||
| return args; | ||
| } | ||
| - @SuppressWarnings( "SpellCheckingInspection" ) | ||
| List<String> commonOptions() { | ||
| final var args = new LinkedList<String>(); | ||
| protected Path getFontDir() { | ||
| return mMutator.getFontDir(); | ||
| + } | ||
| + | ||
| + protected String getEnableMode() { | ||
| + return mMutator.getEnableMode(); | ||
| } | ||
| workspace.typeset.typography.quotes.desc=Export straight quotes and apostrophes as curled equivalents. | ||
| workspace.typeset.typography.quotes.title=Curl | ||
| +workspace.typeset.modes=Modes | ||
| +workspace.typeset.modes.enabled=Enabled | ||
| +workspace.typeset.modes.enabled.desc=Enable typesetting modes, separated by commas; values may use variables (e.g., '{{'document.category'}}'). | ||
| +workspace.typeset.modes.enabled.title=Enable | ||
| workspace.r=R |
| Author | DaveJarvis <email> |
|---|---|
| Date | 2023-12-26 09:38:56 GMT-0800 |
| Commit | 7522553c4efe776b7d2478e59ababe7d64ee5236 |
| Parent | 232cdb3 |
| Delta | 2535 lines added, 2472 lines removed, 63-line increase |