Dave Jarvis' Repositories

git clone https://repo.autonoma.ca/repo/keenwrite.git
src/main/java/com/keenwrite/MainPane.java
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();
}
src/main/java/com/keenwrite/cmdline/Arguments.java
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() ) {
src/main/java/com/keenwrite/editors/definition/DefinitionEditor.java
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();
src/main/java/com/keenwrite/preferences/AppKeys.java
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
src/main/java/com/keenwrite/preferences/PreferencesController.java
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" );
}
src/main/java/com/keenwrite/preferences/Workspace.java
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 );
src/main/java/com/keenwrite/processors/PdfProcessor.java
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();
src/main/java/com/keenwrite/processors/ProcessorContext.java
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 );
}
src/main/java/com/keenwrite/typesetting/GuestTypesetter.java
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 $?"
);
src/main/java/com/keenwrite/typesetting/Typesetter.java
* ({@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();
}
src/main/resources/com/keenwrite/messages.properties
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

Adds ability to pass mode into typesetter

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