Dave Jarvis' Repositories

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

Remove higlight from search upon keypress

AuthorDaveJarvis <email>
Date2020-12-16 23:06:22 GMT-0800
Commit8854c6249f5a16239651e66a7141896d290b2648
Parent0d6229b
src/main/java/com/keenwrite/ExportFormat.java
import java.io.File;
+import java.nio.file.Path;
import static org.apache.commons.io.FilenameUtils.removeExtension;
/**
- * Returns the given file renamed with the extension that matches this
- * {@link ExportFormat} extension.
+ * Returns the given {@link File} with its extension replaced by one that
+ * matches this {@link ExportFormat} extension.
*
- * @param file The file to rename.
- * @return The renamed version of the given file.
+ * @param file The file to perform an extension swap.
+ * @return The given file with its extension replaced.
*/
public File toExportFilename( final File file ) {
return new File( removeExtension( file.getName() ) + mExtension );
+ }
+
+ /**
+ * Delegates to {@link #toExportFilename(File)} after converting the given
+ * {@link Path} to an instance of {@link File}.
+ *
+ * @param path The {@link Path} to convert to a {@link File}.
+ * @return The given path with its extension replaced.
+ */
+ public File toExportFilename( final Path path ) {
+ return toExportFilename( path.toFile() );
}
}
src/main/java/com/keenwrite/MainPane.java
import javafx.event.Event;
import javafx.event.EventHandler;
-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.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<>();
-
- /**
- * 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<>( DEFAULT_MAP_SIZE );
-
- /**
- * 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() {
- open( bin( getWorkspace().getListFiles( KEY_UI_FILES_PATH ) ) );
-
- 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 mediaType = file.getMediaType();
- final var tab = createTab( file );
- final var node = tab.getContent();
- final var tabPane = obtainDetachableTabPane( mediaType );
- final var newTabPane = !getItems().contains( tabPane );
-
- tab.setTooltip( createTooltip( file ) );
- 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 );
- }
-
- getWorkspace().putListItem( KEY_UI_FILES_PATH, file );
- }
-
- /**
- * Opens a new text editor document using the default document file name.
- */
- public void newTextEditor() {
- open( DEFAULT_DOCUMENT );
- }
-
- /**
- * Opens a new definition editor document using the default definition
- * file name.
- */
- public void newDefinitionEditor() {
- open( DEFAULT_DEFINITION );
- }
-
- /**
- * 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(
- ( __ ) -> getWorkspace().purgeListItem( KEY_UI_FILES_PATH, 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 files The files 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 List<File> files ) {
- final var map = new HashMap<MediaType, List<File>>();
- map.put( TEXT_YAML, new ArrayList<>() );
- map.put( TEXT_MARKDOWN, new ArrayList<>() );
- map.put( UNDEFINED, new ArrayList<>() );
-
- for( final var file : files ) {
- final var list = map.computeIfAbsent(
- file.getMediaType(), k -> new ArrayList<>()
- );
-
- list.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( DEFAULT_DEFINITION );
- }
-
- if( documents.isEmpty() ) {
- documents.add( DEFAULT_DOCUMENT );
- }
-
- final var result = new ArrayList<File>( files.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 ) -> {
- final var tabPane = new DetachableTabPane();
-
- // Derive the new title from the main window title.
- tabPane.setStageOwnerFactory( ( stage ) -> {
- final var title = get(
- "Detach.tab.title",
- ((Stage) getWindow()).getTitle(), ++mWindowCount
- );
- stage.setTitle( title );
- return getScene().getWindow();
- } );
-
- // Multiple tabs can be added simultaneously.
- 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 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();
- }
- }
- }
- }
- );
-
- 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 );
- }
- }
- } );
-
- return tabPane;
- }
- );
+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.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<>();
+
+ /**
+ * 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<>( DEFAULT_MAP_SIZE );
+
+ /**
+ * 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() {
+ open( bin( getWorkspace().getListFiles( KEY_UI_FILES_PATH ) ) );
+
+ 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 mediaType = file.getMediaType();
+ final var tab = createTab( file );
+ final var node = tab.getContent();
+ final var tabPane = obtainDetachableTabPane( mediaType );
+ final var newTabPane = !getItems().contains( tabPane );
+
+ tab.setTooltip( createTooltip( file ) );
+ 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 );
+ }
+
+ getWorkspace().putListItem( KEY_UI_FILES_PATH, file );
+ }
+
+ /**
+ * Opens a new text editor document using the default document file name.
+ */
+ public void newTextEditor() {
+ open( DEFAULT_DOCUMENT );
+ }
+
+ /**
+ * Opens a new definition editor document using the default definition
+ * file name.
+ */
+ public void newDefinitionEditor() {
+ open( DEFAULT_DEFINITION );
+ }
+
+ /**
+ * 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(
+ ( __ ) -> getWorkspace().purgeListItem( KEY_UI_FILES_PATH, 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 files The files 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 List<File> files ) {
+ final var map = new HashMap<MediaType, List<File>>();
+ map.put( TEXT_YAML, new ArrayList<>() );
+ map.put( TEXT_MARKDOWN, new ArrayList<>() );
+ map.put( UNDEFINED, new ArrayList<>() );
+
+ for( final var file : files ) {
+ final var list = map.computeIfAbsent(
+ file.getMediaType(), k -> new ArrayList<>()
+ );
+
+ list.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( DEFAULT_DEFINITION );
+ }
+
+ if( documents.isEmpty() ) {
+ documents.add( DEFAULT_DOCUMENT );
+ }
+
+ final var result = new ArrayList<File>( files.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 );
+ }
+ }
+ } );
}
src/main/java/com/keenwrite/editors/TextEditor.java
* integer values.
*
- * @param began Document offset where the style starts.
- * @param ended Document offset where the style ends.
- * @param style The style class to apply between the given offsets.
+ * @param indexes Document offset where style is to start and end.
+ * @param style The style class to apply between the given offset indexes.
*/
- default void stylize( int began, int ended, String style ) {
+ default void stylize( final IndexRange indexes, final String style ) {
}
/**
- * Requests that styling be removed from the document between the given
- * integer values.
- *
- * @param began Document offset where the style starts.
- * @param ended Document offset where the style ends.
+ * Requests that the most recent styling for the given style class be
+ * removed from the document between the given integer values.
*/
- default void unstylize( int began, int ended ) {
+ default void unstylize( final String style ) {
}
src/main/java/com/keenwrite/editors/markdown/MarkdownEditor.java
import org.fxmisc.flowless.VirtualizedScrollPane;
import org.fxmisc.richtext.StyleClassedTextArea;
-import org.fxmisc.undo.UndoManager;
-import org.fxmisc.wellbehaved.event.EventPattern;
-import org.fxmisc.wellbehaved.event.Nodes;
-
-import java.nio.charset.Charset;
-import java.text.BreakIterator;
-import java.util.Locale;
-import java.util.function.Consumer;
-import java.util.function.Supplier;
-import java.util.regex.Pattern;
-
-import static com.keenwrite.Constants.*;
-import static com.keenwrite.Messages.get;
-import static com.keenwrite.StatusBarNotifier.clue;
-import static java.lang.Character.isWhitespace;
-import static java.lang.String.format;
-import static javafx.scene.control.ScrollPane.ScrollBarPolicy.ALWAYS;
-import static javafx.scene.input.KeyCode.*;
-import static javafx.scene.input.KeyCombination.CONTROL_DOWN;
-import static javafx.scene.input.KeyCombination.SHIFT_DOWN;
-import static org.apache.commons.lang3.StringUtils.stripEnd;
-import static org.apache.commons.lang3.StringUtils.stripStart;
-import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
-import static org.fxmisc.wellbehaved.event.InputMap.consume;
-
-/**
- * Responsible for editing Markdown documents.
- */
-public class MarkdownEditor extends BorderPane implements TextEditor {
- private static final String NEWLINE = System.lineSeparator();
-
- /**
- * Regular expression that matches the type of markup block. This is used
- * when Enter is pressed to continue the block environment.
- */
- private static final Pattern PATTERN_AUTO_INDENT = Pattern.compile(
- "(\\s*[*+-]\\s+|\\s*[0-9]+\\.\\s+|\\s+)(.*)" );
-
- /**
- * The text editor.
- */
- private final StyleClassedTextArea mTextArea =
- new StyleClassedTextArea( false );
-
- /**
- * Wraps the text editor in scrollbars.
- */
- private final VirtualizedScrollPane<StyleClassedTextArea> mScrollPane =
- new VirtualizedScrollPane<>( mTextArea );
-
- /**
- * Tracks where the caret is located in this document. This offers observable
- * properties for caret position changes.
- */
- private final Caret mCaret = createCaret( mTextArea );
-
- /**
- * File being edited by this editor instance.
- */
- private File mFile;
-
- /**
- * Set to {@code true} upon text or caret position changes. Value is {@code
- * false} by default.
- */
- private final BooleanProperty mDirty = new SimpleBooleanProperty();
-
- /**
- * 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();
-
- public MarkdownEditor() {
- this( DEFAULT_DOCUMENT );
- }
-
- public MarkdownEditor( final File file ) {
- mEncoding = open( mFile = file );
-
- initTextArea( mTextArea );
- initScrollPane( mScrollPane );
- initSpellchecker( mTextArea );
- initHotKeys();
- initUndoManager();
- }
-
- private void initTextArea( final StyleClassedTextArea textArea ) {
- textArea.getStyleClass().add( "markdown" );
- textArea.getStylesheets().add( STYLESHEET_MARKDOWN );
- textArea.getStylesheets().add( getLocaleStylesheet() );
- textArea.setWrapText( true );
- textArea.requestFollowCaret();
- textArea.moveTo( 0 );
-
- textArea.textProperty().addListener( ( c, o, n ) -> {
- // Fire, regardless of whether the caret position has changed.
- mDirty.set( false );
-
- // Prevent a caret position change from raising the dirty bits.
- mDirty.set( true );
- } );
- textArea.caretPositionProperty().addListener( ( c, o, n ) -> {
- // Fire when the caret position has changed and the text has not.
- mDirty.set( true );
- mDirty.set( false );
- } );
- }
-
- /**
- * Returns the ISO 639 alpha-2 or alpha-3 language code followed by a hyphen
- * followed by the ISO 3166 alpha-2 country code or UN M.49 numeric-3 area
- * code.
- * <p>
- * TODO: Override default locale user's workspace locale preference.
- * </p>
- *
- * @return Unique identifier for language and country.
- */
- private String getLocaleStylesheet() {
- return getLocaleStylesheet( Locale.getDefault() );
- }
-
- private String getLocaleStylesheet( final Locale locale ) {
- return get(
- sSettings.getSetting( STYLESHEET_MARKDOWN_LOCALE, "" ),
- locale.getLanguage(),
- locale.getCountry()
- );
- }
-
- private void initScrollPane(
- final VirtualizedScrollPane<StyleClassedTextArea> scrollpane ) {
- scrollpane.setVbarPolicy( ALWAYS );
- setCenter( scrollpane );
- }
-
- private void initSpellchecker( final StyleClassedTextArea textarea ) {
- final var speller = new TextEditorSpeller();
- speller.checkDocument( textarea );
- speller.checkParagraphs( textarea );
- }
-
- private void initHotKeys() {
- addEventListener( keyPressed( ENTER ), this::onEnterPressed );
- addEventListener( keyPressed( X, CONTROL_DOWN ), this::cut );
- addEventListener( keyPressed( TAB ), this::tab );
- addEventListener( keyPressed( TAB, SHIFT_DOWN ), this::untab );
- }
-
- private void initUndoManager() {
- final var undoManager = getUndoManager();
- final var markedPosition = undoManager.atMarkedPositionProperty();
- undoManager.forgetHistory();
- undoManager.mark();
-
- mModified.bind( Bindings.not( markedPosition ) );
- }
-
- @Override
- public void moveTo( final int offset ) {
- assert 0 <= offset && offset <= mTextArea.getLength();
- mTextArea.moveTo( offset );
- mTextArea.requestFollowCaret();
- }
-
- /**
- * Delegate the focus request to the text area itself.
- */
- @Override
- public void requestFocus() {
- mTextArea.requestFocus();
- }
-
- @Override
- public void setText( final String text ) {
- mTextArea.clear();
- mTextArea.appendText( text );
- mTextArea.getUndoManager().mark();
- }
-
- @Override
- public String getText() {
- return mTextArea.getText();
- }
-
- @Override
- public Charset getEncoding() {
- return mEncoding;
- }
-
- @Override
- public File getFile() {
- return mFile;
- }
-
- @Override
- public void rename( final File file ) {
- mFile = file;
- }
-
- @Override
- public void undo() {
- final var manager = getUndoManager();
- xxdo( manager::isUndoAvailable, manager::undo, "Main.status.error.undo" );
- }
-
- @Override
- public void redo() {
- final var manager = getUndoManager();
- xxdo( manager::isRedoAvailable, manager::redo, "Main.status.error.redo" );
- }
-
- /**
- * Performs an undo or redo action, if possible, otherwise displays an error
- * message to the user.
- *
- * @param ready Answers whether the action can be executed.
- * @param action The action to execute.
- * @param key The informational message key having a value to display if
- * the {@link Supplier} is not ready.
- */
- private void xxdo(
- final Supplier<Boolean> ready, final Runnable action, final String key ) {
- if( ready.get() ) {
- action.run();
- }
- else {
- clue( key );
- }
- }
-
- @Override
- public void cut() {
- final var selected = mTextArea.getSelectedText();
-
- if( selected == null || selected.isEmpty() ) {
- mTextArea.selectLine();
- }
-
- mTextArea.cut();
- }
-
- @Override
- public void copy() {
- mTextArea.copy();
- }
-
- @Override
- public void paste() {
- mTextArea.paste();
- }
-
- @Override
- public void selectAll() {
- mTextArea.selectAll();
- }
-
- @Override
- public void bold() {
- enwrap( "**" );
- }
-
- @Override
- public void italic() {
- enwrap( "*" );
- }
-
- @Override
- public void superscript() {
- enwrap( "^" );
- }
-
- @Override
- public void subscript() {
- enwrap( "~" );
- }
-
- @Override
- public void strikethrough() {
- enwrap( "~~" );
- }
-
- @Override
- public void blockquote() {
- block( "> " );
- }
-
- @Override
- public void code() {
- enwrap( "`" );
- }
-
- @Override
- public void fencedCodeBlock() {
- final var key = "App.action.insert.fenced_code_block.prompt.text";
-
- // TODO: Introduce sample text if nothing is selected.
- //enwrap( "\n\n```\n", "\n```\n\n", get( key ) );
- }
-
- @Override
- public void heading( final int level ) {
- final var hashes = new String( new char[ level ] ).replace( "\0", "#" );
- final var markup = format( "%s ", hashes );
-
- block( markup );
- }
-
- @Override
- public void unorderedList() {
- block( "* " );
- }
-
- @Override
- public void orderedList() {
- block( "1. " );
- }
-
- @Override
- public void horizontalRule() {
- block( format( "---%n%n" ) );
- }
-
- @Override
- public Node getNode() {
- return this;
- }
-
- @Override
- public ReadOnlyBooleanProperty modifiedProperty() {
- return mModified;
- }
-
- @Override
- public void clearModifiedProperty() {
- getUndoManager().mark();
- }
-
- @Override
- public VirtualizedScrollPane<StyleClassedTextArea> getScrollPane() {
- return mScrollPane;
- }
-
- @Override
- public void stylize( final int began, final int ended, final String style ) {
- assert 0 <= began && began <= ended;
- assert style != null;
-
- mTextArea.setStyleClass( began, ended, style );
- }
-
- @Override
- public void unstylize( final int began, final int ended ) {
- assert 0 <= began && began <= ended;
- mTextArea.clearStyle( began, ended );
+import org.fxmisc.richtext.model.StyleSpans;
+import org.fxmisc.undo.UndoManager;
+import org.fxmisc.wellbehaved.event.EventPattern;
+import org.fxmisc.wellbehaved.event.Nodes;
+
+import java.nio.charset.Charset;
+import java.text.BreakIterator;
+import java.util.*;
+import java.util.function.Consumer;
+import java.util.function.Supplier;
+import java.util.regex.Pattern;
+
+import static com.keenwrite.Constants.*;
+import static com.keenwrite.Messages.get;
+import static com.keenwrite.StatusBarNotifier.clue;
+import static java.lang.Character.isWhitespace;
+import static java.lang.String.format;
+import static java.util.Collections.singletonList;
+import static javafx.scene.control.ScrollPane.ScrollBarPolicy.ALWAYS;
+import static javafx.scene.input.KeyCode.*;
+import static javafx.scene.input.KeyCombination.CONTROL_DOWN;
+import static javafx.scene.input.KeyCombination.SHIFT_DOWN;
+import static org.apache.commons.lang3.StringUtils.stripEnd;
+import static org.apache.commons.lang3.StringUtils.stripStart;
+import static org.fxmisc.richtext.model.StyleSpans.singleton;
+import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
+import static org.fxmisc.wellbehaved.event.InputMap.consume;
+
+/**
+ * Responsible for editing Markdown documents.
+ */
+public class MarkdownEditor extends BorderPane implements TextEditor {
+ private static final String NEWLINE = System.lineSeparator();
+
+ /**
+ * Regular expression that matches the type of markup block. This is used
+ * when Enter is pressed to continue the block environment.
+ */
+ private static final Pattern PATTERN_AUTO_INDENT = Pattern.compile(
+ "(\\s*[*+-]\\s+|\\s*[0-9]+\\.\\s+|\\s+)(.*)" );
+
+ /**
+ * The text editor.
+ */
+ private final StyleClassedTextArea mTextArea =
+ new StyleClassedTextArea( false );
+
+ /**
+ * Wraps the text editor in scrollbars.
+ */
+ private final VirtualizedScrollPane<StyleClassedTextArea> mScrollPane =
+ new VirtualizedScrollPane<>( mTextArea );
+
+ /**
+ * Tracks where the caret is located in this document. This offers observable
+ * properties for caret position changes.
+ */
+ private final Caret mCaret = createCaret( mTextArea );
+
+ /**
+ * File being edited by this editor instance.
+ */
+ private File mFile;
+
+ /**
+ * Set to {@code true} upon text or caret position changes. Value is {@code
+ * false} by default.
+ */
+ private final BooleanProperty mDirty = new SimpleBooleanProperty();
+
+ /**
+ * 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();
+
+ public MarkdownEditor() {
+ this( DEFAULT_DOCUMENT );
+ }
+
+ public MarkdownEditor( final File file ) {
+ mEncoding = open( mFile = file );
+
+ initTextArea( mTextArea );
+ initScrollPane( mScrollPane );
+ initSpellchecker( mTextArea );
+ initHotKeys();
+ initUndoManager();
+ }
+
+ private void initTextArea( final StyleClassedTextArea textArea ) {
+ textArea.getStyleClass().add( "markdown" );
+ textArea.getStylesheets().add( STYLESHEET_MARKDOWN );
+ textArea.getStylesheets().add( getLocaleStylesheet() );
+ textArea.setWrapText( true );
+ textArea.requestFollowCaret();
+ textArea.moveTo( 0 );
+
+ textArea.textProperty().addListener( ( c, o, n ) -> {
+ // Fire, regardless of whether the caret position has changed.
+ mDirty.set( false );
+
+ // Prevent a caret position change from raising the dirty bits.
+ mDirty.set( true );
+ } );
+ textArea.caretPositionProperty().addListener( ( c, o, n ) -> {
+ // Fire when the caret position has changed and the text has not.
+ mDirty.set( true );
+ mDirty.set( false );
+ } );
+ }
+
+ /**
+ * Returns the ISO 639 alpha-2 or alpha-3 language code followed by a hyphen
+ * followed by the ISO 3166 alpha-2 country code or UN M.49 numeric-3 area
+ * code.
+ * <p>
+ * TODO: Override default locale user's workspace locale preference.
+ * </p>
+ *
+ * @return Unique identifier for language and country.
+ */
+ private String getLocaleStylesheet() {
+ return getLocaleStylesheet( Locale.getDefault() );
+ }
+
+ private String getLocaleStylesheet( final Locale locale ) {
+ return get(
+ sSettings.getSetting( STYLESHEET_MARKDOWN_LOCALE, "" ),
+ locale.getLanguage(),
+ locale.getCountry()
+ );
+ }
+
+ private void initScrollPane(
+ final VirtualizedScrollPane<StyleClassedTextArea> scrollpane ) {
+ scrollpane.setVbarPolicy( ALWAYS );
+ setCenter( scrollpane );
+ }
+
+ private void initSpellchecker( final StyleClassedTextArea textarea ) {
+ final var speller = new TextEditorSpeller();
+ speller.checkDocument( textarea );
+ speller.checkParagraphs( textarea );
+ }
+
+ private void initHotKeys() {
+ addEventListener( keyPressed( ENTER ), this::onEnterPressed );
+ addEventListener( keyPressed( X, CONTROL_DOWN ), this::cut );
+ addEventListener( keyPressed( TAB ), this::tab );
+ addEventListener( keyPressed( TAB, SHIFT_DOWN ), this::untab );
+ }
+
+ private void initUndoManager() {
+ final var undoManager = getUndoManager();
+ final var markedPosition = undoManager.atMarkedPositionProperty();
+
+ undoManager.forgetHistory();
+ undoManager.mark();
+ mModified.bind( Bindings.not( markedPosition ) );
+ }
+
+ @Override
+ public void moveTo( final int offset ) {
+ assert 0 <= offset && offset <= mTextArea.getLength();
+ mTextArea.moveTo( offset );
+ mTextArea.requestFollowCaret();
+ }
+
+ /**
+ * Delegate the focus request to the text area itself.
+ */
+ @Override
+ public void requestFocus() {
+ mTextArea.requestFocus();
+ }
+
+ @Override
+ public void setText( final String text ) {
+ mTextArea.clear();
+ mTextArea.appendText( text );
+ mTextArea.getUndoManager().mark();
+ }
+
+ @Override
+ public String getText() {
+ return mTextArea.getText();
+ }
+
+ @Override
+ public Charset getEncoding() {
+ return mEncoding;
+ }
+
+ @Override
+ public File getFile() {
+ return mFile;
+ }
+
+ @Override
+ public void rename( final File file ) {
+ mFile = file;
+ }
+
+ @Override
+ public void undo() {
+ final var manager = getUndoManager();
+ xxdo( manager::isUndoAvailable, manager::undo, "Main.status.error.undo" );
+ }
+
+ @Override
+ public void redo() {
+ final var manager = getUndoManager();
+ xxdo( manager::isRedoAvailable, manager::redo, "Main.status.error.redo" );
+ }
+
+ /**
+ * Performs an undo or redo action, if possible, otherwise displays an error
+ * message to the user.
+ *
+ * @param ready Answers whether the action can be executed.
+ * @param action The action to execute.
+ * @param key The informational message key having a value to display if
+ * the {@link Supplier} is not ready.
+ */
+ private void xxdo(
+ final Supplier<Boolean> ready, final Runnable action, final String key ) {
+ if( ready.get() ) {
+ action.run();
+ }
+ else {
+ clue( key );
+ }
+ }
+
+ @Override
+ public void cut() {
+ final var selected = mTextArea.getSelectedText();
+
+ if( selected == null || selected.isEmpty() ) {
+ mTextArea.selectLine();
+ }
+
+ mTextArea.cut();
+ }
+
+ @Override
+ public void copy() {
+ mTextArea.copy();
+ }
+
+ @Override
+ public void paste() {
+ mTextArea.paste();
+ }
+
+ @Override
+ public void selectAll() {
+ mTextArea.selectAll();
+ }
+
+ @Override
+ public void bold() {
+ enwrap( "**" );
+ }
+
+ @Override
+ public void italic() {
+ enwrap( "*" );
+ }
+
+ @Override
+ public void superscript() {
+ enwrap( "^" );
+ }
+
+ @Override
+ public void subscript() {
+ enwrap( "~" );
+ }
+
+ @Override
+ public void strikethrough() {
+ enwrap( "~~" );
+ }
+
+ @Override
+ public void blockquote() {
+ block( "> " );
+ }
+
+ @Override
+ public void code() {
+ enwrap( "`" );
+ }
+
+ @Override
+ public void fencedCodeBlock() {
+ final var key = "App.action.insert.fenced_code_block.prompt.text";
+
+ // TODO: Introduce sample text if nothing is selected.
+ //enwrap( "\n\n```\n", "\n```\n\n", get( key ) );
+ }
+
+ @Override
+ public void heading( final int level ) {
+ final var hashes = new String( new char[ level ] ).replace( "\0", "#" );
+ block( format( "%s ", hashes ) );
+ }
+
+ @Override
+ public void unorderedList() {
+ block( "* " );
+ }
+
+ @Override
+ public void orderedList() {
+ block( "1. " );
+ }
+
+ @Override
+ public void horizontalRule() {
+ block( format( "---%n%n" ) );
+ }
+
+ @Override
+ public Node getNode() {
+ return this;
+ }
+
+ @Override
+ public ReadOnlyBooleanProperty modifiedProperty() {
+ return mModified;
+ }
+
+ @Override
+ public void clearModifiedProperty() {
+ getUndoManager().mark();
+ }
+
+ @Override
+ public VirtualizedScrollPane<StyleClassedTextArea> getScrollPane() {
+ return mScrollPane;
+ }
+
+ private final Map<String, IndexRange> mStyles = new HashMap<>();
+
+
+ @Override
+ public void stylize( final IndexRange range, final String style ) {
+ final var began = range.getStart();
+ final var ended = range.getEnd() + 1;
+
+ assert 0 <= began && began <= ended;
+ assert style != null;
+
+// final var spans = mTextArea.getStyleSpans( range );
+// System.out.println( "SPANS: " + spans );
+
+// final var spans = mTextArea.getStyleSpans( range );
+// mTextArea.setStyleSpans( began, merge( spans, range.getLength(), style
+// ) );
+
+// final var builder = new StyleSpansBuilder<Collection<String>>();
+// builder.add( singleton( style ), range.getLength() + 1 );
+// mTextArea.setStyleSpans( began, builder.create() );
+
+// final var s = mTextArea.getStyleSpans( began, ended );
+// System.out.println( "STYLES: " +s );
+
+ mStyles.put( style, range );
+ mTextArea.setStyleClass( began, ended, style );
+
+ // Ensure that whenever the user interacts with the text that the found
+ // word will have its highlighting removed. The handler removes itself.
+ // This won't remove the highlighting if the caret position moves by mouse.
+ final var handler = mTextArea.getOnKeyPressed();
+ mTextArea.setOnKeyPressed( ( event ) -> {
+ mTextArea.setOnKeyPressed( handler );
+ unstylize( style );
+ } );
+
+ //mTextArea.setStyleSpans(began, ended, s);
+ }
+
+ private static StyleSpans<Collection<String>> merge(
+ StyleSpans<Collection<String>> spans, int len, String style ) {
+ spans = spans.overlay(
+ singleton( singletonList( style ), len ),
+ ( bottomSpan, list ) -> {
+ final List<String> l =
+ new ArrayList<>( bottomSpan.size() + list.size() );
+ l.addAll( bottomSpan );
+ l.addAll( list );
+ return l;
+ } );
+
+ return spans;
+ }
+
+ @Override
+ public void unstylize( final String style ) {
+ final var indexes = mStyles.remove( style );
+ if( indexes != null ) {
+ mTextArea.clearStyle( indexes.getStart(), indexes.getEnd() + 1 );
+ }
}
src/main/java/com/keenwrite/search/SearchModel.java
import javafx.scene.control.IndexRange;
import org.ahocorasick.trie.Emit;
+import org.ahocorasick.trie.Trie;
import java.util.ArrayList;
import java.util.List;
import static org.ahocorasick.trie.Trie.builder;
/**
- * Responsible for finding words in a text document.
+ * Responsible for finding words in a text document. This implementation uses
+ * a {@link Trie} for efficiency.
*/
public class SearchModel {
private CyclicIterator<Emit> mMatches = new CyclicIterator<>( List.of() );
- /**
- * The document to search.
- */
- private final String mHaystack;
+ private String mNeedle = "";
/**
* Creates a new {@link SearchModel} that finds all text string in a
* document simultaneously.
- *
- * @param haystack The document to search for a text string.
*/
- public SearchModel( final String haystack ) {
- mHaystack = haystack;
+ public SearchModel() {
}
* is the main entry point for kicking off text searches.
*
- * @param needle The text string to find in the document, no regex allowed.
+ * @param needle The text string to find in the document, no regex allowed.
+ * @param haystack The document to search within for a text string.
*/
- public void search( final String needle ) {
+ public void search( final String needle, final String haystack ) {
+ assert needle != null;
+ assert haystack != null;
+
final var trie = builder()
.ignoreCase()
.ignoreOverlaps()
.addKeyword( needle )
.build();
- final var emits = trie.parseText( mHaystack );
+ final var emits = trie.parseText( haystack );
mMatches = new CyclicIterator<>( new ArrayList<>( emits ) );
mMatchCount.set( emits.size() );
+ mNeedle = needle;
advance();
+ }
+
+ /**
+ * Searches the document for the last known needle.
+ *
+ * @param haystack The new text to search.
+ */
+ public void search( final String haystack ) {
+ search( mNeedle, haystack );
}
src/main/java/com/keenwrite/ui/actions/ApplicationActions.java
@SuppressWarnings("NonAsciiCharacters")
public class ApplicationActions {
+ private static final String STYLE_SEARCH = "search";
+
/**
* When an action is executed, this is one of the recipients.
public ApplicationActions( final MainPane mainPane ) {
mMainPane = mainPane;
- mSearchModel = new SearchModel( getActiveTextEditor().getText() );
+ mSearchModel = new SearchModel();
mSearchModel.matchOffsetProperty().addListener( ( c, o, n ) -> {
final var editor = getActiveTextEditor();
// Clear highlighted areas before adding highlighting to a new region.
if( o != null ) {
- editor.unstylize( o.getStart(), o.getEnd() + 1 );
+ editor.unstylize( STYLE_SEARCH );
}
if( n != null ) {
editor.moveTo( n.getStart() );
- editor.stylize( n.getStart(), n.getEnd() + 1, "search" );
+ editor.stylize( n, STYLE_SEARCH );
}
+ } );
+
+ mMainPane.activeTextEditorProperty().addListener( ( c, o, n ) -> {
+ mSearchModel.search( getActiveTextEditor().getText() );
} );
}
final var doc = editor.getText();
final var export = chain.apply( doc );
- final var filename = format.toExportFilename( editor.getPath().toFile() );
+ final var filename = format.toExportFilename( editor.getPath() );
final var chooser = new FileChooserCommand( getWindow() );
final var file = chooser.exportAs( new File( filename ) );
searchBar.setOnCancelAction( ( event ) -> {
final var editor = getActiveTextEditor();
- final var indexes = mSearchModel.matchOffsetProperty().getValue();
nodes.remove( searchBar );
+ editor.unstylize( STYLE_SEARCH );
editor.getNode().requestFocus();
- editor.unstylize( indexes.getStart(), indexes.getEnd() + 1 );
} );
searchBar.addInputListener( ( c, o, n ) -> {
if( n != null && !n.isEmpty() ) {
- mSearchModel.search( n );
+ mSearchModel.search( n, getActiveTextEditor().getText() );
}
} );
src/main/java/com/keenwrite/ui/controls/SearchBar.java
final var count = max( 0, mMatchCount.get() );
final var suffix = count == 0 ? "none" : "some";
-
final var key = getMessageValue( "match", suffix );
+
mMatches.setText( get( key, index, count ) );
}
final var button = createButton( "stop" );
button.setCancelButton( true );
-
return button;
}
src/test/java/com/keenwrite/editors/markdown/MarkdownEditorTest.java
"L'Haÿ-les-Roses",
"Mühlfeldstraße",
+ "Da̱nx̱a̱laga̱litła̱n",
};
private static final String TEXT = String.join( " ", WORDS );
private static final Pattern REGEX = compile(
- "[^\\p{IsAlphabetic}\\p{IsDigit}'-]" );
+ "[^\\p{Mn}\\p{Me}\\p{L}\\p{N}'-]+" );
/**
Delta1146 lines added, 1024 lines removed, 122-line increase