Dave Jarvis' Repositories

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

Pass along sigils using workspace properties

AuthorDaveJarvis <email>
Date2020-12-22 23:49:31 GMT-0800
Commitda22a912a43113ffa7c6be4dd4ea27e647fb81c6
Parent13c47e3
.travis.yml
-language: java
-
-jdk:
-- oraclejdk8
-
-# enable Java 8u45+, see https://github.com/travis-ci/travis-ci/issues/4042
-addons:
- apt:
- packages:
- - oracle-java8-installer
-os:
- - linux
-
-# run in container
-sudo: false
src/main/java/com/keenwrite/Constants.java
import com.keenwrite.io.File;
-import com.keenwrite.io.MediaType;
import com.keenwrite.service.Settings;
-import com.keenwrite.sigils.RSigilOperator;
-import com.keenwrite.sigils.SigilOperator;
-import com.keenwrite.sigils.YamlSigilOperator;
import javafx.scene.image.Image;
import java.nio.charset.Charset;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
-import java.util.Map;
-import java.util.function.UnaryOperator;
import static com.keenwrite.Bootstrap.APP_TITLE_LOWERCASE;
-import static com.keenwrite.io.MediaType.APP_R_MARKDOWN;
-import static com.keenwrite.io.MediaType.APP_R_XML;
import static java.io.File.separator;
import static java.lang.String.format;
*/
public static final Path DEFAULT_DIRECTORY = USER_DIRECTORY.toPath();
-
- /**
- * Associates file types with {@link SigilOperator} instances.
- */
- private static final Map<MediaType, SigilOperator> SIGIL_MAP = Map.of(
- APP_R_MARKDOWN, new RSigilOperator(),
- APP_R_XML, new RSigilOperator()
- );
/**
*/
private Constants() {
- }
-
- public static UnaryOperator<String> getSigilOperator(
- final MediaType mediaType ) {
- return SIGIL_MAP.getOrDefault( mediaType, new YamlSigilOperator() );
}
src/main/java/com/keenwrite/DefinitionNameInjector.java
import com.keenwrite.editors.TextEditor;
import com.keenwrite.editors.definition.DefinitionTreeItem;
+import com.keenwrite.sigils.SigilOperator;
import java.util.function.UnaryOperator;
*/
public static void autoinsert(
- final TextEditor editor,
- final TextDefinition definitions,
- final UnaryOperator<String> decorator ) {
+ final TextEditor editor,
+ final TextDefinition definitions,
+ final SigilOperator operator ) {
try {
if( definitions.isEmpty() ) {
}
else {
- editor.replaceText( indexes, decorator.apply( leaf.toPath() ) );
+ editor.replaceText( indexes, operator.entoken( leaf.toPath() ) );
definitions.expand( leaf );
}
* condition with diacritics replaced, then by containment.
*
- * @param word Match the word by: exact, beginning, containment, or other.
+ * @param word Match the word by: exact, beginning, containment, or other.
*/
@SuppressWarnings("ConstantConditions")
private static DefinitionTreeItem<String> findLeaf(
- final TextDefinition definition, final String word ) {
+ final TextDefinition definition, final String word ) {
assert word != null;
src/main/java/com/keenwrite/Launcher.java
* @param args Command-line arguments.
*/
- public static void main( final String[] args ) throws IOException {
+ public static void main( final String[] args ) {
showAppInfo();
MainApp.main( args );
}
@SuppressWarnings("RedundantStringFormatCall")
- private static void showAppInfo() throws IOException {
+ private static void showAppInfo() {
out( format( "%s version %s", APP_TITLE, getVersion() ) );
- out( format( "Copyright %s White Magic Software, Ltd.", getYear() ) );
- out( format( "Portions copyright 2020 Karl Tauber." ) );
+ out( format( "Copyright 2016-%s White Magic Software, Ltd.", getYear() ) );
+ out( format( "Portions copyright 2015-2020 Karl Tauber." ) );
}
public static String getVersion() {
try {
- final Properties properties = loadProperties( "app.properties" );
+ final var properties = loadProperties( "app.properties" );
return properties.getProperty( "application.version" );
} catch( final Exception ex ) {
@SuppressWarnings("SameParameterValue")
private static Properties loadProperties( final String resource )
- throws IOException {
- final Properties properties = new Properties();
+ throws IOException {
+ final var properties = new Properties();
properties.load( getResourceAsStream( getResourceName( resource ) ) );
return properties;
src/main/java/com/keenwrite/MainPane.java
import com.keenwrite.editors.definition.DefinitionEditor;
import com.keenwrite.editors.definition.DefinitionTabSceneFactory;
-import com.keenwrite.editors.definition.yaml.YamlTreeTransformer;
-import com.keenwrite.editors.markdown.MarkdownEditor;
-import com.keenwrite.io.MediaType;
-import com.keenwrite.preferences.Workspace;
-import com.keenwrite.preview.HtmlPreview;
-import com.keenwrite.processors.IdentityProcessor;
-import com.keenwrite.processors.Processor;
-import com.keenwrite.processors.ProcessorContext;
-import com.keenwrite.processors.ProcessorFactory;
-import com.keenwrite.processors.markdown.Caret;
-import com.keenwrite.processors.markdown.CaretExtension;
-import com.keenwrite.service.events.Notifier;
-import com.panemu.tiwulfx.control.dock.DetachableTab;
-import com.panemu.tiwulfx.control.dock.DetachableTabPane;
-import javafx.beans.property.ObjectProperty;
-import javafx.beans.property.ReadOnlyObjectProperty;
-import javafx.beans.property.SetProperty;
-import javafx.beans.property.SimpleObjectProperty;
-import javafx.collections.ListChangeListener;
-import javafx.event.ActionEvent;
-import javafx.event.Event;
-import javafx.event.EventHandler;
-import javafx.scene.Scene;
-import javafx.scene.control.SplitPane;
-import javafx.scene.control.Tab;
-import javafx.scene.control.Tooltip;
-import javafx.scene.control.TreeItem.TreeModificationEvent;
-import javafx.scene.input.KeyEvent;
-import javafx.stage.Stage;
-import javafx.stage.Window;
-
-import java.io.File;
-import java.nio.file.Path;
-import java.util.*;
-import java.util.concurrent.atomic.AtomicBoolean;
-
-import static com.keenwrite.Constants.*;
-import static com.keenwrite.ExportFormat.NONE;
-import static com.keenwrite.Messages.get;
-import static com.keenwrite.StatusBarNotifier.clue;
-import static com.keenwrite.editors.definition.MapInterpolator.interpolate;
-import static com.keenwrite.io.MediaType.*;
-import static com.keenwrite.preferences.Workspace.KEY_UI_FILES_PATH;
-import static com.keenwrite.processors.ProcessorFactory.createProcessors;
-import static com.keenwrite.service.events.Notifier.NO;
-import static com.keenwrite.service.events.Notifier.YES;
-import static javafx.application.Platform.runLater;
-import static javafx.scene.control.TabPane.TabClosingPolicy.ALL_TABS;
-import static javafx.scene.input.KeyCode.SPACE;
-import static javafx.scene.input.KeyCombination.CONTROL_DOWN;
-import static javafx.util.Duration.millis;
-import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
-
-/**
- * Responsible for wiring together the main application components for a
- * particular workspace (project). These include the definition views,
- * text editors, and preview pane along with any corresponding controllers.
- */
-public final class MainPane extends SplitPane {
- private static final Notifier sNotifier = Services.load( Notifier.class );
-
- /**
- * Prevents re-instantiation of processing classes.
- */
- private final Map<TextResource, Processor<String>> mProcessors =
- new HashMap<>();
-
- private final Workspace mWorkspace;
-
- /**
- * Groups similar file type tabs together.
- */
- private final Map<MediaType, DetachableTabPane> mTabPanes = new HashMap<>();
-
- /**
- * Stores definition names and values.
- */
- private final Map<String, String> mResolvedMap =
- new HashMap<>( MAP_SIZE_DEFAULT );
-
- /**
- * Renders the actively selected plain text editor tab.
- */
- private final HtmlPreview mHtmlPreview = new HtmlPreview();
-
- /**
- * Changing the active editor fires the value changed event. This allows
- * refreshes to happen when external definitions are modified and need to
- * trigger the processing chain.
- */
- private final ObjectProperty<TextEditor> mActiveTextEditor =
- createActiveTextEditor();
-
- /**
- * Changing the active definition editor fires the value changed event. This
- * allows refreshes to happen when external definitions are modified and need
- * to trigger the processing chain.
- */
- private final ObjectProperty<TextDefinition> mActiveDefinitionEditor =
- createActiveDefinitionEditor( mActiveTextEditor );
-
- /**
- * Responsible for creating a new scene when a tab is detached into
- * its own window frame.
- */
- private final DefinitionTabSceneFactory mDefinitionTabSceneFactory =
- createDefinitionTabSceneFactory( mActiveDefinitionEditor );
-
- /**
- * Tracks the number of detached tab panels opened into their own windows,
- * which allows unique identification of subordinate windows by their title.
- * It is doubtful more than 128 windows, much less 256, will be created.
- */
- private byte mWindowCount;
-
- /**
- * Called when the definition data is changed.
- */
- private final EventHandler<TreeModificationEvent<Event>> mTreeHandler =
- event -> {
- final var editor = mActiveDefinitionEditor.get();
-
- resolve( editor );
- process( getActiveTextEditor() );
- save( editor );
- };
-
- /**
- * Adds all content panels to the main user interface. This will load the
- * configuration settings from the workspace to reproduce the settings from
- * a previous session.
- */
- public MainPane( final Workspace workspace ) {
- mWorkspace = workspace;
-
- open( bin( getRecentFiles() ) );
-
- final var tabPane = obtainDetachableTabPane( TEXT_HTML );
- tabPane.addTab( "HTML", mHtmlPreview );
- addTabPane( tabPane );
-
- final var ratio = 100f / getItems().size() / 100;
- final var positions = getDividerPositions();
-
- for( int i = 0; i < positions.length; i++ ) {
- positions[ i ] = ratio * i;
- }
-
- // TODO: Load divider positions from exported settings, see bin() comment.
- setDividerPositions( positions );
-
- // Once the main scene's window regains focus, update the active definition
- // editor to the currently selected tab.
- runLater(
- () -> getWindow().focusedProperty().addListener( ( c, o, n ) -> {
- if( n != null && n ) {
- final var pane = mTabPanes.get( TEXT_YAML );
- final var model = pane.getSelectionModel();
- final var tab = model.getSelectedItem();
-
- if( tab != null ) {
- final var editor = (TextDefinition) tab.getContent();
-
- mActiveDefinitionEditor.set( editor );
- }
- }
- } )
- );
-
- forceRepaint();
- }
-
- /**
- * Force preview pane refresh on Windows.
- */
- private void forceRepaint() {
-// if( IS_OS_WINDOWS ) {
-// splitPane.getDividers().get( 1 ).positionProperty().addListener(
-// ( l, oValue, nValue ) -> runLater(
-// () -> getHtmlPreview().repaintScrollPane()
-// )
-// );
-// }
- }
-
- /**
- * Opens all the files into the application, provided the paths are unique.
- * This may only be called for any type of files that a user can edit
- * (i.e., update and persist), such as definitions and text files.
- *
- * @param files The list of files to open.
- */
- public void open( final List<File> files ) {
- files.forEach( this::open );
- }
-
- /**
- * This opens the given file. Since the preview pane is not a file that
- * can be opened, it is safe to add a listener to the detachable pane.
- *
- * @param file The file to open.
- */
- private void open( final File file ) {
- final var kFile = new com.keenwrite.io.File( file );
- final var mediaType = kFile.getMediaType();
- final var tab = createTab( kFile );
- final var node = tab.getContent();
- final var tabPane = obtainDetachableTabPane( mediaType );
- final var newTabPane = !getItems().contains( tabPane );
-
- tab.setTooltip( createTooltip( kFile ) );
- tabPane.setFocusTraversable( false );
- tabPane.setTabClosingPolicy( ALL_TABS );
- tabPane.getTabs().add( tab );
-
- if( newTabPane ) {
- var index = getItems().size();
-
- if( node instanceof TextDefinition ) {
- tabPane.setSceneFactory( mDefinitionTabSceneFactory::create );
- index = 0;
- }
-
- addTabPane( index, tabPane );
- }
-
- getRecentFiles().add( file );
- }
-
- /**
- * Opens a new text editor document using the default document file name.
- */
- public void newTextEditor() {
- open( DOCUMENT_DEFAULT );
- }
-
- /**
- * Opens a new definition editor document using the default definition
- * file name.
- */
- public void newDefinitionEditor() {
- open( DEFINITION_DEFAULT );
- }
-
- /**
- * Iterates over all tab panes to find all {@link TextEditor}s and request
- * that they save themselves.
- */
- public void saveAll() {
- mTabPanes.forEach(
- ( mt, tp ) -> tp.getTabs().forEach( ( tab ) -> {
- final var node = tab.getContent();
- if( node instanceof TextEditor ) {
- save( ((TextEditor) node) );
- }
- } )
- );
- }
-
- /**
- * Requests that the active {@link TextEditor} saves itself. Don't bother
- * checking if modified first because if the user swaps external media from
- * an external source (e.g., USB thumb drive), save should not second-guess
- * the user: save always re-saves. Also, it's less code.
- */
- public void save() {
- save( getActiveTextEditor() );
- }
-
- /**
- * Saves the active {@link TextEditor} under a new name.
- *
- * @param file The new active editor {@link File} reference.
- */
- public void saveAs( final File file ) {
- assert file != null;
- final var editor = getActiveTextEditor();
- final var tab = getTab( editor );
-
- editor.rename( file );
- tab.ifPresent( t -> {
- t.setText( editor.getFilename() );
- t.setTooltip( createTooltip( file ) );
- } );
-
- save();
- }
-
- /**
- * Saves the given {@link TextResource} to a file. This is typically used
- * to save either an instance of {@link TextEditor} or {@link TextDefinition}.
- *
- * @param resource The resource to export.
- */
- private void save( final TextResource resource ) {
- try {
- resource.save();
- } catch( final Exception ex ) {
- clue( ex );
- sNotifier.alert(
- getWindow(), resource.getPath(), "TextResource.saveFailed", ex
- );
- }
- }
-
- /**
- * Closes all open {@link TextEditor}s; all {@link TextDefinition}s stay open.
- *
- * @return {@code true} when all editors, modified or otherwise, were
- * permitted to close; {@code false} when one or more editors were modified
- * and the user requested no closing.
- */
- public boolean closeAll() {
- var closable = true;
-
- for( final var entry : mTabPanes.entrySet() ) {
- final var tabPane = entry.getValue();
- final var tabIterator = tabPane.getTabs().iterator();
-
- while( tabIterator.hasNext() ) {
- final var tab = tabIterator.next();
- final var node = tab.getContent();
-
- if( node instanceof TextEditor &&
- (closable &= canClose( (TextEditor) node )) ) {
- tabIterator.remove();
- close( tab );
- }
- }
- }
-
- return closable;
- }
-
- /**
- * Calls the tab's {@link Tab#getOnClosed()} handler to carry out a close
- * event.
- *
- * @param tab The {@link Tab} that was closed.
- */
- private void close( final Tab tab ) {
- final var handler = tab.getOnClosed();
-
- if( handler != null ) {
- handler.handle( new ActionEvent() );
- }
- }
-
- /**
- * Closes the active tab; delegates to {@link #canClose(TextEditor)}.
- */
- public void close() {
- final var editor = getActiveTextEditor();
- if( canClose( editor ) ) {
- close( editor );
- }
- }
-
- /**
- * Closes the given {@link TextEditor}. This must not be called from within
- * a loop that iterates over the tab panes using {@code forEach}, lest a
- * concurrent modification exception be thrown.
- *
- * @param editor The {@link TextEditor} to close, without confirming with
- * the user.
- */
- private void close( final TextEditor editor ) {
- getTab( editor ).ifPresent(
- ( tab ) -> {
- tab.getTabPane().getTabs().remove( tab );
- close( tab );
- }
- );
- }
-
- /**
- * Answers whether the given {@link TextEditor} may be closed.
- *
- * @param editor The {@link TextEditor} to try closing.
- * @return {@code true} when the editor may be closed; {@code false} when
- * the user has requested to keep the editor open.
- */
- private boolean canClose( final TextEditor editor ) {
- final var editorTab = getTab( editor );
- final var canClose = new AtomicBoolean( true );
-
- if( editor.isModified() ) {
- final var filename = new StringBuilder();
- editorTab.ifPresent( ( tab ) -> filename.append( tab.getText() ) );
-
- final var message = sNotifier.createNotification(
- Messages.get( "Alert.file.close.title" ),
- Messages.get( "Alert.file.close.text" ),
- filename.toString()
- );
-
- final var dialog = sNotifier.createConfirmation( getWindow(), message );
-
- dialog.showAndWait().ifPresent(
- save -> canClose.set( save == YES ? editor.save() : save == NO )
- );
- }
-
- return canClose.get();
- }
-
- private ObjectProperty<TextEditor> createActiveTextEditor() {
- final var editor = new SimpleObjectProperty<TextEditor>();
-
- editor.addListener( ( c, o, n ) -> {
- if( n != null ) {
- mHtmlPreview.setBaseUri( n.getPath() );
- process( n );
- }
- } );
-
- return editor;
- }
-
- /**
- * Returns the tab that contains the given {@link TextEditor}.
- *
- * @param editor The {@link TextEditor} instance to find amongst the tabs.
- * @return The first tab having content that matches the given tab.
- */
- private Optional<Tab> getTab( final TextEditor editor ) {
- return mTabPanes.values()
- .stream()
- .flatMap( pane -> pane.getTabs().stream() )
- .filter( tab -> editor.equals( tab.getContent() ) )
- .findFirst();
- }
-
- /**
- * Creates a new {@link DefinitionEditor} wrapped in a listener that
- * is used to detect when the active {@link DefinitionEditor} has changed.
- * Upon changing, the {@link #mResolvedMap} is updated and the active
- * text editor is refreshed.
- *
- * @param editor Text editor to update with the revised resolved map.
- * @return A newly configured property that represents the active
- * {@link DefinitionEditor}, never null.
- */
- private ObjectProperty<TextDefinition> createActiveDefinitionEditor(
- final ObjectProperty<TextEditor> editor ) {
- final var definitions = new SimpleObjectProperty<TextDefinition>();
- definitions.addListener( ( c, o, n ) -> {
- resolve( n == null ? createDefinitionEditor() : n );
- process( editor.get() );
- } );
-
- return definitions;
- }
-
- /**
- * Instantiates a factory that's responsible for creating new scenes when
- * a tab is dropped outside of any application window. The definition tabs
- * are fairly complex in that only one may be active at any time. When
- * activated, the {@link #mResolvedMap} must be updated to reflect the
- * hierarchy displayed in the {@link DefinitionEditor}.
- *
- * @param activeDefinitionEditor The current {@link DefinitionEditor}.
- * @return An object that listens to {@link DefinitionEditor} tab focus
- * changes.
- */
- private DefinitionTabSceneFactory createDefinitionTabSceneFactory(
- final ObjectProperty<TextDefinition> activeDefinitionEditor ) {
- return new DefinitionTabSceneFactory( ( tab ) -> {
- assert tab != null;
-
- var node = tab.getContent();
- if( node instanceof TextDefinition ) {
- activeDefinitionEditor.set( (DefinitionEditor) node );
- }
- } );
- }
-
- private DetachableTab createTab( final File file ) {
- final var r = createTextResource( file );
- final var tab = new DetachableTab( r.getFilename(), r.getNode() );
-
- r.modifiedProperty().addListener(
- ( c, o, n ) -> tab.setText( r.getFilename() + (n ? "*" : "") )
- );
-
- // This is called when either the tab is closed by the user clicking on
- // the tab's close icon or when closing (all) from the file menu.
- tab.setOnClosed( ( __ ) -> getRecentFiles().remove( file ) );
-
- 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. Each different
- * {@link MediaType} will be created in its own pane.
- * </p>
- *
- * @param paths The file paths to bin by {@link MediaType}.
- * @return An in-order list of files, first by structured definition files,
- * then by plain text documents.
- */
- private List<File> bin( final SetProperty<Object> paths ) {
- final var map = new HashMap<MediaType, Set<File>>();
- map.put( TEXT_YAML, new HashSet<>() );
- map.put( TEXT_MARKDOWN, new HashSet<>() );
- map.put( UNDEFINED, new HashSet<>() );
-
- for( final var path : paths ) {
- final var file = new com.keenwrite.io.File( path.toString() );
-
- final var set = map.computeIfAbsent(
- file.getMediaType(), k -> new HashSet<>()
- );
-
- set.add( file );
- }
-
- final var definitions = map.get( TEXT_YAML );
- final var documents = map.get( TEXT_MARKDOWN );
- final var undefined = map.get( UNDEFINED );
-
- if( definitions.isEmpty() ) {
- definitions.add( DEFINITION_DEFAULT );
- }
-
- if( documents.isEmpty() ) {
- documents.add( DOCUMENT_DEFAULT );
- }
-
- final var result = new ArrayList<File>( paths.size() );
- result.addAll( definitions );
- result.addAll( documents );
- result.addAll( undefined );
-
- return result;
- }
-
- /**
- * Uses the given {@link TextDefinition} instance to update the
- * {@link #mResolvedMap}.
- *
- * @param editor A non-null, possibly empty definition editor.
- */
- private void resolve( final TextDefinition editor ) {
- assert editor != null;
- mResolvedMap.clear();
- mResolvedMap.putAll( interpolate( new HashMap<>( editor.toMap() ) ) );
- }
-
- /**
- * Force the active editor to update, which will cause the processor
- * to re-evaluate the interpolated definition map thereby updating the
- * preview pane.
- *
- * @param editor Contains the source document to update in the preview pane.
- */
- private void process( final TextEditor editor ) {
- mProcessors.getOrDefault( editor, IdentityProcessor.INSTANCE )
- .apply( editor == null ? "" : editor.getText() );
- mHtmlPreview.scrollTo( CARET_ID );
- }
-
- /**
- * Lazily creates a {@link DetachableTabPane} configured to handle focus
- * requests by delegating to the selected tab's content. The tab pane is
- * associated with a given media type so that similar files can be grouped
- * together.
- *
- * @param mediaType The media type to associate with the tab pane.
- * @return An instance of {@link DetachableTabPane} that will handle
- * docking of tabs.
- */
- private DetachableTabPane obtainDetachableTabPane(
- final MediaType mediaType ) {
- return mTabPanes.computeIfAbsent(
- mediaType, ( mt ) -> createDetachableTabPane()
- );
- }
-
- /**
- * Creates an initialized {@link DetachableTabPane} instance.
- *
- * @return A new {@link DetachableTabPane} with all listeners configured.
- */
- private DetachableTabPane createDetachableTabPane() {
- final var tabPane = new DetachableTabPane();
-
- initStageOwnerFactory( tabPane );
- initTabListener( tabPane );
- initSelectionModelListener( tabPane );
-
- return tabPane;
- }
-
- /**
- * When any {@link DetachableTabPane} is detached from the main window,
- * the stage owner factory must be given its parent window, which will
- * own the child window. The parent window is the {@link MainPane}'s
- * {@link Scene}'s {@link Window} instance.
- *
- * <p>
- * This will derives the new title from the main window title, incrementing
- * the window count to help uniquely identify the child windows.
- * </p>
- *
- * @param tabPane A new {@link DetachableTabPane} to configure.
- */
- private void initStageOwnerFactory( final DetachableTabPane tabPane ) {
- tabPane.setStageOwnerFactory( ( stage ) -> {
- final var title = get(
- "Detach.tab.title",
- ((Stage) getWindow()).getTitle(), ++mWindowCount
- );
- stage.setTitle( title );
- return getScene().getWindow();
- } );
- }
-
- /**
- * Responsible for configuring the content of each {@link DetachableTab} when
- * it is added to the given {@link DetachableTabPane} instance.
- * <p>
- * For {@link TextEditor} contents, an instance of {@link ScrollEventHandler}
- * is initialized to perform synchronized scrolling between the editor and
- * its preview window. Additionally, the last tab in the tab pane's list of
- * tabs is given focus.
- * </p>
- * <p>
- * Note that multiple tabs can be added simultaneously.
- * </p>
- *
- * @param tabPane A new {@link DetachableTabPane} to configure.
- */
- private void initTabListener( final DetachableTabPane tabPane ) {
- tabPane.getTabs().addListener(
- ( final ListChangeListener.Change<? extends Tab> listener ) -> {
- while( listener.next() ) {
- if( listener.wasAdded() ) {
- final var tabs = listener.getAddedSubList();
-
- tabs.forEach( ( tab ) -> {
- final var node = tab.getContent();
-
- if( node instanceof TextEditor ) {
- initScrollEventListener( tab );
- }
- } );
-
- // Select and give focus to the last tab opened.
- final var index = tabs.size() - 1;
- if( index >= 0 ) {
- final var tab = tabs.get( index );
- tabPane.getSelectionModel().select( tab );
- tab.getContent().requestFocus();
- }
- }
- }
- }
- );
- }
-
- /**
- * Responsible for handling tab change events.
- *
- * @param tabPane A new {@link DetachableTabPane} to configure.
- */
- private void initSelectionModelListener( final DetachableTabPane tabPane ) {
- final var model = tabPane.getSelectionModel();
-
- model.selectedItemProperty().addListener( ( c, o, n ) -> {
- if( o != null && n == null ) {
- final var node = o.getContent();
-
- // If the last definition editor in the active pane was closed,
- // clear out the definitions then refresh the text editor.
- if( node instanceof TextDefinition ) {
- mActiveDefinitionEditor.set( createDefinitionEditor() );
- }
- }
- else if( n != null ) {
- final var node = n.getContent();
-
- if( node instanceof TextEditor ) {
- // Changing the active node will fire an event, which will
- // update the preview panel and grab focus.
- mActiveTextEditor.set( (TextEditor) node );
- runLater( node::requestFocus );
- }
- else if( node instanceof TextDefinition ) {
- mActiveDefinitionEditor.set( (DefinitionEditor) node );
- }
- }
- } );
- }
-
- /**
- * Synchronizes scrollbar positions between the given {@link Tab} that
- * contains an instance of {@link TextEditor} and {@link HtmlPreview} pane.
- *
- * @param tab The container for an instance of {@link TextEditor}.
- */
- private void initScrollEventListener( final Tab tab ) {
- final var editor = (TextEditor) tab.getContent();
- final var scrollPane = editor.getScrollPane();
- final var scrollBar = mHtmlPreview.getVerticalScrollBar();
- final var handler = new ScrollEventHandler( scrollPane, scrollBar );
- handler.enabledProperty().bind( tab.selectedProperty() );
- }
-
- private void addTabPane( final int index, final DetachableTabPane tabPane ) {
- getItems().add( index, tabPane );
- }
-
- private void addTabPane( final DetachableTabPane tabPane ) {
- addTabPane( getItems().size(), tabPane );
- }
-
- /**
- * @param path Used by {@link ProcessorFactory} to determine
- * {@link Processor} type to create based on file type.
- * @param caret Used by {@link CaretExtension} to add ID attribute into
- * preview document for scrollbar synchronization.
- * @return A new {@link ProcessorContext} to use when creating an instance of
- * {@link Processor}.
- */
- private ProcessorContext createProcessorContext(
- final Path path, final Caret caret ) {
- return new ProcessorContext(
- mHtmlPreview, mResolvedMap, path, caret, NONE, mWorkspace
- );
- }
-
- public ProcessorContext createProcessorContext( final TextEditor t ) {
- return createProcessorContext( t.getPath(), t.getCaret() );
- }
-
- @SuppressWarnings({"RedundantCast", "unchecked", "RedundantSuppression"})
- private TextResource createTextResource( final File file ) {
- final var mediaType = new com.keenwrite.io.File( file ).getMediaType();
-
- // TODO: Create PlainTextEditor that's returned by default.
- return switch( mediaType ) {
- case TEXT_MARKDOWN -> createMarkdownEditor( file );
- case TEXT_YAML -> createDefinitionEditor( file );
- default -> createMarkdownEditor( file );
- };
- }
-
- /**
- * 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 file The file containing contents for the text editor.
- * @return A non-null text editor.
- */
- private TextResource createMarkdownEditor( final File file ) {
- final var path = file.toPath();
- final var editor = new MarkdownEditor( file );
- final var caret = editor.getCaret();
- final var context = createProcessorContext( path, caret );
-
- mProcessors.computeIfAbsent( editor, p -> createProcessors( context ) );
-
- editor.addDirtyListener( ( c, o, n ) -> {
- if( n ) {
- process( getActiveTextEditor() );
- }
- } );
-
- editor.addEventListener(
- keyPressed( SPACE, CONTROL_DOWN ), this::autoinsert
- );
-
- // Set the active editor, which refreshes the preview panel.
- mActiveTextEditor.set( editor );
-
- return editor;
- }
-
- /**
- * Delegates to {@link #autoinsert()}.
- *
- * @param event Ignored.
- */
- private void autoinsert( final KeyEvent event ) {
- autoinsert();
- }
-
- /**
- * Finds a node that matches the word at the caret, then inserts the
- * corresponding definition. The definition token delimiters depend on
- * the type of file being edited.
- */
- public void autoinsert() {
- final var definitions = getActiveTextDefinition();
- final var editor = getActiveTextEditor();
- final var mediaType = editor.getMediaType();
- final var decorator = getSigilOperator( mediaType );
-
- DefinitionNameInjector.autoinsert( editor, definitions, decorator );
- }
-
- private TextDefinition createDefinitionEditor() {
- return createDefinitionEditor( DEFINITION_DEFAULT );
- }
-
- private TextDefinition createDefinitionEditor( final File file ) {
- final var editor = new DefinitionEditor( file, new YamlTreeTransformer() );
-
- editor.addTreeChangeHandler( mTreeHandler );
-
- return editor;
- }
-
- private Tooltip createTooltip( final File file ) {
- final var path = file.toPath();
- final var tooltip = new Tooltip( path.toString() );
-
- tooltip.setShowDelay( millis( 200 ) );
- return tooltip;
- }
-
- public TextEditor getActiveTextEditor() {
- return mActiveTextEditor.get();
- }
-
- public ReadOnlyObjectProperty<TextEditor> activeTextEditorProperty() {
- return mActiveTextEditor;
- }
-
- public TextDefinition getActiveTextDefinition() {
- return mActiveDefinitionEditor.get();
- }
-
- public Window getWindow() {
- return getScene().getWindow();
- }
-
- public Workspace getWorkspace() {
- return mWorkspace;
- }
-
- /**
- * Returns the set of filenames opened in the application. The names must
- * be converted to {@link File} objects.
- *
- * @return A {@link Set} of filenames.
- */
- private SetProperty<Object> getRecentFiles() {
- return getWorkspace().setsProperty( KEY_UI_FILES_PATH );
+import com.keenwrite.editors.definition.TreeTransformer;
+import com.keenwrite.editors.definition.yaml.YamlTreeTransformer;
+import com.keenwrite.editors.markdown.MarkdownEditor;
+import com.keenwrite.io.MediaType;
+import com.keenwrite.preferences.Key;
+import com.keenwrite.preferences.Workspace;
+import com.keenwrite.preview.HtmlPreview;
+import com.keenwrite.processors.IdentityProcessor;
+import com.keenwrite.processors.Processor;
+import com.keenwrite.processors.ProcessorContext;
+import com.keenwrite.processors.ProcessorFactory;
+import com.keenwrite.processors.markdown.Caret;
+import com.keenwrite.processors.markdown.CaretExtension;
+import com.keenwrite.service.events.Notifier;
+import com.keenwrite.sigils.RSigilOperator;
+import com.keenwrite.sigils.SigilOperator;
+import com.keenwrite.sigils.Tokens;
+import com.keenwrite.sigils.YamlSigilOperator;
+import com.panemu.tiwulfx.control.dock.DetachableTab;
+import com.panemu.tiwulfx.control.dock.DetachableTabPane;
+import javafx.beans.property.*;
+import javafx.collections.ListChangeListener;
+import javafx.event.ActionEvent;
+import javafx.event.Event;
+import javafx.event.EventHandler;
+import javafx.scene.Scene;
+import javafx.scene.control.SplitPane;
+import javafx.scene.control.Tab;
+import javafx.scene.control.Tooltip;
+import javafx.scene.control.TreeItem.TreeModificationEvent;
+import javafx.scene.input.KeyEvent;
+import javafx.stage.Stage;
+import javafx.stage.Window;
+
+import java.io.File;
+import java.nio.file.Path;
+import java.util.*;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import static com.keenwrite.Constants.*;
+import static com.keenwrite.ExportFormat.NONE;
+import static com.keenwrite.Messages.get;
+import static com.keenwrite.StatusBarNotifier.clue;
+import static com.keenwrite.io.MediaType.*;
+import static com.keenwrite.preferences.Workspace.*;
+import static com.keenwrite.processors.ProcessorFactory.createProcessors;
+import static com.keenwrite.service.events.Notifier.NO;
+import static com.keenwrite.service.events.Notifier.YES;
+import static javafx.application.Platform.runLater;
+import static javafx.scene.control.TabPane.TabClosingPolicy.ALL_TABS;
+import static javafx.scene.input.KeyCode.SPACE;
+import static javafx.scene.input.KeyCombination.CONTROL_DOWN;
+import static javafx.util.Duration.millis;
+import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
+
+/**
+ * Responsible for wiring together the main application components for a
+ * particular workspace (project). These include the definition views,
+ * text editors, and preview pane along with any corresponding controllers.
+ */
+public final class MainPane extends SplitPane {
+ private static final Notifier sNotifier = Services.load( Notifier.class );
+
+ /**
+ * Prevents re-instantiation of processing classes.
+ */
+ private final Map<TextResource, Processor<String>> mProcessors =
+ new HashMap<>();
+
+ private final Workspace mWorkspace;
+
+ /**
+ * Groups similar file type tabs together.
+ */
+ private final Map<MediaType, DetachableTabPane> mTabPanes = new HashMap<>();
+
+ /**
+ * Stores definition names and values.
+ */
+ private final Map<String, String> mResolvedMap =
+ new HashMap<>( MAP_SIZE_DEFAULT );
+
+ /**
+ * Renders the actively selected plain text editor tab.
+ */
+ private final HtmlPreview mHtmlPreview = new HtmlPreview();
+
+ /**
+ * Changing the active editor fires the value changed event. This allows
+ * refreshes to happen when external definitions are modified and need to
+ * trigger the processing chain.
+ */
+ private final ObjectProperty<TextEditor> mActiveTextEditor =
+ createActiveTextEditor();
+
+ /**
+ * Changing the active definition editor fires the value changed event. This
+ * allows refreshes to happen when external definitions are modified and need
+ * to trigger the processing chain.
+ */
+ private final ObjectProperty<TextDefinition> mActiveDefinitionEditor =
+ createActiveDefinitionEditor( mActiveTextEditor );
+
+ /**
+ * Responsible for creating a new scene when a tab is detached into
+ * its own window frame.
+ */
+ private final DefinitionTabSceneFactory mDefinitionTabSceneFactory =
+ createDefinitionTabSceneFactory( mActiveDefinitionEditor );
+
+ /**
+ * Tracks the number of detached tab panels opened into their own windows,
+ * which allows unique identification of subordinate windows by their title.
+ * It is doubtful more than 128 windows, much less 256, will be created.
+ */
+ private byte mWindowCount;
+
+ /**
+ * Called when the definition data is changed.
+ */
+ private final EventHandler<TreeModificationEvent<Event>> mTreeHandler =
+ event -> {
+ final var editor = mActiveDefinitionEditor.get();
+
+ resolve( editor );
+ process( getActiveTextEditor() );
+ save( editor );
+ };
+
+ /**
+ * Adds all content panels to the main user interface. This will load the
+ * configuration settings from the workspace to reproduce the settings from
+ * a previous session.
+ */
+ public MainPane( final Workspace workspace ) {
+ mWorkspace = workspace;
+
+ open( bin( getRecentFiles() ) );
+
+ final var tabPane = obtainDetachableTabPane( TEXT_HTML );
+ tabPane.addTab( "HTML", mHtmlPreview );
+ addTabPane( tabPane );
+
+ final var ratio = 100f / getItems().size() / 100;
+ final var positions = getDividerPositions();
+
+ for( int i = 0; i < positions.length; i++ ) {
+ positions[ i ] = ratio * i;
+ }
+
+ // TODO: Load divider positions from exported settings, see bin() comment.
+ setDividerPositions( positions );
+
+ // Once the main scene's window regains focus, update the active definition
+ // editor to the currently selected tab.
+ runLater(
+ () -> getWindow().focusedProperty().addListener( ( c, o, n ) -> {
+ if( n != null && n ) {
+ final var pane = mTabPanes.get( TEXT_YAML );
+ final var model = pane.getSelectionModel();
+ final var tab = model.getSelectedItem();
+
+ if( tab != null ) {
+ final var editor = (TextDefinition) tab.getContent();
+
+ mActiveDefinitionEditor.set( editor );
+ }
+ }
+ } )
+ );
+
+ forceRepaint();
+ }
+
+ /**
+ * Force preview pane refresh on Windows.
+ */
+ private void forceRepaint() {
+// if( IS_OS_WINDOWS ) {
+// splitPane.getDividers().get( 1 ).positionProperty().addListener(
+// ( l, oValue, nValue ) -> runLater(
+// () -> getHtmlPreview().repaintScrollPane()
+// )
+// );
+// }
+ }
+
+ /**
+ * Opens all the files into the application, provided the paths are unique.
+ * This may only be called for any type of files that a user can edit
+ * (i.e., update and persist), such as definitions and text files.
+ *
+ * @param files The list of files to open.
+ */
+ public void open( final List<File> files ) {
+ files.forEach( this::open );
+ }
+
+ /**
+ * This opens the given file. Since the preview pane is not a file that
+ * can be opened, it is safe to add a listener to the detachable pane.
+ *
+ * @param file The file to open.
+ */
+ private void open( final File file ) {
+ final var kFile = new com.keenwrite.io.File( file );
+ final var mediaType = kFile.getMediaType();
+ final var tab = createTab( kFile );
+ final var node = tab.getContent();
+ final var tabPane = obtainDetachableTabPane( mediaType );
+ final var newTabPane = !getItems().contains( tabPane );
+
+ tab.setTooltip( createTooltip( kFile ) );
+ tabPane.setFocusTraversable( false );
+ tabPane.setTabClosingPolicy( ALL_TABS );
+ tabPane.getTabs().add( tab );
+
+ if( newTabPane ) {
+ var index = getItems().size();
+
+ if( node instanceof TextDefinition ) {
+ tabPane.setSceneFactory( mDefinitionTabSceneFactory::create );
+ index = 0;
+ }
+
+ addTabPane( index, tabPane );
+ }
+
+ getRecentFiles().add( file );
+ }
+
+ /**
+ * Opens a new text editor document using the default document file name.
+ */
+ public void newTextEditor() {
+ open( DOCUMENT_DEFAULT );
+ }
+
+ /**
+ * Opens a new definition editor document using the default definition
+ * file name.
+ */
+ public void newDefinitionEditor() {
+ open( DEFINITION_DEFAULT );
+ }
+
+ /**
+ * Iterates over all tab panes to find all {@link TextEditor}s and request
+ * that they save themselves.
+ */
+ public void saveAll() {
+ mTabPanes.forEach(
+ ( mt, tp ) -> tp.getTabs().forEach( ( tab ) -> {
+ final var node = tab.getContent();
+ if( node instanceof TextEditor ) {
+ save( ((TextEditor) node) );
+ }
+ } )
+ );
+ }
+
+ /**
+ * Requests that the active {@link TextEditor} saves itself. Don't bother
+ * checking if modified first because if the user swaps external media from
+ * an external source (e.g., USB thumb drive), save should not second-guess
+ * the user: save always re-saves. Also, it's less code.
+ */
+ public void save() {
+ save( getActiveTextEditor() );
+ }
+
+ /**
+ * Saves the active {@link TextEditor} under a new name.
+ *
+ * @param file The new active editor {@link File} reference.
+ */
+ public void saveAs( final File file ) {
+ assert file != null;
+ final var editor = getActiveTextEditor();
+ final var tab = getTab( editor );
+
+ editor.rename( file );
+ tab.ifPresent( t -> {
+ t.setText( editor.getFilename() );
+ t.setTooltip( createTooltip( file ) );
+ } );
+
+ save();
+ }
+
+ /**
+ * Saves the given {@link TextResource} to a file. This is typically used
+ * to save either an instance of {@link TextEditor} or {@link TextDefinition}.
+ *
+ * @param resource The resource to export.
+ */
+ private void save( final TextResource resource ) {
+ try {
+ resource.save();
+ } catch( final Exception ex ) {
+ clue( ex );
+ sNotifier.alert(
+ getWindow(), resource.getPath(), "TextResource.saveFailed", ex
+ );
+ }
+ }
+
+ /**
+ * Closes all open {@link TextEditor}s; all {@link TextDefinition}s stay open.
+ *
+ * @return {@code true} when all editors, modified or otherwise, were
+ * permitted to close; {@code false} when one or more editors were modified
+ * and the user requested no closing.
+ */
+ public boolean closeAll() {
+ var closable = true;
+
+ for( final var entry : mTabPanes.entrySet() ) {
+ final var tabPane = entry.getValue();
+ final var tabIterator = tabPane.getTabs().iterator();
+
+ while( tabIterator.hasNext() ) {
+ final var tab = tabIterator.next();
+ final var node = tab.getContent();
+
+ if( node instanceof TextEditor &&
+ (closable &= canClose( (TextEditor) node )) ) {
+ tabIterator.remove();
+ close( tab );
+ }
+ }
+ }
+
+ return closable;
+ }
+
+ /**
+ * Calls the tab's {@link Tab#getOnClosed()} handler to carry out a close
+ * event.
+ *
+ * @param tab The {@link Tab} that was closed.
+ */
+ private void close( final Tab tab ) {
+ final var handler = tab.getOnClosed();
+
+ if( handler != null ) {
+ handler.handle( new ActionEvent() );
+ }
+ }
+
+ /**
+ * Closes the active tab; delegates to {@link #canClose(TextEditor)}.
+ */
+ public void close() {
+ final var editor = getActiveTextEditor();
+ if( canClose( editor ) ) {
+ close( editor );
+ }
+ }
+
+ /**
+ * Closes the given {@link TextEditor}. This must not be called from within
+ * a loop that iterates over the tab panes using {@code forEach}, lest a
+ * concurrent modification exception be thrown.
+ *
+ * @param editor The {@link TextEditor} to close, without confirming with
+ * the user.
+ */
+ private void close( final TextEditor editor ) {
+ getTab( editor ).ifPresent(
+ ( tab ) -> {
+ tab.getTabPane().getTabs().remove( tab );
+ close( tab );
+ }
+ );
+ }
+
+ /**
+ * Answers whether the given {@link TextEditor} may be closed.
+ *
+ * @param editor The {@link TextEditor} to try closing.
+ * @return {@code true} when the editor may be closed; {@code false} when
+ * the user has requested to keep the editor open.
+ */
+ private boolean canClose( final TextEditor editor ) {
+ final var editorTab = getTab( editor );
+ final var canClose = new AtomicBoolean( true );
+
+ if( editor.isModified() ) {
+ final var filename = new StringBuilder();
+ editorTab.ifPresent( ( tab ) -> filename.append( tab.getText() ) );
+
+ final var message = sNotifier.createNotification(
+ Messages.get( "Alert.file.close.title" ),
+ Messages.get( "Alert.file.close.text" ),
+ filename.toString()
+ );
+
+ final var dialog = sNotifier.createConfirmation( getWindow(), message );
+
+ dialog.showAndWait().ifPresent(
+ save -> canClose.set( save == YES ? editor.save() : save == NO )
+ );
+ }
+
+ return canClose.get();
+ }
+
+ private ObjectProperty<TextEditor> createActiveTextEditor() {
+ final var editor = new SimpleObjectProperty<TextEditor>();
+
+ editor.addListener( ( c, o, n ) -> {
+ if( n != null ) {
+ mHtmlPreview.setBaseUri( n.getPath() );
+ process( n );
+ }
+ } );
+
+ return editor;
+ }
+
+ /**
+ * Returns the tab that contains the given {@link TextEditor}.
+ *
+ * @param editor The {@link TextEditor} instance to find amongst the tabs.
+ * @return The first tab having content that matches the given tab.
+ */
+ private Optional<Tab> getTab( final TextEditor editor ) {
+ return mTabPanes.values()
+ .stream()
+ .flatMap( pane -> pane.getTabs().stream() )
+ .filter( tab -> editor.equals( tab.getContent() ) )
+ .findFirst();
+ }
+
+ /**
+ * Creates a new {@link DefinitionEditor} wrapped in a listener that
+ * is used to detect when the active {@link DefinitionEditor} has changed.
+ * Upon changing, the {@link #mResolvedMap} is updated and the active
+ * text editor is refreshed.
+ *
+ * @param editor Text editor to update with the revised resolved map.
+ * @return A newly configured property that represents the active
+ * {@link DefinitionEditor}, never null.
+ */
+ private ObjectProperty<TextDefinition> createActiveDefinitionEditor(
+ final ObjectProperty<TextEditor> editor ) {
+ final var definitions = new SimpleObjectProperty<TextDefinition>();
+ definitions.addListener( ( c, o, n ) -> {
+ resolve( n == null ? createDefinitionEditor() : n );
+ process( editor.get() );
+ } );
+
+ return definitions;
+ }
+
+ /**
+ * Instantiates a factory that's responsible for creating new scenes when
+ * a tab is dropped outside of any application window. The definition tabs
+ * are fairly complex in that only one may be active at any time. When
+ * activated, the {@link #mResolvedMap} must be updated to reflect the
+ * hierarchy displayed in the {@link DefinitionEditor}.
+ *
+ * @param activeDefinitionEditor The current {@link DefinitionEditor}.
+ * @return An object that listens to {@link DefinitionEditor} tab focus
+ * changes.
+ */
+ private DefinitionTabSceneFactory createDefinitionTabSceneFactory(
+ final ObjectProperty<TextDefinition> activeDefinitionEditor ) {
+ return new DefinitionTabSceneFactory( ( tab ) -> {
+ assert tab != null;
+
+ var node = tab.getContent();
+ if( node instanceof TextDefinition ) {
+ activeDefinitionEditor.set( (DefinitionEditor) node );
+ }
+ } );
+ }
+
+ private DetachableTab createTab( final File file ) {
+ final var r = createTextResource( file );
+ final var tab = new DetachableTab( r.getFilename(), r.getNode() );
+
+ r.modifiedProperty().addListener(
+ ( c, o, n ) -> tab.setText( r.getFilename() + (n ? "*" : "") )
+ );
+
+ // This is called when either the tab is closed by the user clicking on
+ // the tab's close icon or when closing (all) from the file menu.
+ tab.setOnClosed( ( __ ) -> getRecentFiles().remove( file ) );
+
+ 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. Each different
+ * {@link MediaType} will be created in its own pane.
+ * </p>
+ *
+ * @param paths The file paths to bin by {@link MediaType}.
+ * @return An in-order list of files, first by structured definition files,
+ * then by plain text documents.
+ */
+ private List<File> bin( final SetProperty<Object> paths ) {
+ final var map = new HashMap<MediaType, Set<File>>();
+ map.put( TEXT_YAML, new HashSet<>() );
+ map.put( TEXT_MARKDOWN, new HashSet<>() );
+ map.put( UNDEFINED, new HashSet<>() );
+
+ for( final var path : paths ) {
+ final var file = new com.keenwrite.io.File( path.toString() );
+
+ final var set = map.computeIfAbsent(
+ file.getMediaType(), k -> new HashSet<>()
+ );
+
+ set.add( file );
+ }
+
+ final var definitions = map.get( TEXT_YAML );
+ final var documents = map.get( TEXT_MARKDOWN );
+ final var undefined = map.get( UNDEFINED );
+
+ if( definitions.isEmpty() ) {
+ definitions.add( DEFINITION_DEFAULT );
+ }
+
+ if( documents.isEmpty() ) {
+ documents.add( DOCUMENT_DEFAULT );
+ }
+
+ final var result = new ArrayList<File>( paths.size() );
+ result.addAll( definitions );
+ result.addAll( documents );
+ result.addAll( undefined );
+
+ return result;
+ }
+
+ /**
+ * Uses the given {@link TextDefinition} instance to update the
+ * {@link #mResolvedMap}.
+ *
+ * @param editor A non-null, possibly empty definition editor.
+ */
+ private void resolve( final TextDefinition editor ) {
+ assert editor != null;
+
+ final var tokens = createDefinitionTokens();
+ final var operator = new YamlSigilOperator( tokens );
+ final var map = new HashMap<String, String>();
+
+ editor.toMap().forEach( ( k, v ) -> map.put( operator.entoken( k ), v ) );
+
+ mResolvedMap.clear();
+ mResolvedMap.putAll( editor.interpolate( map, tokens ) );
+ }
+
+ /**
+ * Force the active editor to update, which will cause the processor
+ * to re-evaluate the interpolated definition map thereby updating the
+ * preview pane.
+ *
+ * @param editor Contains the source document to update in the preview pane.
+ */
+ private void process( final TextEditor editor ) {
+ mProcessors.getOrDefault( editor, IdentityProcessor.INSTANCE )
+ .apply( editor == null ? "" : editor.getText() );
+ mHtmlPreview.scrollTo( CARET_ID );
+ }
+
+ /**
+ * Lazily creates a {@link DetachableTabPane} configured to handle focus
+ * requests by delegating to the selected tab's content. The tab pane is
+ * associated with a given media type so that similar files can be grouped
+ * together.
+ *
+ * @param mediaType The media type to associate with the tab pane.
+ * @return An instance of {@link DetachableTabPane} that will handle
+ * docking of tabs.
+ */
+ private DetachableTabPane obtainDetachableTabPane(
+ final MediaType mediaType ) {
+ return mTabPanes.computeIfAbsent(
+ mediaType, ( mt ) -> createDetachableTabPane()
+ );
+ }
+
+ /**
+ * Creates an initialized {@link DetachableTabPane} instance.
+ *
+ * @return A new {@link DetachableTabPane} with all listeners configured.
+ */
+ private DetachableTabPane createDetachableTabPane() {
+ final var tabPane = new DetachableTabPane();
+
+ initStageOwnerFactory( tabPane );
+ initTabListener( tabPane );
+ initSelectionModelListener( tabPane );
+
+ return tabPane;
+ }
+
+ /**
+ * When any {@link DetachableTabPane} is detached from the main window,
+ * the stage owner factory must be given its parent window, which will
+ * own the child window. The parent window is the {@link MainPane}'s
+ * {@link Scene}'s {@link Window} instance.
+ *
+ * <p>
+ * This will derives the new title from the main window title, incrementing
+ * the window count to help uniquely identify the child windows.
+ * </p>
+ *
+ * @param tabPane A new {@link DetachableTabPane} to configure.
+ */
+ private void initStageOwnerFactory( final DetachableTabPane tabPane ) {
+ tabPane.setStageOwnerFactory( ( stage ) -> {
+ final var title = get(
+ "Detach.tab.title",
+ ((Stage) getWindow()).getTitle(), ++mWindowCount
+ );
+ stage.setTitle( title );
+ return getScene().getWindow();
+ } );
+ }
+
+ /**
+ * Responsible for configuring the content of each {@link DetachableTab} when
+ * it is added to the given {@link DetachableTabPane} instance.
+ * <p>
+ * For {@link TextEditor} contents, an instance of {@link ScrollEventHandler}
+ * is initialized to perform synchronized scrolling between the editor and
+ * its preview window. Additionally, the last tab in the tab pane's list of
+ * tabs is given focus.
+ * </p>
+ * <p>
+ * Note that multiple tabs can be added simultaneously.
+ * </p>
+ *
+ * @param tabPane A new {@link DetachableTabPane} to configure.
+ */
+ private void initTabListener( final DetachableTabPane tabPane ) {
+ tabPane.getTabs().addListener(
+ ( final ListChangeListener.Change<? extends Tab> listener ) -> {
+ while( listener.next() ) {
+ if( listener.wasAdded() ) {
+ final var tabs = listener.getAddedSubList();
+
+ tabs.forEach( ( tab ) -> {
+ final var node = tab.getContent();
+
+ if( node instanceof TextEditor ) {
+ initScrollEventListener( tab );
+ }
+ } );
+
+ // Select and give focus to the last tab opened.
+ final var index = tabs.size() - 1;
+ if( index >= 0 ) {
+ final var tab = tabs.get( index );
+ tabPane.getSelectionModel().select( tab );
+ tab.getContent().requestFocus();
+ }
+ }
+ }
+ }
+ );
+ }
+
+ /**
+ * Responsible for handling tab change events.
+ *
+ * @param tabPane A new {@link DetachableTabPane} to configure.
+ */
+ private void initSelectionModelListener( final DetachableTabPane tabPane ) {
+ final var model = tabPane.getSelectionModel();
+
+ model.selectedItemProperty().addListener( ( c, o, n ) -> {
+ if( o != null && n == null ) {
+ final var node = o.getContent();
+
+ // If the last definition editor in the active pane was closed,
+ // clear out the definitions then refresh the text editor.
+ if( node instanceof TextDefinition ) {
+ mActiveDefinitionEditor.set( createDefinitionEditor() );
+ }
+ }
+ else if( n != null ) {
+ final var node = n.getContent();
+
+ if( node instanceof TextEditor ) {
+ // Changing the active node will fire an event, which will
+ // update the preview panel and grab focus.
+ mActiveTextEditor.set( (TextEditor) node );
+ runLater( node::requestFocus );
+ }
+ else if( node instanceof TextDefinition ) {
+ mActiveDefinitionEditor.set( (DefinitionEditor) node );
+ }
+ }
+ } );
+ }
+
+ /**
+ * Synchronizes scrollbar positions between the given {@link Tab} that
+ * contains an instance of {@link TextEditor} and {@link HtmlPreview} pane.
+ *
+ * @param tab The container for an instance of {@link TextEditor}.
+ */
+ private void initScrollEventListener( final Tab tab ) {
+ final var editor = (TextEditor) tab.getContent();
+ final var scrollPane = editor.getScrollPane();
+ final var scrollBar = mHtmlPreview.getVerticalScrollBar();
+ final var handler = new ScrollEventHandler( scrollPane, scrollBar );
+ handler.enabledProperty().bind( tab.selectedProperty() );
+ }
+
+ private void addTabPane( final int index, final DetachableTabPane tabPane ) {
+ getItems().add( index, tabPane );
+ }
+
+ private void addTabPane( final DetachableTabPane tabPane ) {
+ addTabPane( getItems().size(), tabPane );
+ }
+
+ /**
+ * @param path Used by {@link ProcessorFactory} to determine
+ * {@link Processor} type to create based on file type.
+ * @param caret Used by {@link CaretExtension} to add ID attribute into
+ * preview document for scrollbar synchronization.
+ * @return A new {@link ProcessorContext} to use when creating an instance of
+ * {@link Processor}.
+ */
+ private ProcessorContext createProcessorContext(
+ final Path path, final Caret caret ) {
+ return new ProcessorContext(
+ mHtmlPreview, mResolvedMap, path, caret, NONE, mWorkspace
+ );
+ }
+
+ public ProcessorContext createProcessorContext( final TextEditor t ) {
+ return createProcessorContext( t.getPath(), t.getCaret() );
+ }
+
+ @SuppressWarnings({"RedundantCast", "unchecked", "RedundantSuppression"})
+ private TextResource createTextResource( final File file ) {
+ final var mediaType = new com.keenwrite.io.File( file ).getMediaType();
+
+ // TODO: Create PlainTextEditor that's returned by default.
+ return switch( mediaType ) {
+ case TEXT_MARKDOWN -> createMarkdownEditor( file );
+ case TEXT_YAML -> createDefinitionEditor( file );
+ default -> createMarkdownEditor( file );
+ };
+ }
+
+ /**
+ * 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 file The file containing contents for the text editor.
+ * @return A non-null text editor.
+ */
+ private TextResource createMarkdownEditor( final File file ) {
+ final var path = file.toPath();
+ final var editor = new MarkdownEditor( file );
+ final var caret = editor.getCaret();
+ final var context = createProcessorContext( path, caret );
+
+ mProcessors.computeIfAbsent( editor, p -> createProcessors( context ) );
+
+ editor.addDirtyListener( ( c, o, n ) -> {
+ if( n ) {
+ process( getActiveTextEditor() );
+ }
+ } );
+
+ editor.addEventListener(
+ keyPressed( SPACE, CONTROL_DOWN ), this::autoinsert
+ );
+
+ // Set the active editor, which refreshes the preview panel.
+ mActiveTextEditor.set( editor );
+
+ return editor;
+ }
+
+ /**
+ * Delegates to {@link #autoinsert()}.
+ *
+ * @param event Ignored.
+ */
+ private void autoinsert( final KeyEvent event ) {
+ autoinsert();
+ }
+
+ /**
+ * Finds a node that matches the word at the caret, then inserts the
+ * corresponding definition. The definition token delimiters depend on
+ * the type of file being edited.
+ */
+ public void autoinsert() {
+ final var definitions = getActiveTextDefinition();
+ final var editor = getActiveTextEditor();
+ final var mediaType = editor.getMediaType();
+ final var operator = getSigilOperator( mediaType );
+
+ DefinitionNameInjector.autoinsert( editor, definitions, operator );
+ }
+
+ private TextDefinition createDefinitionEditor() {
+ return createDefinitionEditor( DEFINITION_DEFAULT );
+ }
+
+ private TextDefinition createDefinitionEditor( final File file ) {
+ final var transformer = createTreeTransformer();
+ final var editor = new DefinitionEditor( file, transformer );
+
+ editor.addTreeChangeHandler( mTreeHandler );
+
+ return editor;
+ }
+
+ private TreeTransformer createTreeTransformer() {
+ return new YamlTreeTransformer();
+ }
+
+ private Tooltip createTooltip( final File file ) {
+ final var path = file.toPath();
+ final var tooltip = new Tooltip( path.toString() );
+
+ tooltip.setShowDelay( millis( 200 ) );
+ return tooltip;
+ }
+
+ public TextEditor getActiveTextEditor() {
+ return mActiveTextEditor.get();
+ }
+
+ public ReadOnlyObjectProperty<TextEditor> activeTextEditorProperty() {
+ return mActiveTextEditor;
+ }
+
+ public TextDefinition getActiveTextDefinition() {
+ return mActiveDefinitionEditor.get();
+ }
+
+ public Window getWindow() {
+ return getScene().getWindow();
+ }
+
+ public Workspace getWorkspace() {
+ return mWorkspace;
+ }
+
+ /**
+ * Returns the sigil operator for the given {@link MediaType}.
+ *
+ * @param mediaType The type of file being edited.
+ */
+ private SigilOperator getSigilOperator( final MediaType mediaType ) {
+ final var operator = new YamlSigilOperator( createDefinitionTokens() );
+
+ return switch( mediaType ) {
+ case APP_R_MARKDOWN, APP_R_XML -> new RSigilOperator(
+ createRTokens(), operator );
+ default -> operator;
+ };
+ }
+
+ /**
+ * Returns the set of filenames opened in the application. The names must
+ * be converted to {@link File} objects.
+ *
+ * @return A {@link Set} of filenames.
+ */
+ private SetProperty<Object> getRecentFiles() {
+ return getWorkspace().setsProperty( KEY_UI_FILES_PATH );
+ }
+
+ private StringProperty stringProperty( final Key key ) {
+ return getWorkspace().stringProperty( key );
+ }
+
+ private Tokens createRTokens() {
+ return createTokens( KEY_R_DELIM_BEGAN, KEY_R_DELIM_ENDED );
+ }
+
+ private Tokens createDefinitionTokens() {
+ return createTokens( KEY_DEF_DELIM_BEGAN, KEY_DEF_DELIM_ENDED );
+ }
+
+ private Tokens createTokens( final Key began, final Key ended ) {
+ return new Tokens( stringProperty( began ), stringProperty( ended ) );
}
}
src/main/java/com/keenwrite/editors/TextDefinition.java
import com.keenwrite.editors.definition.DefinitionTreeItem;
import com.keenwrite.editors.markdown.MarkdownEditor;
+import com.keenwrite.sigils.Tokens;
import javafx.scene.control.TreeItem;
*/
Map<String, String> toMap();
+
+ /**
+ * Performs string interpolation on the values in the given map. This will
+ * change any value in the map that contains a variable that matches
+ * the definition regex pattern against the given {@link Tokens}.
+ *
+ * @param map Contains values that represent references to keys.
+ * @param tokens The beginning and ending tokens that delimit variables.
+ */
+ Map<String, String> interpolate( Map<String, String> map, Tokens tokens );
/**
src/main/java/com/keenwrite/editors/definition/DefinitionEditor.java
import com.keenwrite.Constants;
import com.keenwrite.editors.TextDefinition;
-import de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon;
-import de.jensd.fx.glyphs.fontawesome.utils.FontAwesomeIconFactory;
-import javafx.beans.property.BooleanProperty;
-import javafx.beans.property.ReadOnlyBooleanProperty;
-import javafx.beans.property.SimpleBooleanProperty;
-import javafx.collections.ObservableList;
-import javafx.event.ActionEvent;
-import javafx.event.Event;
-import javafx.event.EventHandler;
-import javafx.scene.Node;
-import javafx.scene.control.*;
-import javafx.scene.input.KeyEvent;
-import javafx.scene.layout.BorderPane;
-import javafx.scene.layout.HBox;
-
-import java.io.File;
-import java.nio.charset.Charset;
-import java.util.*;
-
-import static com.keenwrite.Constants.DEFINITION_DEFAULT;
-import static com.keenwrite.Messages.get;
-import static com.keenwrite.StatusBarNotifier.clue;
-import static javafx.geometry.Pos.CENTER;
-import static javafx.geometry.Pos.TOP_CENTER;
-import static javafx.scene.control.SelectionMode.MULTIPLE;
-import static javafx.scene.control.TreeItem.childrenModificationEvent;
-import static javafx.scene.control.TreeItem.valueChangedEvent;
-import static javafx.scene.input.KeyEvent.KEY_PRESSED;
-
-/**
- * Provides the user interface that holds a {@link TreeView}, which
- * allows users to interact with key/value pairs loaded from the
- * document parser and adapted using a {@link TreeTransformer}.
- */
-public final class DefinitionEditor extends BorderPane implements
- TextDefinition {
-
- /**
- * Contains the root that is added to the view.
- */
- private final DefinitionTreeItem<String> mTreeRoot = createRootTreeItem();
-
- /**
- * Contains a view of the definitions.
- */
- private final TreeView<String> mTreeView = new TreeView<>( mTreeRoot );
-
- /**
- * Used to adapt the structured document into a {@link TreeView}.
- */
- private final TreeTransformer mTreeTransformer;
-
- /**
- * Handlers for key press events.
- */
- private final Set<EventHandler<? super KeyEvent>> mKeyEventHandlers
- = new HashSet<>();
-
- /**
- * File being edited by this editor instance.
- */
- private File mFile;
-
- /**
- * Opened file's character encoding, or {@link Constants#DEFAULT_CHARSET} if
- * either no encoding could be determined or this is a new (empty) file.
- */
- private final Charset mEncoding;
-
- /**
- * Tracks whether the in-memory definitions have changed with respect to the
- * persisted definitions.
- */
- private final BooleanProperty mModified = new SimpleBooleanProperty();
-
- /**
- * This is provided for unit tests that are not backed by files.
- *
- * @param treeTransformer The
- */
- public DefinitionEditor( final TreeTransformer treeTransformer ) {
- this( DEFINITION_DEFAULT, treeTransformer );
- }
-
- /**
- * Constructs a definition pane with a given tree view root.
- *
- * @param file The file to
- */
- public DefinitionEditor(
- final File file, final TreeTransformer treeTransformer ) {
- assert file != null;
- assert treeTransformer != null;
-
- mFile = file;
- mTreeTransformer = treeTransformer;
-
- mTreeView.setEditable( true );
- mTreeView.setCellFactory( new TreeCellFactory() );
- mTreeView.setContextMenu( createContextMenu() );
- mTreeView.addEventFilter( KEY_PRESSED, this::keyEventFilter );
- mTreeView.setShowRoot( false );
- getSelectionModel().setSelectionMode( MULTIPLE );
-
- 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( 10 );
-
- setTop( buttonBar );
- setCenter( mTreeView );
- setAlignment( buttonBar, TOP_CENTER );
- addTreeChangeHandler( event -> mModified.set( true ) );
- mEncoding = open( mFile );
- }
-
- @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 {
- final var root = getTreeView().getRoot();
- final var problem = isTreeWellFormed();
-
- problem.ifPresentOrElse(
- ( node ) -> clue( "yaml.error.tree.form", node ),
- () -> result.append( mTreeTransformer.transform( root ) )
- );
- } 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 = "App.action.definition." + msgKey;
- final var button = new Button( get( keyPrefix + ".text" ) );
- final var icon = get( keyPrefix + ".icon" );
- final var glyph = FontAwesomeIcon.valueOf( icon.toUpperCase() );
-
- button.setOnAction( eventHandler );
- button.setGraphic(
- FontAwesomeIconFactory.get().createIcon( glyph )
- );
- button.setTooltip( new Tooltip( get( keyPrefix + ".tooltip" ) ) );
-
- return button;
- }
-
- public Map<String, String> toMap() {
- return TreeItemMapper.toMap( getTreeView().getRoot() );
- }
-
- /**
- * 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 );
- }
-
- public void addKeyEventHandler(
- final EventHandler<? super KeyEvent> handler ) {
- getKeyEventHandlers().add( 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 sub-tree 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 );
- }
-
- /**
- * 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, "App.action.definition.create.text" )
- .setOnAction( e -> createDefinition() );
- addMenuItem( items, "App.action.definition.rename.text" )
- .setOnAction( e -> renameDefinition() );
- addMenuItem( items, "App.action.definition.delete.text" )
- .setOnAction( e -> deleteSelectedItem() );
-
- 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();
- }
- }
- }
-
- for( final var handler : getKeyEventHandlers() ) {
- handler.handle( event );
- }
- }
- }
-
- /**
- * 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() {
- super.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 ) {
+import com.keenwrite.sigils.Tokens;
+import de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon;
+import de.jensd.fx.glyphs.fontawesome.utils.FontAwesomeIconFactory;
+import javafx.beans.property.BooleanProperty;
+import javafx.beans.property.ReadOnlyBooleanProperty;
+import javafx.beans.property.SimpleBooleanProperty;
+import javafx.collections.ObservableList;
+import javafx.event.ActionEvent;
+import javafx.event.Event;
+import javafx.event.EventHandler;
+import javafx.scene.Node;
+import javafx.scene.control.*;
+import javafx.scene.input.KeyEvent;
+import javafx.scene.layout.BorderPane;
+import javafx.scene.layout.HBox;
+
+import java.io.File;
+import java.nio.charset.Charset;
+import java.util.*;
+import java.util.regex.Pattern;
+
+import static com.keenwrite.Constants.DEFINITION_DEFAULT;
+import static com.keenwrite.Messages.get;
+import static com.keenwrite.StatusBarNotifier.clue;
+import static java.lang.String.format;
+import static java.util.regex.Pattern.compile;
+import static java.util.regex.Pattern.quote;
+import static javafx.geometry.Pos.CENTER;
+import static javafx.geometry.Pos.TOP_CENTER;
+import static javafx.scene.control.SelectionMode.MULTIPLE;
+import static javafx.scene.control.TreeItem.childrenModificationEvent;
+import static javafx.scene.control.TreeItem.valueChangedEvent;
+import static javafx.scene.input.KeyEvent.KEY_PRESSED;
+
+/**
+ * Provides the user interface that holds a {@link TreeView}, which
+ * allows users to interact with key/value pairs loaded from the
+ * document parser and adapted using a {@link TreeTransformer}.
+ */
+public final class DefinitionEditor extends BorderPane
+ implements TextDefinition {
+ private static final int GROUP_DELIMITED = 1;
+
+ /**
+ * Contains the root that is added to the view.
+ */
+ private final DefinitionTreeItem<String> mTreeRoot = createRootTreeItem();
+
+ /**
+ * Contains a view of the definitions.
+ */
+ private final TreeView<String> mTreeView = new TreeView<>( mTreeRoot );
+
+ /**
+ * Used to adapt the structured document into a {@link TreeView}.
+ */
+ private final TreeTransformer mTreeTransformer;
+
+ /**
+ * Handlers for key press events.
+ */
+ private final Set<EventHandler<? super KeyEvent>> mKeyEventHandlers
+ = new HashSet<>();
+
+ /**
+ * File being edited by this editor instance.
+ */
+ private File mFile;
+
+ /**
+ * Opened file's character encoding, or {@link Constants#DEFAULT_CHARSET} if
+ * either no encoding could be determined or this is a new (empty) file.
+ */
+ private final Charset mEncoding;
+
+ /**
+ * Tracks whether the in-memory definitions have changed with respect to the
+ * persisted definitions.
+ */
+ private final BooleanProperty mModified = new SimpleBooleanProperty();
+
+ /**
+ * This is provided for unit tests that are not backed by files.
+ *
+ * @param treeTransformer Responsible for transforming the definitions into
+ * {@link TreeItem} instances.
+ */
+ public DefinitionEditor(
+ final TreeTransformer treeTransformer ) {
+ this( DEFINITION_DEFAULT, treeTransformer );
+ }
+
+ /**
+ * Constructs a definition pane with a given tree view root.
+ *
+ * @param file The file to
+ */
+ public DefinitionEditor(
+ final File file,
+ final TreeTransformer treeTransformer ) {
+ assert file != null;
+ assert treeTransformer != null;
+
+ mFile = file;
+ mTreeTransformer = treeTransformer;
+
+ mTreeView.setEditable( true );
+ mTreeView.setCellFactory( new TreeCellFactory() );
+ mTreeView.setContextMenu( createContextMenu() );
+ mTreeView.addEventFilter( KEY_PRESSED, this::keyEventFilter );
+ mTreeView.setShowRoot( false );
+ getSelectionModel().setSelectionMode( MULTIPLE );
+
+ 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( 10 );
+
+ setTop( buttonBar );
+ setCenter( mTreeView );
+ setAlignment( buttonBar, TOP_CENTER );
+ addTreeChangeHandler( event -> mModified.set( true ) );
+ mEncoding = open( mFile );
+ }
+
+ @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 {
+ final var root = getTreeView().getRoot();
+ final var problem = isTreeWellFormed();
+
+ problem.ifPresentOrElse(
+ ( node ) -> clue( "yaml.error.tree.form", node ),
+ () -> result.append( mTreeTransformer.transform( root ) )
+ );
+ } 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 = "App.action.definition." + msgKey;
+ final var button = new Button( get( keyPrefix + ".text" ) );
+ final var icon = get( keyPrefix + ".icon" );
+ final var glyph = FontAwesomeIcon.valueOf( icon.toUpperCase() );
+
+ button.setOnAction( eventHandler );
+ button.setGraphic(
+ FontAwesomeIconFactory.get().createIcon( glyph )
+ );
+ button.setTooltip( new Tooltip( get( keyPrefix + ".tooltip" ) ) );
+
+ return button;
+ }
+
+ @Override
+ public Map<String, String> toMap() {
+ return new TreeItemMapper().toMap( getTreeView().getRoot() );
+ }
+
+ @Override
+ public Map<String, String> interpolate(
+ final Map<String, String> map, final Tokens tokens ) {
+
+ // Non-greedy match of key names delimited by definition tokens.
+ final var pattern = compile(
+ format( "(%s.*?%s)",
+ quote( tokens.getBegan() ),
+ quote( tokens.getEnded() )
+ )
+ );
+
+ map.replaceAll( ( k, v ) -> resolve( map, v, pattern ) );
+ return map;
+ }
+
+ /**
+ * Given a value with zero or more key references, this will resolve all
+ * the values, recursively. If a key cannot be de-referenced, the value will
+ * contain the key name.
+ *
+ * @param map Map to search for keys when resolving key references.
+ * @param value Value containing zero or more key references.
+ * @param pattern The regular expression pattern to match variable key names.
+ * @return The given value with all embedded key references interpolated.
+ */
+ private String resolve(
+ final Map<String, String> map, String value, final Pattern pattern ) {
+ final var matcher = pattern.matcher( value );
+
+ while( matcher.find() ) {
+ final var keyName = matcher.group( GROUP_DELIMITED );
+ final var mapValue = map.get( keyName );
+ final var keyValue = mapValue == null
+ ? keyName
+ : resolve( map, mapValue, pattern );
+
+ value = value.replace( keyName, keyValue );
+ }
+
+ return value;
+ }
+
+
+ /**
+ * 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 );
+ }
+
+ public void addKeyEventHandler(
+ final EventHandler<? super KeyEvent> handler ) {
+ getKeyEventHandlers().add( 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 sub-tree 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 );
+ }
+
+ /**
+ * 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, "App.action.definition.create.text" )
+ .setOnAction( e -> createDefinition() );
+ addMenuItem( items, "App.action.definition.rename.text" )
+ .setOnAction( e -> renameDefinition() );
+ addMenuItem( items, "App.action.definition.delete.text" )
+ .setOnAction( e -> deleteSelectedItem() );
+
+ 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();
+ }
+ }
+ }
+
+ for( final var handler : getKeyEventHandlers() ) {
+ handler.handle( event );
+ }
+ }
+ }
+
+ /**
+ * 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() {
+ super.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();
src/main/java/com/keenwrite/editors/definition/DefinitionTreeItem.java
*/
public DefinitionTreeItem<T> findLeaf(
- final String text,
- final BiFunction<DefinitionTreeItem<T>, String, Boolean> findMode ) {
+ final String text,
+ final BiFunction<DefinitionTreeItem<T>, String, Boolean> findMode ) {
final var stack = new Stack<DefinitionTreeItem<T>>();
stack.push( this );
private String getDiacriticlessValue() {
return normalize( getValue().toString(), NFD )
- .replaceAll( "\\p{M}", "" );
+ .replaceAll( "\\p{M}", "" );
}
private boolean valueContainsNoCase( final String s ) {
return isLeaf() &&
- getDiacriticlessValue().toLowerCase().contains( s.toLowerCase() );
+ getDiacriticlessValue().toLowerCase().contains( s.toLowerCase() );
}
*/
public String toPath() {
- return TreeItemMapper.toPath( getParent() );
+ return new TreeItemMapper().toPath( getParent() );
}
src/main/java/com/keenwrite/editors/definition/MapInterpolator.java
-/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
-package com.keenwrite.editors.definition;
-
-import com.keenwrite.sigils.YamlSigilOperator;
-
-import java.util.Map;
-import java.util.regex.Matcher;
-
-import static com.keenwrite.sigils.YamlSigilOperator.REGEX_PATTERN;
-
-/**
- * Responsible for performing string interpolation on key/value pairs stored
- * in a map. The values in the map can use a delimited syntax to refer to
- * keys in the map.
- */
-public final class MapInterpolator {
- private static final int GROUP_DELIMITED = 1;
-
- /**
- * Prevent instantiation.
- */
- private MapInterpolator() {
- }
-
- /**
- * Performs string interpolation on the values in the given map. This will
- * change any value in the map that contains a variable that matches
- * {@link YamlSigilOperator#REGEX_PATTERN}.
- *
- * @param map Contains values that represent references to keys.
- */
- public static Map<String, String> interpolate(
- final Map<String, String> map ) {
- map.replaceAll( ( k, v ) -> resolve( map, v ) );
- return map;
- }
-
- /**
- * Given a value with zero or more key references, this will resolve all
- * the values, recursively. If a key cannot be dereferenced, the value will
- * contain the key name.
- *
- * @param map Map to search for keys when resolving key references.
- * @param value Value containing zero or more key references
- * @return The given value with all embedded key references interpolated.
- */
- private static String resolve(
- final Map<String, String> map, String value ) {
- final Matcher matcher = REGEX_PATTERN.matcher( value );
-
- while( matcher.find() ) {
- final String keyName = matcher.group( GROUP_DELIMITED );
- final String mapValue = map.get( keyName );
- final String keyValue = mapValue == null
- ? keyName
- : resolve( map, mapValue );
-
- value = value.replace( keyName, keyValue );
- }
-
- return value;
- }
-}
src/main/java/com/keenwrite/editors/definition/TreeItemMapper.java
import com.fasterxml.jackson.databind.JsonNode;
-import com.keenwrite.sigils.YamlSigilOperator;
import com.keenwrite.preview.HtmlPreview;
import javafx.scene.control.TreeItem;
public class TreeItemMapper {
/**
- * Separates YAML definition keys (e.g., the dots in {@code $root.node.var$}).
+ * Separates definition keys (e.g., the dots in {@code $root.node.var$}).
*/
public static final String SEPARATOR = ".";
*/
private static final class TreeIterator
- implements Iterator<TreeItem<String>> {
+ implements Iterator<TreeItem<String>> {
private final Stack<TreeItem<String>> mStack = new Stack<>();
}
- /**
- * Prevent direct instantiation.
- */
- private TreeItemMapper() {
+ public TreeItemMapper() {
}
/**
* Iterate over a given root node (at any level of the tree) and process each
* leaf node into a flat map. Values must be interpolated separately.
*/
- public static Map<String, String> toMap( final TreeItem<String> root ) {
- final Map<String, String> map = new HashMap<>( MAP_SIZE_DEFAULT );
- final TreeIterator iterator = new TreeIterator( root );
+ public Map<String, String> toMap( final TreeItem<String> root ) {
+ final var map = new HashMap<String, String>( MAP_SIZE_DEFAULT );
+ final var iterator = new TreeIterator( root );
iterator.forEachRemaining( item -> {
if( item.isLeaf() ) {
map.put( toPath( item.getParent() ), item.getValue() );
}
} );
return map;
}
-
/**
* For a given node, this will ascend the tree to generate a key name
* that is associated with the leaf node's value.
*
* @param node Ascendants represent the key to this node's value.
* @param <T> Data type that the {@link TreeItem} contains.
* @return The string representation of the node's unique key.
*/
- public static <T> String toPath( TreeItem<T> node ) {
+ public <T> String toPath( TreeItem<T> node ) {
assert node != null;
- final StringBuilder key = new StringBuilder( DEFAULT_KEY_LENGTH );
- final Stack<TreeItem<T>> stack = new Stack<>();
+ final var key = new StringBuilder( DEFAULT_KEY_LENGTH );
+ final var stack = new Stack<TreeItem<T>>();
while( node != null && !(node instanceof RootTreeItem) ) {
stack.push( node );
node = node.getParent();
}
// Gets set at end of first iteration (to avoid an if condition).
- String separator = "";
+ var separator = "";
while( !stack.empty() ) {
final T subkey = stack.pop().getValue();
key.append( separator );
key.append( subkey );
separator = SEPARATOR;
}
- return YamlSigilOperator.entoken( key.toString() );
+ return key.toString();
}
}
src/main/java/com/keenwrite/preferences/UserPreferences.java
-/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
-package com.keenwrite.preferences;
-
-import com.dlsc.preferencesfx.PreferencesFx;
-import javafx.beans.property.ObjectProperty;
-import javafx.beans.property.SimpleObjectProperty;
-import javafx.beans.property.SimpleStringProperty;
-import javafx.beans.property.StringProperty;
-
-import java.io.File;
-
-import static com.keenwrite.Constants.*;
-
-/**
- * Responsible for user preferences that can be changed from the GUI. The
- * settings are displayed and persisted using {@link PreferencesFx}.
- */
-public final class UserPreferences {
- /**
- * Implementation of the initialization-on-demand holder design pattern
- * for a lazily-loaded singleton. In all versions of Java, the idiom enables
- * a safe, highly concurrent lazy initialization of static fields with good
- * performance. The implementation relies upon the initialization phase of
- * execution within the Java Virtual Machine (JVM) as specified by the Java
- * Language Specification.
- */
- private static class Container {
- private static final UserPreferences INSTANCE = new UserPreferences();
- }
-
- /**
- * Returns the singleton instance for user preferences.
- *
- * @return A non-null instance, loaded, configured, and ready to persist.
- */
- public static UserPreferences getInstance() {
- return Container.INSTANCE;
- }
-
- private final ObjectProperty<File> mPropDefinitionPath;
- private final StringProperty mPropRDelimBegan;
- private final StringProperty mPropRDelimEnded;
- private final StringProperty mPropDefDelimBegan;
- private final StringProperty mPropDefDelimEnded;
-
- private UserPreferences() {
- mPropDefinitionPath = new SimpleObjectProperty<>( DEFINITION_DEFAULT );
- mPropDefDelimBegan = new SimpleStringProperty( DEF_DELIM_BEGAN_DEFAULT );
- mPropDefDelimEnded = new SimpleStringProperty( DEF_DELIM_ENDED_DEFAULT );
-
- mPropRDelimBegan = new SimpleStringProperty( R_DELIM_BEGAN_DEFAULT );
- mPropRDelimEnded = new SimpleStringProperty( R_DELIM_ENDED_DEFAULT );
- }
-
- public ObjectProperty<File> definitionPathProperty() {
- return mPropDefinitionPath;
- }
-
- public StringProperty defDelimiterBeganProperty() {
- return mPropDefDelimBegan;
- }
-
- public String getDefDelimiterBegan() {
- return defDelimiterBeganProperty().get();
- }
-
- public StringProperty defDelimiterEndedProperty() {
- return mPropDefDelimEnded;
- }
-
- public String getDefDelimiterEnded() {
- return defDelimiterEndedProperty().get();
- }
-
- public StringProperty rDelimiterBeganProperty() {
- return mPropRDelimBegan;
- }
-
- public StringProperty rDelimiterEndedProperty() {
- return mPropRDelimEnded;
- }
-}
src/main/java/com/keenwrite/preferences/UserPreferencesView.java
/**
- * Responsible for configuring the {@link UserPreferences} user interface.
+ * Provides the ability for users to configure their preferences.
*/
public class UserPreferencesView {
src/main/java/com/keenwrite/preferences/Workspace.java
import com.keenwrite.Constants;
+import com.keenwrite.sigils.Tokens;
import javafx.application.Platform;
import javafx.beans.property.*;
public String toString( final Key key ) {
return stringProperty( key ).get();
+ }
+
+ public Tokens toTokens( final Key began, final Key ended ) {
+ return new Tokens( stringProperty( began ), stringProperty( ended ) );
}
src/main/java/com/keenwrite/processors/RVariableProcessor.java
package com.keenwrite.processors;
+import com.keenwrite.preferences.Workspace;
import com.keenwrite.sigils.RSigilOperator;
+import com.keenwrite.sigils.SigilOperator;
+import com.keenwrite.sigils.YamlSigilOperator;
import java.util.HashMap;
import java.util.Map;
+
+import static com.keenwrite.preferences.Workspace.*;
/**
* Converts the keys of the resolved map from default form to R form, then
* performs a substitution on the text. The default R variable syntax is
* {@code v$tree$leaf}.
*/
public class RVariableProcessor extends DefinitionProcessor {
+
+ private final SigilOperator mSigilOperator;
public RVariableProcessor(
- final InlineRProcessor irp, final ProcessorContext context ) {
+ final InlineRProcessor irp, final ProcessorContext context ) {
super( irp, context );
+ mSigilOperator = createSigilOperator( context.getWorkspace() );
}
for( final var entry : map.entrySet() ) {
final var key = entry.getKey();
- rMap.put( RSigilOperator.entoken( key ), toRValue( map.get( key ) ) );
+ rMap.put( mSigilOperator.entoken( key ), toRValue( map.get( key ) ) );
}
@SuppressWarnings("SameParameterValue")
private String escape(
- final String haystack, final char needle, final String thread ) {
+ final String haystack, final char needle, final String thread ) {
int end = haystack.indexOf( needle );
return sb.append( haystack.substring( start ) ).toString();
+ }
+
+ private SigilOperator createSigilOperator( final Workspace workspace ) {
+ final var tokens = workspace.toTokens(
+ KEY_R_DELIM_BEGAN, KEY_R_DELIM_ENDED );
+ final var antecedent = createDefinitionOperator( workspace );
+ return new RSigilOperator( tokens, antecedent );
+ }
+
+ private SigilOperator createDefinitionOperator(
+ final Workspace workspace ) {
+ final var tokens = workspace.toTokens(
+ KEY_DEF_DELIM_BEGAN, KEY_DEF_DELIM_ENDED );
+ return new YamlSigilOperator( tokens );
}
}
src/main/java/com/keenwrite/processors/markdown/Caret.java
/**
- * Represents the absolute, relative, and maximum position of the caret.
- * The caret position is a character offset into the text.
+ * Represents the absolute, relative, and maximum position of the caret. The
+ * caret position is a character offset into the text.
*/
public class Caret {
src/main/java/com/keenwrite/processors/markdown/MarkdownProcessor.java
super( successor );
- extensions.add( LigatureExtension.create() );
-
mParser = Parser.builder().extensions( extensions ).build();
mRenderer = HtmlRenderer.builder().extensions( extensions ).build();
extensions.add( TablesExtension.create() );
extensions.add( TypographicExtension.create() );
+ extensions.add( LigatureExtension.create() );
return extensions;
}
src/main/java/com/keenwrite/sigils/RSigilOperator.java
package com.keenwrite.sigils;
-import javafx.beans.property.StringProperty;
-
import static com.keenwrite.sigils.YamlSigilOperator.KEY_SEPARATOR_DEF;
public static final char SUFFIX = '`';
- private final StringProperty mDelimiterBegan =
- getUserPreferences().rDelimiterBeganProperty();
- private final StringProperty mDelimiterEnded =
- getUserPreferences().rDelimiterEndedProperty();
+ /**
+ * Definition variables are inserted into the document before R variables,
+ * so this is required to reformat the definition variable suitable for R.
+ */
+ private final SigilOperator mAntecedent;
+
+ public RSigilOperator( final Tokens tokens, final SigilOperator antecedent ) {
+ super( tokens );
+ mAntecedent = antecedent;
+ }
/**
* Returns the given string R-escaping backticks prepended and appended. This
* is not null safe. Do not pass null into this method.
*
* @param key The string to adorn with R token delimiters.
- * @return "`r#" + delimiterBegan + variableName+ delimiterEnded + "`".
+ * @return PREFIX + delimiterBegan + variableName+ delimiterEnded + SUFFIX.
*/
@Override
public String apply( final String key ) {
assert key != null;
-
- return PREFIX
- + mDelimiterBegan.getValue()
- + entoken( key )
- + mDelimiterEnded.getValue()
- + SUFFIX;
+ return PREFIX + getBegan() + entoken( key ) + getEnded() + SUFFIX;
}
/**
* Transforms a definition key (bracketed by token delimiters) into the
* expected format for an R variable key name.
*
* @param key The variable name to transform, can be empty but not null.
* @return The transformed variable name.
*/
- public static String entoken( final String key ) {
- return "v$" +
- YamlSigilOperator.detoken( key )
- .replace( KEY_SEPARATOR_DEF, KEY_SEPARATOR_R );
+ public String entoken( final String key ) {
+ return "v$" + mAntecedent.detoken( key )
+ .replace( KEY_SEPARATOR_DEF, KEY_SEPARATOR_R );
}
}
src/main/java/com/keenwrite/sigils/SigilOperator.java
package com.keenwrite.sigils;
-import com.keenwrite.preferences.UserPreferences;
-
import java.util.function.UnaryOperator;
/**
* Responsible for updating definition keys to use a machine-readable format
* corresponding to the type of file being edited. This changes a definition
* key name based on some criteria determined by the factory that creates
* implementations of this interface.
*/
public abstract class SigilOperator implements UnaryOperator<String> {
- protected static UserPreferences getUserPreferences() {
- return UserPreferences.getInstance();
+ private final Tokens mTokens;
+
+ SigilOperator( final Tokens tokens ) {
+ mTokens = tokens;
+ }
+
+ /**
+ * Removes start and stop definition key delimiters from the given key. This
+ * method does not check for delimiters, only that there are sufficient
+ * characters to remove from either end of the given key.
+ *
+ * @param key The key adorned with start and stop tokens.
+ * @return The given key with the delimiters removed.
+ */
+ String detoken( final String key ) {
+ return key;
+ }
+
+ String getBegan() {
+ return mTokens.getBegan();
+ }
+
+ String getEnded() {
+ return mTokens.getEnded();
}
+
+ /**
+ * Wraps the given key in the began and ended tokens. This may perform any
+ * preprocessing necessary to ensure the transformation happens.
+ *
+ * @param key The variable name to transform.
+ * @return The given key with tokens to delimit it (from the edited text).
+ */
+ public abstract String entoken( final String key );
}
src/main/java/com/keenwrite/sigils/Tokens.java
+/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
+package com.keenwrite.sigils;
+
+import javafx.beans.property.StringProperty;
+
+import java.util.AbstractMap.SimpleImmutableEntry;
+
+/**
+ * Convenience class for pairing a start and end sigil together.
+ */
+public final class Tokens
+ extends SimpleImmutableEntry<StringProperty, StringProperty> {
+
+ /**
+ * Associates a new key-value pair.
+ *
+ * @param began The starting sigil.
+ * @param ended The ending sigil.
+ */
+ public Tokens( final StringProperty began, final StringProperty ended ) {
+ super( began, ended );
+ }
+
+ /**
+ * @return The opening sigil token.
+ */
+ public String getBegan() {
+ return getKey().get();
+ }
+
+ /**
+ * @return The closing sigil token, or the empty string if none set.
+ */
+ public String getEnded() {
+ return getValue().get();
+ }
+}
src/main/java/com/keenwrite/sigils/YamlSigilOperator.java
package com.keenwrite.sigils;
-import java.util.regex.Pattern;
-
-import static java.lang.String.format;
-import static java.util.regex.Pattern.compile;
-import static java.util.regex.Pattern.quote;
-
/**
* Brackets definition keys with token delimiters.
*/
public class YamlSigilOperator extends SigilOperator {
public static final char KEY_SEPARATOR_DEF = '.';
-
- private static final String mDelimiterBegan =
- getUserPreferences().getDefDelimiterBegan();
- private static final String mDelimiterEnded =
- getUserPreferences().getDefDelimiterEnded();
-
- /**
- * Non-greedy match of key names delimited by definition tokens.
- */
- private static final String REGEX =
- format( "(%s.*?%s)", quote( mDelimiterBegan ), quote( mDelimiterEnded ) );
- /**
- * Compiled regular expression for matching delimited references.
- */
- public static final Pattern REGEX_PATTERN = compile( REGEX );
+ public YamlSigilOperator( final Tokens tokens ) {
+ super( tokens );
+ }
/**
* @return The given key bracketed by definition token symbols.
*/
- public static String entoken( final String key ) {
+ public String entoken( final String key ) {
assert key != null;
- return mDelimiterBegan + key + mDelimiterEnded;
+ return getBegan() + key + getEnded();
}
* @return The given key with the delimiters removed.
*/
- public static String detoken( final String key ) {
- final int beganLen = mDelimiterBegan.length();
- final int endedLen = mDelimiterEnded.length();
+ public String detoken( final String key ) {
+ final int beganLen = getBegan().length();
+ final int endedLen = getEnded().length();
return key.length() > beganLen + endedLen
- ? key.substring( beganLen, key.length() - endedLen )
- : key;
+ ? key.substring( beganLen, key.length() - endedLen )
+ : key;
}
}
src/test/java/com/keenwrite/definition/TreeViewTest.java
import javafx.scene.control.TreeItem;
import javafx.stage.Stage;
-import org.junit.jupiter.api.Disabled;
import org.testfx.framework.junit5.Start;
import static com.keenwrite.util.FontLoader.initFonts;
-import static java.lang.Thread.sleep;
//@ExtendWith(ApplicationExtension.class)
public class TreeViewTest extends Application {
private final SimpleObjectProperty<Node> mTextEditor =
- new SimpleObjectProperty<>();
+ new SimpleObjectProperty<>();
private final EventHandler<TreeItem.TreeModificationEvent<Event>> mTreeHandler =
- event -> refresh( mTextEditor.get() );
+ event -> refresh( mTextEditor.get() );
private void refresh( final Node node ) {
- throw new RuntimeException( "Nerp" );
+ throw new RuntimeException( "Derp: " + node );
}
stage.show();
- }
-
- @Disabled
- public void test_DragAndDrop() throws InterruptedException {
- sleep( 30_000 );
}
}
Delta1654 lines added, 1665 lines removed, 11-line decrease