Dave Jarvis' Repositories

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

Merge WorkspacePreferences into Workspace

AuthorDaveJarvis <email>
Date2020-12-20 18:08:54 GMT-0800
Commit76842e9be972a33534cfe65e7c042757a452285b
Parent973e699
src/main/java/com/keenwrite/MainApp.java
import com.keenwrite.preferences.Workspace;
-import com.keenwrite.preferences.WorkspacePreferences;
import com.keenwrite.service.Snitch;
import javafx.application.Application;
import javafx.application.Platform;
import javafx.stage.Stage;
import java.util.function.BooleanSupplier;
import static com.keenwrite.Bootstrap.APP_TITLE;
import static com.keenwrite.Constants.LOGOS;
-import static com.keenwrite.preferences.WorkspacePreferences.*;
+import static com.keenwrite.preferences.Workspace.*;
import static com.keenwrite.util.FontLoader.initFonts;
import static javafx.scene.input.KeyCode.F11;
private Workspace mWorkspace;
- private WorkspacePreferences mPreferences;
/**
public void start( final Stage stage ) {
// These must be instantiated after the UI is initialized.
- mPreferences = new WorkspacePreferences();
- mWorkspace = new Workspace( mPreferences );
+ mWorkspace = new Workspace();
initFonts();
final var enable = createBoundsEnabledSupplier( stage );
- stage.setX( mPreferences.toDouble( KEY_UI_WINDOW_X ) );
- stage.setY( mPreferences.toDouble( KEY_UI_WINDOW_Y ) );
- stage.setWidth( mPreferences.toDouble( KEY_UI_WINDOW_W ) );
- stage.setHeight( mPreferences.toDouble( KEY_UI_WINDOW_H ) );
- stage.setMaximized( mPreferences.toBoolean( KEY_UI_WINDOW_MAX ) );
- stage.setFullScreen( mPreferences.toBoolean( KEY_UI_WINDOW_FULL ) );
+ stage.setX( mWorkspace.toDouble( KEY_UI_WINDOW_X ) );
+ stage.setY( mWorkspace.toDouble( KEY_UI_WINDOW_Y ) );
+ stage.setWidth( mWorkspace.toDouble( KEY_UI_WINDOW_W ) );
+ stage.setHeight( mWorkspace.toDouble( KEY_UI_WINDOW_H ) );
+ stage.setMaximized( mWorkspace.toBoolean( KEY_UI_WINDOW_MAX ) );
+ stage.setFullScreen( mWorkspace.toBoolean( KEY_UI_WINDOW_FULL ) );
- mPreferences.listen( KEY_UI_WINDOW_X, stage.xProperty(), enable );
- mPreferences.listen( KEY_UI_WINDOW_Y, stage.yProperty(), enable );
- mPreferences.listen( KEY_UI_WINDOW_W, stage.widthProperty(), enable );
- mPreferences.listen( KEY_UI_WINDOW_H, stage.heightProperty(), enable );
- mPreferences.listen( KEY_UI_WINDOW_MAX, stage.maximizedProperty() );
- mPreferences.listen( KEY_UI_WINDOW_FULL, stage.fullScreenProperty() );
+ mWorkspace.listen( KEY_UI_WINDOW_X, stage.xProperty(), enable );
+ mWorkspace.listen( KEY_UI_WINDOW_Y, stage.yProperty(), enable );
+ mWorkspace.listen( KEY_UI_WINDOW_W, stage.widthProperty(), enable );
+ mWorkspace.listen( KEY_UI_WINDOW_H, stage.heightProperty(), enable );
+ mWorkspace.listen( KEY_UI_WINDOW_MAX, stage.maximizedProperty() );
+ mWorkspace.listen( KEY_UI_WINDOW_FULL, stage.fullScreenProperty() );
}
private void initScene( final Stage stage ) {
- stage.setScene( (new MainScene( mPreferences )).getScene() );
+ stage.setScene( (new MainScene( mWorkspace )).getScene() );
}
src/main/java/com/keenwrite/MainPane.java
import com.keenwrite.io.File;
import com.keenwrite.io.MediaType;
-import com.keenwrite.preferences.WorkspacePreferences;
-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.*;
-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.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.WorkspacePreferences.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 WorkspacePreferences mPreferences;
-
- /**
- * 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 WorkspacePreferences preferences ) {
- mPreferences = preferences;
-
- open( bin( preferences.setsProperty( 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 );
- }
-
- getPreferences().setsProperty( KEY_UI_FILES_PATH ).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(
- ( __ ) -> getPreferences().setsProperty( KEY_UI_FILES_PATH )
- .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 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
- );
- }
-
- 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 = file.getMediaType();
-
- return switch( mediaType ) {
- case TEXT_MARKDOWN -> createMarkdownEditor( file );
- case TEXT_YAML -> createDefinitionEditor( file );
- default -> new PlainTextEditor( 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.getFile().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 WorkspacePreferences getPreferences() {
- return mPreferences;
+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.*;
+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.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( workspace.setsProperty( 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 );
+ }
+
+ getPreferences().setsProperty( KEY_UI_FILES_PATH ).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(
+ ( __ ) -> getPreferences().setsProperty( KEY_UI_FILES_PATH )
+ .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 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
+ );
+ }
+
+ 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 = file.getMediaType();
+
+ return switch( mediaType ) {
+ case TEXT_MARKDOWN -> createMarkdownEditor( file );
+ case TEXT_YAML -> createDefinitionEditor( file );
+ default -> new PlainTextEditor( 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.getFile().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 getPreferences() {
+ return mWorkspace;
}
}
src/main/java/com/keenwrite/MainScene.java
package com.keenwrite;
-import com.keenwrite.preferences.WorkspacePreferences;
+import com.keenwrite.preferences.Workspace;
import com.keenwrite.ui.actions.ApplicationActions;
import com.keenwrite.ui.actions.ApplicationMenuBar;
private final Scene mScene;
- public MainScene( final WorkspacePreferences preferences ) {
+ public MainScene( final Workspace preferences ) {
final var mainPane = createMainPane( preferences );
final var actions = createApplicationActions( mainPane );
}
- private MainPane createMainPane( final WorkspacePreferences preferences ) {
+ private MainPane createMainPane( final Workspace preferences ) {
return new MainPane( preferences );
}
src/main/java/com/keenwrite/preferences/Workspace.java
import com.keenwrite.Constants;
import com.keenwrite.io.File;
-import javafx.beans.property.Property;
-import javafx.beans.property.SimpleBooleanProperty;
-import javafx.beans.property.SimpleDoubleProperty;
-import javafx.beans.property.SimpleFloatProperty;
+import javafx.application.Platform;
+import javafx.beans.property.*;
import org.apache.commons.configuration2.XMLConfiguration;
import org.apache.commons.configuration2.builder.fluent.Configurations;
import org.apache.commons.configuration2.ex.ConfigurationException;
import org.apache.commons.configuration2.io.FileHandler;
import java.util.HashSet;
import java.util.Locale;
import java.util.Map;
+import java.util.function.BiConsumer;
+import java.util.function.BooleanSupplier;
+import java.util.function.Consumer;
import java.util.function.Function;
import static com.keenwrite.Bootstrap.APP_TITLE_LOWERCASE;
-import static com.keenwrite.Constants.FILE_PREFERENCES;
+import static com.keenwrite.Constants.*;
+import static com.keenwrite.Launcher.getVersion;
import static com.keenwrite.StatusBarNotifier.clue;
+import static com.keenwrite.preferences.Key.key;
+import static java.util.Map.entry;
+import static javafx.application.Platform.runLater;
import static javafx.collections.FXCollections.observableSet;
/**
* Responsible for defining behaviours for separate projects. A workspace has
* the ability to save and restore a session, including the window dimensions,
* tab setup, files, and user preferences.
* <p>
- * The {@link Workspace} configuration must support hierarchical (nested)
- * configuration nodes to persist the user interface state. Although possible
- * with a flat configuration file, it's not nearly as simple or elegant.
+ * The configuration must support hierarchical (nested) configuration nodes
+ * to persist the user interface state. Although possible with a flat
+ * configuration file, it's not nearly as simple or elegant.
* </p>
* <p>
* Neither JSON nor HOCON support schema validation and versioning, which makes
* XML the more suitable configuration file format. Schema validation and
* versioning provide future-proofing and ease of reading and upgrading previous
* versions of the configuration file.
+ * </p>
+ * <p>
+ * Persistent preferences may be set directly by the user or indirectly by
+ * the act of using the application.
+ * </p>
+ * <p>
+ * Note the following definitions:
* </p>
+ * <dl>
+ * <dt>File</dt>
+ * <dd>References a filename (no path), path, or directory.</dd>
+ * <dt>Path</dt>
+ * <dd>Fully qualified filename, which includes all parent directories.</dd>
+ * <dt>Dir</dt>
+ * <dd>Directory without a filename ({@link File#isDirectory()} is true).</dd>
+ * </dl>
*/
-public final class Workspace {
+public class Workspace {
+ private static final Key KEY_ROOT = key( "workspace" );
+
+ public static final Key KEY_META = key( KEY_ROOT, "meta" );
+ public static final Key KEY_META_NAME = key( KEY_META, "name" );
+ public static final Key KEY_META_VERSION = key( KEY_META, "version" );
+
+ public static final Key KEY_R = key( KEY_ROOT, "r" );
+ public static final Key KEY_R_SCRIPT = key( KEY_R, "script" );
+ public static final Key KEY_R_DIR = key( KEY_R, "dir" );
+ public static final Key KEY_R_DELIM = key( KEY_R, "delimiter" );
+ public static final Key KEY_R_DELIM_BEGAN = key( KEY_R_DELIM, "began" );
+ public static final Key KEY_R_DELIM_ENDED = key( KEY_R_DELIM, "ended" );
+
+ public static final Key KEY_IMAGES = key( KEY_ROOT, "images" );
+ public static final Key KEY_IMAGES_DIR = key( KEY_IMAGES, "dir" );
+ public static final Key KEY_IMAGES_ORDER = key( KEY_IMAGES, "order" );
+
+ public static final Key KEY_DEF = key( KEY_ROOT, "definition" );
+ public static final Key KEY_DEF_PATH = key( KEY_DEF, "path" );
+ public static final Key KEY_DEF_DELIM = key( KEY_DEF, "delimiter" );
+ public static final Key KEY_DEF_DELIM_BEGAN = key( KEY_DEF_DELIM, "began" );
+ public static final Key KEY_DEF_DELIM_ENDED = key( KEY_DEF_DELIM, "ended" );
+
+ //@formatter:off
+ public static final Key KEY_UI = key( KEY_ROOT, "ui" );
+
+ public static final Key KEY_UI_RECENT = key( KEY_UI, "recent" );
+ public static final Key KEY_UI_RECENT_DIR = key( KEY_UI_RECENT, "dir" );
+ public static final Key KEY_UI_RECENT_DOCUMENT = key( KEY_UI_RECENT,"document" );
+ public static final Key KEY_UI_RECENT_DEFINITION = key( KEY_UI_RECENT, "definition" );
+
+ public static final Key KEY_UI_FILES = key( KEY_UI, "files" );
+ public static final Key KEY_UI_FILES_PATH = key( KEY_UI_FILES, "path" );
+
+ public static final Key KEY_UI_FONT = key( KEY_UI, "font" );
+ public static final Key KEY_UI_FONT_LOCALE = key( KEY_UI_FONT, "locale" );
+ public static final Key KEY_UI_FONT_EDITOR = key( KEY_UI_FONT, "editor" );
+ public static final Key KEY_UI_FONT_EDITOR_SIZE = key( KEY_UI_FONT_EDITOR, "size" );
+ public static final Key KEY_UI_FONT_PREVIEW = key( KEY_UI_FONT, "preview" );
+ public static final Key KEY_UI_FONT_PREVIEW_SIZE = key( KEY_UI_FONT_PREVIEW, "size" );
+
+ public static final Key KEY_UI_WINDOW = key( KEY_UI, "window" );
+ public static final Key KEY_UI_WINDOW_X = key( KEY_UI_WINDOW, "x" );
+ public static final Key KEY_UI_WINDOW_Y = key( KEY_UI_WINDOW, "y" );
+ public static final Key KEY_UI_WINDOW_W = key( KEY_UI_WINDOW, "width" );
+ public static final Key KEY_UI_WINDOW_H = key( KEY_UI_WINDOW, "height" );
+ public static final Key KEY_UI_WINDOW_MAX = key( KEY_UI_WINDOW, "maximized" );
+ public static final Key KEY_UI_WINDOW_FULL = key( KEY_UI_WINDOW, "full" );
+
+ private final Map<Key, Property<?>> VALUES = Map.ofEntries(
+ entry( KEY_META_VERSION, new SimpleStringProperty( getVersion() ) ),
+ entry( KEY_META_NAME, new SimpleStringProperty( "defaullt" ) ),
+
+ entry( KEY_R_SCRIPT, new SimpleStringProperty( "" ) ),
+ entry( KEY_R_DIR, new SimpleFileProperty( USER_DIRECTORY ) ),
+ entry( KEY_R_DELIM_BEGAN, new SimpleStringProperty( R_DELIM_BEGAN_DEFAULT ) ),
+ entry( KEY_R_DELIM_ENDED, new SimpleStringProperty( R_DELIM_ENDED_DEFAULT ) ),
+
+ entry( KEY_IMAGES_DIR, new SimpleFileProperty( USER_DIRECTORY ) ),
+ entry( KEY_IMAGES_ORDER, new SimpleStringProperty( PERSIST_IMAGES_DEFAULT ) ),
+
+ entry( KEY_DEF_PATH, new SimpleFileProperty( DEFINITION_DEFAULT ) ),
+ entry( KEY_DEF_DELIM_BEGAN, new SimpleStringProperty( DEF_DELIM_BEGAN_DEFAULT ) ),
+ entry( KEY_DEF_DELIM_ENDED, new SimpleStringProperty( DEF_DELIM_ENDED_DEFAULT ) ),
+
+ entry( KEY_UI_RECENT_DIR, new SimpleFileProperty( USER_DIRECTORY ) ),
+ entry( KEY_UI_RECENT_DOCUMENT, new SimpleFileProperty( DOCUMENT_DEFAULT ) ),
+ entry( KEY_UI_RECENT_DEFINITION, new SimpleFileProperty( DEFINITION_DEFAULT ) ),
+
+ entry( KEY_UI_FONT_LOCALE, new SimpleLocaleProperty( LOCALE_DEFAULT ) ),
+ entry( KEY_UI_FONT_EDITOR_SIZE, new SimpleFloatProperty( FONT_SIZE_EDITOR_DEFAULT ) ),
+ entry( KEY_UI_FONT_PREVIEW_SIZE, new SimpleFloatProperty( FONT_SIZE_PREVIEW_DEFAULT ) ),
+
+ entry( KEY_UI_WINDOW_X, new SimpleDoubleProperty( WINDOW_X_DEFAULT ) ),
+ entry( KEY_UI_WINDOW_Y, new SimpleDoubleProperty( WINDOW_Y_DEFAULT ) ),
+ entry( KEY_UI_WINDOW_W, new SimpleDoubleProperty( WINDOW_W_DEFAULT ) ),
+ entry( KEY_UI_WINDOW_H, new SimpleDoubleProperty( WINDOW_H_DEFAULT ) ),
+ entry( KEY_UI_WINDOW_MAX, new SimpleBooleanProperty() ),
+ entry( KEY_UI_WINDOW_FULL, new SimpleBooleanProperty() )
+ );
+ //@formatter:on
+
+ private final Map<Key, SetProperty<?>> SETS = Map.ofEntries(
+ entry( KEY_UI_FILES_PATH, new SimpleSetProperty<>() )
+ );
/**
SimpleFileProperty.class, File::new
);
+
+ public Workspace() {
+ load();
+ }
/**
- * Defines observable user preferences properties and lists.
+ * Returns a value that represents a setting in the application that the user
+ * may configure, either directly or indirectly.
+ *
+ * @param key The reference to the users' preference stored in deference
+ * of app reëntrance.
+ * @return An observable property to be persisted.
*/
- private final WorkspacePreferences mPreferences;
+ @SuppressWarnings("unchecked")
+ public <T> Property<T> valuesProperty( final Key key ) {
+ // The type that goes into the map must come out.
+ return (Property<T>) VALUES.get( key );
+ }
/**
- * Constructs a new workspace with the given identifier. This will attempt
- * to read the configuration file stored in the
+ * Returns a list of values that represent a setting in the application that
+ * the user may configure, either directly or indirectly.
+ *
+ * @param key The {@link Key} associated with a preference value.
+ * @return An observable property to be persisted.
*/
- public Workspace( final WorkspacePreferences preferences ) {
- mPreferences = preferences;
- load( preferences );
+ @SuppressWarnings("unchecked")
+ public <T> SetProperty<T> setsProperty( final Key key ) {
+ // The type that goes into the map must come out.
+ return (SetProperty<T>) SETS.get( key );
}
/**
- * Attempts to load the {@link Constants#FILE_PREFERENCES} configuration file.
- * If not found, this will fall back to an empty configuration file, leaving
- * the application to fill in default values.
+ * Returns the {@link Double} preference value associated with the given
+ * {@link Key}. The caller must be sure that the given {@link Key} is
+ * associated with a value that matches the return type.
+ *
+ * @param key The {@link Key} associated with a preference value.
+ * @return The value associated with the given {@link Key}.
*/
- private void load( final WorkspacePreferences preferences ) {
- try {
- final var config = createConfiguration();
+ public double toDouble( final Key key ) {
+ return (double) valuesProperty( key ).getValue();
+ }
- preferences.consumeValueKeys( ( key ) -> {
- final var configValue = config.getProperty( key.toString() );
- final var propertyValue = preferences.valuesProperty( key );
- propertyValue.setValue( unmarshall( propertyValue, configValue ) );
- } );
+ /**
+ * Returns the {@link Boolean} preference value associated with the given
+ * {@link Key}. The caller must be sure that the given {@link Key} is
+ * associated with a value that matches the return type.
+ *
+ * @param key The {@link Key} associated with a preference value.
+ * @return The value associated with the given {@link Key}.
+ */
+ public boolean toBoolean( final Key key ) {
+ return (boolean) valuesProperty( key ).getValue();
+ }
- preferences.consumeSetKeys( ( key ) -> {
- final var configList =
- new HashSet<>( config.getList( key.toString() ) );
- final var propertySet = preferences.setsProperty( key );
- propertySet.setValue( observableSet( configList ) );
- } );
- } catch( final Exception ex ) {
- clue( ex );
- }
+ /**
+ * Returns the {@link File} {@link Property} associated with the given
+ * {@link Key} from the internal list of preference values. The caller
+ * must be sure that the given {@link Key} is associated with a {@link File}
+ * {@link Property}.
+ *
+ * @param key The {@link Key} associated with a preference value.
+ * @return The value associated with the given {@link Key}.
+ */
+ public Property<File> fileProperty( final Key key ) {
+ return valuesProperty( key );
+ }
+
+ /**
+ * Calls the given consumer for all single-value keys. For lists, see
+ * {@link #consumeSets(BiConsumer)}.
+ *
+ * @param consumer Called to accept each preference key value.
+ */
+ public void consumeValues( final BiConsumer<Key, Property<?>> consumer ) {
+ VALUES.forEach( consumer );
+ }
+
+ /**
+ * Calls the given consumer for all multi-value keys. For single items, see
+ * {@link #consumeValues(BiConsumer)}. Callers are responsible for iterating
+ * over the list of items retrieved through this method.
+ *
+ * @param consumer Called to accept each preference key list.
+ */
+ public void consumeSets( final BiConsumer<Key, SetProperty<?>> consumer ) {
+ SETS.forEach( consumer );
+ }
+
+ public void consumeValueKeys( final Consumer<Key> consumer ) {
+ VALUES.keySet().forEach( consumer );
+ }
+
+ public void consumeSetKeys( final Consumer<Key> consumer ) {
+ SETS.keySet().forEach( consumer );
+ }
+
+ /**
+ * Delegates to {@link #listen(Key, ReadOnlyProperty, BooleanSupplier)},
+ * providing a value of {@code true} for the {@link BooleanSupplier} to
+ * indicate the property changes always take effect.
+ *
+ * @param key The value to bind to the internal key property.
+ * @param property The external property value that sets the internal value.
+ */
+ public <T> void listen( final Key key, final ReadOnlyProperty<T> property ) {
+ listen( key, property, () -> true );
+ }
+
+ /**
+ * Binds a read-only property to a value in the preferences. This allows
+ * user interface properties to change and the preferences will be
+ * synchronized automatically.
+ * <p>
+ * This calls {@link Platform#runLater(Runnable)} to ensure that all pending
+ * application window states are finished before assessing whether property
+ * changes should be applied. Without this, exiting the application while the
+ * window is maximized would persist the window's maximum dimensions,
+ * preventing restoration to its prior, non-maximum size.
+ * </p>
+ *
+ * @param key The value to bind to the internal key property.
+ * @param property The external property value that sets the internal value.
+ * @param enabled Indicates whether property changes should be applied.
+ */
+ public <T> void listen(
+ final Key key,
+ final ReadOnlyProperty<T> property,
+ final BooleanSupplier enabled ) {
+ property.addListener(
+ ( c, o, n ) ->
+ runLater( () -> {
+ if( enabled.getAsBoolean() ) {
+ valuesProperty( key ).setValue( n );
+ }
+ } )
+ );
}
config.setRootElementName( APP_TITLE_LOWERCASE );
- mPreferences.consumeValues( ( key, value ) -> config.setProperty(
+ consumeValues( ( key, value ) -> config.setProperty(
key.toString(), value.getValue() )
);
- mPreferences.consumeSets(
+ consumeSets(
( key, set ) -> {
final String keyName = key.toString();
set.forEach( ( value ) -> config.addProperty( keyName, value ) );
}
);
new FileHandler( config ).save( FILE_PREFERENCES );
+ } catch( final Exception ex ) {
+ clue( ex );
+ }
+ }
+
+ /**
+ * Attempts to load the {@link Constants#FILE_PREFERENCES} configuration file.
+ * If not found, this will fall back to an empty configuration file, leaving
+ * the application to fill in default values.
+ */
+ private void load() {
+ try {
+ final var config = createConfiguration();
+
+ consumeValueKeys( ( key ) -> {
+ final var configValue = config.getProperty( key.toString() );
+ final var propertyValue = valuesProperty( key );
+ propertyValue.setValue( unmarshall( propertyValue, configValue ) );
+ } );
+
+ consumeSetKeys( ( key ) -> {
+ final var configList =
+ new HashSet<>( config.getList( key.toString() ) );
+ final var propertySet = setsProperty( key );
+ propertySet.setValue( observableSet( configList ) );
+ } );
} catch( final Exception ex ) {
clue( ex );
src/main/java/com/keenwrite/preferences/WorkspacePreferences.java
-/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */
-package com.keenwrite.preferences;
-
-import com.keenwrite.io.File;
-import javafx.application.Platform;
-import javafx.beans.property.*;
-
-import java.util.Map;
-import java.util.function.BiConsumer;
-import java.util.function.BooleanSupplier;
-import java.util.function.Consumer;
-
-import static com.keenwrite.Constants.*;
-import static com.keenwrite.Launcher.getVersion;
-import static com.keenwrite.preferences.Key.key;
-import static java.util.Map.entry;
-import static javafx.application.Platform.runLater;
-
-/**
- * Represents persistent preferences set both directly and indirectly.
- * <p>
- * Note the following definitions:
- * </p>
- * <dl>
- * <dt>File</dt>
- * <dd>References a filename (no path), path, or directory.</dd>
- * <dt>Path</dt>
- * <dd>Fully qualified filename, which includes all parent directories.</dd>
- * <dt>Dir</dt>
- * <dd>Directory without a filename ({@link File#isDirectory()} is true).</dd>
- * </dl>
- */
-public class WorkspacePreferences {
- private static final Key KEY_ROOT = key( "workspace" );
-
- public static final Key KEY_META = key( KEY_ROOT, "meta" );
- public static final Key KEY_META_NAME = key( KEY_META, "name" );
- public static final Key KEY_META_VERSION = key( KEY_META, "version" );
-
- public static final Key KEY_R = key( KEY_ROOT, "r" );
- public static final Key KEY_R_SCRIPT = key( KEY_R, "script" );
- public static final Key KEY_R_DIR = key( KEY_R, "dir" );
- public static final Key KEY_R_DELIM = key( KEY_R, "delimiter" );
- public static final Key KEY_R_DELIM_BEGAN = key( KEY_R_DELIM, "began" );
- public static final Key KEY_R_DELIM_ENDED = key( KEY_R_DELIM, "ended" );
-
- public static final Key KEY_IMAGES = key( KEY_ROOT, "images" );
- public static final Key KEY_IMAGES_DIR = key( KEY_IMAGES, "dir" );
- public static final Key KEY_IMAGES_ORDER = key( KEY_IMAGES, "order" );
-
- public static final Key KEY_DEF = key( KEY_ROOT, "definition" );
- public static final Key KEY_DEF_PATH = key( KEY_DEF, "path" );
- public static final Key KEY_DEF_DELIM = key( KEY_DEF, "delimiter" );
- public static final Key KEY_DEF_DELIM_BEGAN = key( KEY_DEF_DELIM, "began" );
- public static final Key KEY_DEF_DELIM_ENDED = key( KEY_DEF_DELIM, "ended" );
-
- //@formatter:off
- public static final Key KEY_UI = key( KEY_ROOT, "ui" );
-
- public static final Key KEY_UI_RECENT = key( KEY_UI, "recent" );
- public static final Key KEY_UI_RECENT_DIR = key( KEY_UI_RECENT, "dir" );
- public static final Key KEY_UI_RECENT_DOCUMENT = key( KEY_UI_RECENT,"document" );
- public static final Key KEY_UI_RECENT_DEFINITION = key( KEY_UI_RECENT, "definition" );
-
- public static final Key KEY_UI_FILES = key( KEY_UI, "files" );
- public static final Key KEY_UI_FILES_PATH = key( KEY_UI_FILES, "path" );
-
- public static final Key KEY_UI_FONT = key( KEY_UI, "font" );
- public static final Key KEY_UI_FONT_LOCALE = key( KEY_UI_FONT, "locale" );
- public static final Key KEY_UI_FONT_EDITOR = key( KEY_UI_FONT, "editor" );
- public static final Key KEY_UI_FONT_EDITOR_SIZE = key( KEY_UI_FONT_EDITOR, "size" );
- public static final Key KEY_UI_FONT_PREVIEW = key( KEY_UI_FONT, "preview" );
- public static final Key KEY_UI_FONT_PREVIEW_SIZE = key( KEY_UI_FONT_PREVIEW, "size" );
-
- public static final Key KEY_UI_WINDOW = key( KEY_UI, "window" );
- public static final Key KEY_UI_WINDOW_X = key( KEY_UI_WINDOW, "x" );
- public static final Key KEY_UI_WINDOW_Y = key( KEY_UI_WINDOW, "y" );
- public static final Key KEY_UI_WINDOW_W = key( KEY_UI_WINDOW, "width" );
- public static final Key KEY_UI_WINDOW_H = key( KEY_UI_WINDOW, "height" );
- public static final Key KEY_UI_WINDOW_MAX = key( KEY_UI_WINDOW, "maximized" );
- public static final Key KEY_UI_WINDOW_FULL = key( KEY_UI_WINDOW, "full" );
-
- private final Map<Key, Property<?>> VALUES = Map.ofEntries(
- entry( KEY_META_VERSION, new SimpleStringProperty( getVersion() ) ),
- entry( KEY_META_NAME, new SimpleStringProperty( "defaullt" ) ),
-
- entry( KEY_R_SCRIPT, new SimpleStringProperty( "" ) ),
- entry( KEY_R_DIR, new SimpleFileProperty( USER_DIRECTORY ) ),
- entry( KEY_R_DELIM_BEGAN, new SimpleStringProperty( R_DELIM_BEGAN_DEFAULT ) ),
- entry( KEY_R_DELIM_ENDED, new SimpleStringProperty( R_DELIM_ENDED_DEFAULT ) ),
-
- entry( KEY_IMAGES_DIR, new SimpleFileProperty( USER_DIRECTORY ) ),
- entry( KEY_IMAGES_ORDER, new SimpleStringProperty( PERSIST_IMAGES_DEFAULT ) ),
-
- entry( KEY_DEF_PATH, new SimpleFileProperty( DEFINITION_DEFAULT ) ),
- entry( KEY_DEF_DELIM_BEGAN, new SimpleStringProperty( DEF_DELIM_BEGAN_DEFAULT ) ),
- entry( KEY_DEF_DELIM_ENDED, new SimpleStringProperty( DEF_DELIM_ENDED_DEFAULT ) ),
-
- entry( KEY_UI_RECENT_DIR, new SimpleFileProperty( USER_DIRECTORY ) ),
- entry( KEY_UI_RECENT_DOCUMENT, new SimpleFileProperty( DOCUMENT_DEFAULT ) ),
- entry( KEY_UI_RECENT_DEFINITION, new SimpleFileProperty( DEFINITION_DEFAULT ) ),
-
- entry( KEY_UI_FONT_LOCALE, new SimpleLocaleProperty( LOCALE_DEFAULT ) ),
- entry( KEY_UI_FONT_EDITOR_SIZE, new SimpleFloatProperty( FONT_SIZE_EDITOR_DEFAULT ) ),
- entry( KEY_UI_FONT_PREVIEW_SIZE, new SimpleFloatProperty( FONT_SIZE_PREVIEW_DEFAULT ) ),
-
- entry( KEY_UI_WINDOW_X, new SimpleDoubleProperty( WINDOW_X_DEFAULT ) ),
- entry( KEY_UI_WINDOW_Y, new SimpleDoubleProperty( WINDOW_Y_DEFAULT ) ),
- entry( KEY_UI_WINDOW_W, new SimpleDoubleProperty( WINDOW_W_DEFAULT ) ),
- entry( KEY_UI_WINDOW_H, new SimpleDoubleProperty( WINDOW_H_DEFAULT ) ),
- entry( KEY_UI_WINDOW_MAX, new SimpleBooleanProperty() ),
- entry( KEY_UI_WINDOW_FULL, new SimpleBooleanProperty() )
- );
- //@formatter:on
-
- private final Map<Key, SetProperty<?>> SETS = Map.ofEntries(
- entry( KEY_UI_FILES_PATH, new SimpleSetProperty<>() )
- );
-
- public WorkspacePreferences() {
- }
-
- /**
- * Returns a value that represents a setting in the application that the user
- * may configure, either directly or indirectly.
- *
- * @param key The reference to the users' preference stored in deference
- * of app reëntrance.
- * @return An observable property to be persisted.
- */
- @SuppressWarnings("unchecked")
- public <T> Property<T> valuesProperty( final Key key ) {
- // The type that goes into the map must come out.
- return (Property<T>) VALUES.get( key );
- }
-
- /**
- * Returns a list of values that represent a setting in the application that
- * the user may configure, either directly or indirectly.
- *
- * @param key The {@link Key} associated with a preference value.
- * @return An observable property to be persisted.
- */
- @SuppressWarnings("unchecked")
- public <T> SetProperty<T> setsProperty( final Key key ) {
- // The type that goes into the map must come out.
- return (SetProperty<T>) SETS.get( key );
- }
-
- /**
- * Returns the {@link Double} preference value associated with the given
- * {@link Key}. The caller must be sure that the given {@link Key} is
- * associated with a value that matches the return type.
- *
- * @param key The {@link Key} associated with a preference value.
- * @return The value associated with the given {@link Key}.
- */
- public double toDouble( final Key key ) {
- return (double) valuesProperty( key ).getValue();
- }
-
- /**
- * Returns the {@link Boolean} preference value associated with the given
- * {@link Key}. The caller must be sure that the given {@link Key} is
- * associated with a value that matches the return type.
- *
- * @param key The {@link Key} associated with a preference value.
- * @return The value associated with the given {@link Key}.
- */
- public boolean toBoolean( final Key key ) {
- return (boolean) valuesProperty( key ).getValue();
- }
-
- /**
- * Returns the {@link File} {@link Property} associated with the given
- * {@link Key} from the internal list of preference values. The caller
- * must be sure that the given {@link Key} is associated with a {@link File}
- * {@link Property}.
- *
- * @param key The {@link Key} associated with a preference value.
- * @return The value associated with the given {@link Key}.
- */
- public Property<File> fileProperty( final Key key ) {
- return valuesProperty( key );
- }
-
- /**
- * Calls the given consumer for all single-value keys. For lists, see
- * {@link #consumeSets(BiConsumer)}.
- *
- * @param consumer Called to accept each preference key value.
- */
- public void consumeValues( final BiConsumer<Key, Property<?>> consumer ) {
- VALUES.forEach( consumer );
- }
-
- /**
- * Calls the given consumer for all multi-value keys. For single items, see
- * {@link #consumeValues(BiConsumer)}. Callers are responsible for iterating
- * over the list of items retrieved through this method.
- *
- * @param consumer Called to accept each preference key list.
- */
- public void consumeSets( final BiConsumer<Key, SetProperty<?>> consumer ) {
- SETS.forEach( consumer );
- }
-
- public void consumeValueKeys( final Consumer<Key> consumer ) {
- VALUES.keySet().forEach( consumer );
- }
-
- public void consumeSetKeys( final Consumer<Key> consumer ) {
- SETS.keySet().forEach( consumer );
- }
-
- /**
- * Delegates to {@link #listen(Key, ReadOnlyProperty, BooleanSupplier)},
- * providing a value of {@code true} for the {@link BooleanSupplier} to
- * indicate the property changes always take effect.
- *
- * @param key The value to bind to the internal key property.
- * @param property The external property value that sets the internal value.
- */
- public <T> void listen( final Key key, final ReadOnlyProperty<T> property ) {
- listen( key, property, () -> true );
- }
-
- /**
- * Binds a read-only property to a value in the preferences. This allows
- * user interface properties to change and the preferences will be
- * synchronized automatically.
- * <p>
- * This calls {@link Platform#runLater(Runnable)} to ensure that all pending
- * application window states are finished before assessing whether property
- * changes should be applied. Without this, exiting the application while the
- * window is maximized would persist the window's maximum dimensions,
- * preventing restoration to its prior, non-maximum size.
- * </p>
- *
- * @param key The value to bind to the internal key property.
- * @param property The external property value that sets the internal value.
- * @param enabled Indicates whether property changes should be applied.
- */
- public <T> void listen(
- final Key key,
- final ReadOnlyProperty<T> property,
- final BooleanSupplier enabled ) {
- property.addListener(
- ( c, o, n ) ->
- runLater( () -> {
- if( enabled.getAsBoolean() ) {
- valuesProperty( key ).setValue( n );
- }
- } )
- );
- }
-}
src/main/java/com/keenwrite/ui/actions/ApplicationActions.java
import com.keenwrite.io.File;
import com.keenwrite.preferences.UserPreferencesView;
-import com.keenwrite.preferences.WorkspacePreferences;
-import com.keenwrite.processors.ProcessorContext;
-import com.keenwrite.search.SearchModel;
-import com.keenwrite.ui.controls.SearchBar;
-import javafx.scene.control.Alert;
-import javafx.scene.image.ImageView;
-import javafx.stage.Window;
-import javafx.stage.WindowEvent;
-
-import static com.keenwrite.Bootstrap.APP_TITLE;
-import static com.keenwrite.Constants.ICON_DIALOG;
-import static com.keenwrite.ExportFormat.*;
-import static com.keenwrite.Messages.get;
-import static com.keenwrite.StatusBarNotifier.clue;
-import static com.keenwrite.StatusBarNotifier.getStatusBar;
-import static com.keenwrite.preferences.WorkspacePreferences.KEY_UI_RECENT_DIR;
-import static com.keenwrite.processors.ProcessorFactory.createProcessors;
-import static java.nio.file.Files.writeString;
-import static javafx.event.Event.fireEvent;
-import static javafx.scene.control.Alert.AlertType.INFORMATION;
-import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST;
-
-/**
- * Responsible for abstracting how functionality is mapped to the application.
- * This allows users to customize accelerator keys and will provide pluggable
- * functionality so that different text markup languages can change documents
- * using their respective syntax.
- */
-@SuppressWarnings("NonAsciiCharacters")
-public class ApplicationActions {
- private static final String STYLE_SEARCH = "search";
-
- /**
- * When an action is executed, this is one of the recipients.
- */
- private final MainPane mMainPane;
-
- /**
- * Tracks finding text in the active document.
- */
- private final SearchModel mSearchModel;
-
- public ApplicationActions( final MainPane mainPane ) {
- mMainPane = mainPane;
- 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( STYLE_SEARCH );
- }
-
- if( n != null ) {
- editor.moveTo( n.getStart() );
- editor.stylize( n, STYLE_SEARCH );
- }
- } );
-
- // When the active text editor changes, update the haystack.
- mMainPane.activeTextEditorProperty().addListener(
- ( c, o, n ) -> mSearchModel.search( getActiveTextEditor().getText() )
- );
- }
-
- public void file‿new() {
- getMainPane().newTextEditor();
- }
-
- public void file‿open() {
- getMainPane().open( createFileChooser().openFiles() );
- }
-
- public void file‿close() {
- getMainPane().close();
- }
-
- public void file‿close_all() {
- getMainPane().closeAll();
- }
-
- public void file‿save() {
- getMainPane().save();
- }
-
- public void file‿save_as() {
- final var file = createFileChooser().saveAs();
- file.ifPresent( ( f ) -> getMainPane().saveAs( f ) );
- }
-
- public void file‿save_all() {
- getMainPane().saveAll();
- }
-
- public void file‿export‿html_svg() {
- file‿export( HTML_TEX_SVG );
- }
-
- public void file‿export‿html_tex() {
- file‿export( HTML_TEX_DELIMITED );
- }
-
- public void file‿export‿markdown() {
- file‿export( MARKDOWN_PLAIN );
- }
-
- private void file‿export( final ExportFormat format ) {
- final var editor = getActiveTextEditor();
- final var context = createProcessorContext( editor );
- final var chain = createProcessors( context );
- final var doc = editor.getText();
- final var export = chain.apply( doc );
- final var filename = format.toExportFilename( editor.getPath() );
- final var chooser = createFileChooser();
- final var file = chooser.exportAs( new File( filename ) );
-
- file.ifPresent( ( f ) -> {
- try {
- writeString( f.toPath(), export );
- final var m = get( "Main.status.export.success", f.toString() );
- clue( m );
- } catch( final Exception e ) {
- clue( e );
- }
- } );
- }
-
- private ProcessorContext createProcessorContext( final TextEditor editor ) {
- return getMainPane().createProcessorContext( editor );
- }
-
- public void file‿exit() {
- final var window = getWindow();
- fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) );
- }
-
- public void edit‿undo() {
- getActiveTextEditor().undo();
- }
-
- public void edit‿redo() {
- getActiveTextEditor().redo();
- }
-
- public void edit‿cut() {
- getActiveTextEditor().cut();
- }
-
- public void edit‿copy() {
- getActiveTextEditor().copy();
- }
-
- public void edit‿paste() {
- getActiveTextEditor().paste();
- }
-
- public void edit‿select_all() {
- getActiveTextEditor().selectAll();
- }
-
- public void edit‿find() {
- final var nodes = getStatusBar().getLeftItems();
-
- if( nodes.isEmpty() ) {
- final var searchBar = new SearchBar();
-
- searchBar.matchIndexProperty().bind( mSearchModel.matchIndexProperty() );
- searchBar.matchCountProperty().bind( mSearchModel.matchCountProperty() );
-
- searchBar.setOnCancelAction( ( event ) -> {
- final var editor = getActiveTextEditor();
- nodes.remove( searchBar );
- editor.unstylize( STYLE_SEARCH );
- editor.getNode().requestFocus();
- } );
-
- searchBar.addInputListener( ( c, o, n ) -> {
- if( n != null && !n.isEmpty() ) {
- mSearchModel.search( n, getActiveTextEditor().getText() );
- }
- } );
-
- searchBar.setOnNextAction( ( event ) -> edit‿find_next() );
- searchBar.setOnPrevAction( ( event ) -> edit‿find_prev() );
-
- nodes.add( searchBar );
- searchBar.requestFocus();
- }
- else {
- nodes.clear();
- }
- }
-
- public void edit‿find_next() {
- mSearchModel.advance();
- }
-
- public void edit‿find_prev() {
- mSearchModel.retreat();
- }
-
- public void edit‿preferences() {
- UserPreferencesView.getInstance().show();
- }
-
- public void format‿bold() {
- getActiveTextEditor().bold();
- }
-
- public void format‿italic() {
- getActiveTextEditor().italic();
- }
-
- public void format‿superscript() {
- getActiveTextEditor().superscript();
- }
-
- public void format‿subscript() {
- getActiveTextEditor().subscript();
- }
-
- public void format‿strikethrough() {
- getActiveTextEditor().strikethrough();
- }
-
- public void insert‿blockquote() {
- getActiveTextEditor().blockquote();
- }
-
- public void insert‿code() {
- getActiveTextEditor().code();
- }
-
- public void insert‿fenced_code_block() {
- getActiveTextEditor().fencedCodeBlock();
- }
-
- public void insert‿link() {
- createMarkdownDialog().insertLink( getActiveTextEditor().getTextArea() );
- }
-
- public void insert‿image() {
- createMarkdownDialog().insertImage( getActiveTextEditor().getTextArea() );
- }
-
- private MarkdownCommands createMarkdownDialog() {
- return new MarkdownCommands( getWindow(), getActiveTextEditor().getPath() );
- }
-
- public void insert‿heading_1() {
- insert‿heading( 1 );
- }
-
- public void insert‿heading_2() {
- insert‿heading( 2 );
- }
-
- public void insert‿heading_3() {
- insert‿heading( 3 );
- }
-
- private void insert‿heading( final int level ) {
- getActiveTextEditor().heading( level );
- }
-
- public void insert‿unordered_list() {
- getActiveTextEditor().unorderedList();
- }
-
- public void insert‿ordered_list() {
- getActiveTextEditor().orderedList();
- }
-
- public void insert‿horizontal_rule() {
- getActiveTextEditor().horizontalRule();
- }
-
- public void definition‿create() {
- getActiveTextDefinition().createDefinition();
- }
-
- public void definition‿rename() {
- getActiveTextDefinition().renameDefinition();
- }
-
- public void definition‿delete() {
- getActiveTextDefinition().deleteDefinitions();
- }
-
- public void definition‿autoinsert() {
- getMainPane().autoinsert();
- }
-
- public void view‿refresh() {
- }
-
- public void view‿preview() {
- }
-
- public void help‿about() {
- final Alert alert = new Alert( INFORMATION );
- alert.setTitle( get( "Dialog.about.title", APP_TITLE ) );
- alert.setHeaderText( get( "Dialog.about.header", APP_TITLE ) );
- alert.setContentText( get( "Dialog.about.content" ) );
- alert.setGraphic( new ImageView( ICON_DIALOG ) );
- alert.initOwner( getWindow() );
- alert.showAndWait();
- }
-
- private FileChooserCommand createFileChooser() {
- final var dir = getPreferences().fileProperty( KEY_UI_RECENT_DIR );
- return new FileChooserCommand( getWindow(), dir );
- }
-
- private MainPane getMainPane() {
- return mMainPane;
- }
-
- private TextEditor getActiveTextEditor() {
- return getMainPane().getActiveTextEditor();
- }
-
- private TextDefinition getActiveTextDefinition() {
- return getMainPane().getActiveTextDefinition();
- }
-
- private WorkspacePreferences getPreferences() {
+import com.keenwrite.preferences.Workspace;
+import com.keenwrite.processors.ProcessorContext;
+import com.keenwrite.search.SearchModel;
+import com.keenwrite.ui.controls.SearchBar;
+import javafx.scene.control.Alert;
+import javafx.scene.image.ImageView;
+import javafx.stage.Window;
+import javafx.stage.WindowEvent;
+
+import static com.keenwrite.Bootstrap.APP_TITLE;
+import static com.keenwrite.Constants.ICON_DIALOG;
+import static com.keenwrite.ExportFormat.*;
+import static com.keenwrite.Messages.get;
+import static com.keenwrite.StatusBarNotifier.clue;
+import static com.keenwrite.StatusBarNotifier.getStatusBar;
+import static com.keenwrite.preferences.Workspace.KEY_UI_RECENT_DIR;
+import static com.keenwrite.processors.ProcessorFactory.createProcessors;
+import static java.nio.file.Files.writeString;
+import static javafx.event.Event.fireEvent;
+import static javafx.scene.control.Alert.AlertType.INFORMATION;
+import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST;
+
+/**
+ * Responsible for abstracting how functionality is mapped to the application.
+ * This allows users to customize accelerator keys and will provide pluggable
+ * functionality so that different text markup languages can change documents
+ * using their respective syntax.
+ */
+@SuppressWarnings("NonAsciiCharacters")
+public class ApplicationActions {
+ private static final String STYLE_SEARCH = "search";
+
+ /**
+ * When an action is executed, this is one of the recipients.
+ */
+ private final MainPane mMainPane;
+
+ /**
+ * Tracks finding text in the active document.
+ */
+ private final SearchModel mSearchModel;
+
+ public ApplicationActions( final MainPane mainPane ) {
+ mMainPane = mainPane;
+ 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( STYLE_SEARCH );
+ }
+
+ if( n != null ) {
+ editor.moveTo( n.getStart() );
+ editor.stylize( n, STYLE_SEARCH );
+ }
+ } );
+
+ // When the active text editor changes, update the haystack.
+ mMainPane.activeTextEditorProperty().addListener(
+ ( c, o, n ) -> mSearchModel.search( getActiveTextEditor().getText() )
+ );
+ }
+
+ public void file‿new() {
+ getMainPane().newTextEditor();
+ }
+
+ public void file‿open() {
+ getMainPane().open( createFileChooser().openFiles() );
+ }
+
+ public void file‿close() {
+ getMainPane().close();
+ }
+
+ public void file‿close_all() {
+ getMainPane().closeAll();
+ }
+
+ public void file‿save() {
+ getMainPane().save();
+ }
+
+ public void file‿save_as() {
+ final var file = createFileChooser().saveAs();
+ file.ifPresent( ( f ) -> getMainPane().saveAs( f ) );
+ }
+
+ public void file‿save_all() {
+ getMainPane().saveAll();
+ }
+
+ public void file‿export‿html_svg() {
+ file‿export( HTML_TEX_SVG );
+ }
+
+ public void file‿export‿html_tex() {
+ file‿export( HTML_TEX_DELIMITED );
+ }
+
+ public void file‿export‿markdown() {
+ file‿export( MARKDOWN_PLAIN );
+ }
+
+ private void file‿export( final ExportFormat format ) {
+ final var editor = getActiveTextEditor();
+ final var context = createProcessorContext( editor );
+ final var chain = createProcessors( context );
+ final var doc = editor.getText();
+ final var export = chain.apply( doc );
+ final var filename = format.toExportFilename( editor.getPath() );
+ final var chooser = createFileChooser();
+ final var file = chooser.exportAs( new File( filename ) );
+
+ file.ifPresent( ( f ) -> {
+ try {
+ writeString( f.toPath(), export );
+ final var m = get( "Main.status.export.success", f.toString() );
+ clue( m );
+ } catch( final Exception e ) {
+ clue( e );
+ }
+ } );
+ }
+
+ private ProcessorContext createProcessorContext( final TextEditor editor ) {
+ return getMainPane().createProcessorContext( editor );
+ }
+
+ public void file‿exit() {
+ final var window = getWindow();
+ fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) );
+ }
+
+ public void edit‿undo() {
+ getActiveTextEditor().undo();
+ }
+
+ public void edit‿redo() {
+ getActiveTextEditor().redo();
+ }
+
+ public void edit‿cut() {
+ getActiveTextEditor().cut();
+ }
+
+ public void edit‿copy() {
+ getActiveTextEditor().copy();
+ }
+
+ public void edit‿paste() {
+ getActiveTextEditor().paste();
+ }
+
+ public void edit‿select_all() {
+ getActiveTextEditor().selectAll();
+ }
+
+ public void edit‿find() {
+ final var nodes = getStatusBar().getLeftItems();
+
+ if( nodes.isEmpty() ) {
+ final var searchBar = new SearchBar();
+
+ searchBar.matchIndexProperty().bind( mSearchModel.matchIndexProperty() );
+ searchBar.matchCountProperty().bind( mSearchModel.matchCountProperty() );
+
+ searchBar.setOnCancelAction( ( event ) -> {
+ final var editor = getActiveTextEditor();
+ nodes.remove( searchBar );
+ editor.unstylize( STYLE_SEARCH );
+ editor.getNode().requestFocus();
+ } );
+
+ searchBar.addInputListener( ( c, o, n ) -> {
+ if( n != null && !n.isEmpty() ) {
+ mSearchModel.search( n, getActiveTextEditor().getText() );
+ }
+ } );
+
+ searchBar.setOnNextAction( ( event ) -> edit‿find_next() );
+ searchBar.setOnPrevAction( ( event ) -> edit‿find_prev() );
+
+ nodes.add( searchBar );
+ searchBar.requestFocus();
+ }
+ else {
+ nodes.clear();
+ }
+ }
+
+ public void edit‿find_next() {
+ mSearchModel.advance();
+ }
+
+ public void edit‿find_prev() {
+ mSearchModel.retreat();
+ }
+
+ public void edit‿preferences() {
+ UserPreferencesView.getInstance().show();
+ }
+
+ public void format‿bold() {
+ getActiveTextEditor().bold();
+ }
+
+ public void format‿italic() {
+ getActiveTextEditor().italic();
+ }
+
+ public void format‿superscript() {
+ getActiveTextEditor().superscript();
+ }
+
+ public void format‿subscript() {
+ getActiveTextEditor().subscript();
+ }
+
+ public void format‿strikethrough() {
+ getActiveTextEditor().strikethrough();
+ }
+
+ public void insert‿blockquote() {
+ getActiveTextEditor().blockquote();
+ }
+
+ public void insert‿code() {
+ getActiveTextEditor().code();
+ }
+
+ public void insert‿fenced_code_block() {
+ getActiveTextEditor().fencedCodeBlock();
+ }
+
+ public void insert‿link() {
+ createMarkdownDialog().insertLink( getActiveTextEditor().getTextArea() );
+ }
+
+ public void insert‿image() {
+ createMarkdownDialog().insertImage( getActiveTextEditor().getTextArea() );
+ }
+
+ private MarkdownCommands createMarkdownDialog() {
+ return new MarkdownCommands( getWindow(), getActiveTextEditor().getPath() );
+ }
+
+ public void insert‿heading_1() {
+ insert‿heading( 1 );
+ }
+
+ public void insert‿heading_2() {
+ insert‿heading( 2 );
+ }
+
+ public void insert‿heading_3() {
+ insert‿heading( 3 );
+ }
+
+ private void insert‿heading( final int level ) {
+ getActiveTextEditor().heading( level );
+ }
+
+ public void insert‿unordered_list() {
+ getActiveTextEditor().unorderedList();
+ }
+
+ public void insert‿ordered_list() {
+ getActiveTextEditor().orderedList();
+ }
+
+ public void insert‿horizontal_rule() {
+ getActiveTextEditor().horizontalRule();
+ }
+
+ public void definition‿create() {
+ getActiveTextDefinition().createDefinition();
+ }
+
+ public void definition‿rename() {
+ getActiveTextDefinition().renameDefinition();
+ }
+
+ public void definition‿delete() {
+ getActiveTextDefinition().deleteDefinitions();
+ }
+
+ public void definition‿autoinsert() {
+ getMainPane().autoinsert();
+ }
+
+ public void view‿refresh() {
+ }
+
+ public void view‿preview() {
+ }
+
+ public void help‿about() {
+ final Alert alert = new Alert( INFORMATION );
+ alert.setTitle( get( "Dialog.about.title", APP_TITLE ) );
+ alert.setHeaderText( get( "Dialog.about.header", APP_TITLE ) );
+ alert.setContentText( get( "Dialog.about.content" ) );
+ alert.setGraphic( new ImageView( ICON_DIALOG ) );
+ alert.initOwner( getWindow() );
+ alert.showAndWait();
+ }
+
+ private FileChooserCommand createFileChooser() {
+ final var dir = getPreferences().fileProperty( KEY_UI_RECENT_DIR );
+ return new FileChooserCommand( getWindow(), dir );
+ }
+
+ private MainPane getMainPane() {
+ return mMainPane;
+ }
+
+ private TextEditor getActiveTextEditor() {
+ return getMainPane().getActiveTextEditor();
+ }
+
+ private TextDefinition getActiveTextDefinition() {
+ return getMainPane().getActiveTextDefinition();
+ }
+
+ private Workspace getPreferences() {
return mMainPane.getPreferences();
}
Delta1460 lines added, 1490 lines removed, 30-line decrease