| Author | DaveJarvis <email> |
|---|---|
| Date | 2020-12-22 23:49:31 GMT-0800 |
| Commit | da22a912a43113ffa7c6be4dd4ea27e647fb81c6 |
| Parent | 13c47e3 |
| -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 | ||
| 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() ); | ||
| } | ||
| 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; | ||
| * @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; | ||
| 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 ) ); | ||
| } | ||
| } |
| 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 ); | ||
| /** | ||
| 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(); |
| */ | ||
| 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() ); | ||
| } | ||
| -/* 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; | ||
| - } | ||
| -} | ||
| 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(); | ||
| } | ||
| } | ||
| -/* 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; | ||
| - } | ||
| -} | ||
| /** | ||
| - * Responsible for configuring the {@link UserPreferences} user interface. | ||
| + * Provides the ability for users to configure their preferences. | ||
| */ | ||
| public class UserPreferencesView { |
| 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 ) ); | ||
| } | ||
| 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 ); | ||
| } | ||
| } | ||
| /** | ||
| - * 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 { |
| 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; | ||
| } | ||
| 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 ); | ||
| } | ||
| } | ||
| 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 ); | ||
| } | ||
| +/* 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(); | ||
| + } | ||
| +} | ||
| 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; | ||
| } | ||
| } | ||
| 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 ); | ||
| } | ||
| } | ||
| Delta | 1654 lines added, 1665 lines removed, 11-line decrease |
|---|