Dave Jarvis' Repositories

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

Replace reference to GUI widget in processor context

AuthorDaveJarvis <email>
Date2021-12-14 21:06:40 GMT-0800
Commitbb3185acef5950635aa8ba01cc50264c5cacadfc
Parent02b7108
src/main/java/com/keenwrite/MainPane.java
* 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;
-
- /**
- * Called when the definition data is changed.
- */
- private final EventHandler<TreeModificationEvent<Event>> mTreeHandler =
- event -> {
- process( getActiveTextEditor() );
- save( getActiveTextDefinition() );
- };
-
- /**
- * 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;
-
- private final DocumentStatistics mStatistics;
-
- /**
- * 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;
- mPreview = new HtmlPreview( workspace );
- mStatistics = new DocumentStatistics( workspace );
- mActiveTextEditor.set( new MarkdownEditor( workspace ) );
- mActiveDefinitionEditor = createActiveDefinitionEditor( mActiveTextEditor );
-
- open( collect( getRecentFiles() ) );
- viewPreview();
- setDividerPositions( calculateDividerPositions() );
-
- // Once the main scene's window regains focus, update the active definition
- // editor to the currently selected tab.
- runLater( () -> getWindow().setOnCloseRequest( ( event ) -> {
- // Order matters here. We want to close all the tabs to ensure each
- // is saved, but after they are closed, the workspace should still
- // retain the list of files that were open. If this line came after
- // closing, then restarting the application would list no files.
- mWorkspace.save();
-
- if( closeAll() ) {
- Platform.exit();
- System.exit( 0 );
- }
- else {
- event.consume();
- }
- } ) );
-
- register( this );
- initAutosave( workspace );
- }
-
- @Subscribe
- public void handle( final TextEditorFocusEvent event ) {
- mActiveTextEditor.set( event.get() );
- }
-
- @Subscribe
- public void handle( final TextDefinitionFocusEvent event ) {
- mActiveDefinitionEditor.set( event.get() );
- }
-
- /**
- * Typically called when a file name is clicked in the preview panel.
- *
- * @param event The event to process, must contain a valid file reference.
- */
- @Subscribe
- public void handle( final FileOpenEvent event ) {
- final File eventFile;
- final var eventUri = event.getUri();
-
- if( eventUri.isAbsolute() ) {
- eventFile = new File( eventUri.getPath() );
- }
- else {
- final var activeFile = getActiveTextEditor().getFile();
- final var parent = activeFile.getParentFile();
-
- if( parent == null ) {
- clue( new FileNotFoundException( eventUri.getPath() ) );
- return;
- }
- else {
- final var parentPath = parent.getAbsolutePath();
- eventFile = Path.of( parentPath, eventUri.getPath() ).toFile();
- }
- }
-
- runLater( () -> open( eventFile ) );
- }
-
- @Subscribe
- public void handle( final CaretNavigationEvent event ) {
- runLater( () -> {
- final var textArea = getActiveTextEditor().getTextArea();
- textArea.moveTo( event.getOffset() );
- textArea.requestFollowCaret();
- textArea.requestFocus();
- } );
- }
-
- @Subscribe
- @SuppressWarnings( "unused" )
- public void handle( final ExportFailedEvent event ) {
- final var os = getProperty( "os.name" );
- final var arch = getProperty( "os.arch" ).toLowerCase();
- final var bits = getProperty( "sun.arch.data.model" );
-
- final var title = Messages.get( "Alert.typesetter.missing.title" );
- final var header = Messages.get( "Alert.typesetter.missing.header" );
- final var version = Messages.get(
- "Alert.typesetter.missing.version",
- os,
- arch
- .replaceAll( "amd.*|i.*|x86.*", "X86" )
- .replaceAll( "mips.*", "MIPS" )
- .replaceAll( "armv.*", "ARM" ),
- bits );
- final var text = Messages.get( "Alert.typesetter.missing.installer.text" );
-
- // Download and install ConTeXt for {0} {1} {2}-bit
- final var content = format( "%s %s", text, version );
- final var flowPane = new FlowPane();
- final var link = new Hyperlink( text );
- final var label = new Label( version );
- flowPane.getChildren().addAll( link, label );
-
- final var alert = new Alert( ERROR, content, OK );
- alert.setTitle( title );
- alert.setHeaderText( header );
- alert.getDialogPane().contentProperty().set( flowPane );
- alert.setGraphic( ICON_DIALOG_NODE );
-
- link.setOnAction( ( e ) -> {
- alert.close();
- final var url = Messages.get( "Alert.typesetter.missing.installer.url" );
- runLater( () -> HyperlinkOpenEvent.fire( url ) );
- } );
-
- alert.showAndWait();
- }
-
- private void initAutosave( final Workspace workspace ) {
- final var rate = workspace.integerProperty( KEY_EDITOR_AUTOSAVE );
-
- rate.addListener(
- ( c, o, n ) -> {
- final var taskRef = mSaveTask.get();
-
- // Prevent multiple autosaves from running.
- if( taskRef != null ) {
- taskRef.cancel( false );
- }
-
- initAutosave( rate );
- }
- );
-
- // Start the save listener (avoids duplicating some code).
- initAutosave( rate );
- }
-
- private void initAutosave( final IntegerProperty rate ) {
- mSaveTask.set(
- mSaver.scheduleAtFixedRate(
- () -> {
- if( getActiveTextEditor().isModified() ) {
- // Ensure the modified indicator is cleared by running on EDT.
- runLater( this::save );
- }
- }, 0, rate.intValue(), SECONDS
- )
- );
- }
-
- /**
- * TODO: Load divider positions from exported settings, see
- * {@link #collect(SetProperty)} comment.
- */
- private double[] calculateDividerPositions() {
- final var ratio = 100f / getItems().size() / 100;
- final var positions = getDividerPositions();
-
- for( int i = 0; i < positions.length; i++ ) {
- positions[ i ] = ratio * i;
- }
-
- return positions;
- }
-
- /**
- * Opens all the files into the application, provided the paths are unique.
- * This may only be called for any type of files that a user can edit
- * (i.e., update and persist), such as definitions and text files.
- *
- * @param files The list of files to open.
- */
- public void open( final List<File> files ) {
- files.forEach( this::open );
- }
-
- /**
- * This opens the given file. Since the preview pane is not a file that
- * can be opened, it is safe to add a listener to the detachable pane.
- *
- * @param inputFile The file to open.
- */
- private void open( final File inputFile ) {
- final var tab = createTab( inputFile );
- final var node = tab.getContent();
- final var mediaType = MediaType.valueFrom( inputFile );
- final var tabPane = obtainTabPane( mediaType );
-
- tab.setTooltip( createTooltip( inputFile ) );
- tabPane.setFocusTraversable( false );
- tabPane.setTabClosingPolicy( ALL_TABS );
- tabPane.getTabs().add( tab );
-
- // Attach the tab scene factory for new tab panes.
- if( !getItems().contains( tabPane ) ) {
- addTabPane(
- node instanceof TextDefinition ? 0 : getItems().size(), tabPane
- );
- }
-
- getRecentFiles().add( inputFile.getAbsolutePath() );
- }
-
- /**
- * 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(
- ( tp ) -> tp.getTabs().forEach( ( tab ) -> {
- final var node = tab.getContent();
- if( node instanceof final TextEditor editor ) {
- save( editor );
- }
- } )
- );
- }
-
- /**
- * 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 files The new active editor {@link File} reference, must contain
- * at least one element.
- */
- public void saveAs( final List<File> files ) {
- assert files != null;
- assert !files.isEmpty();
- final var editor = getActiveTextEditor();
- final var tab = getTab( editor );
- final var file = files.get( 0 );
-
- 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 tabPane : mTabPanes ) {
- final var tabIterator = tabPane.getTabs().iterator();
-
- while( tabIterator.hasNext() ) {
- final var tab = tabIterator.next();
- final var resource = tab.getContent();
-
- // The definition panes auto-save, so being specific here prevents
- // closing the definitions in the situation where the user wants to
- // continue editing (i.e., possibly save unsaved work).
- if( !(resource instanceof TextEditor) ) {
- continue;
- }
-
- if( canClose( (TextEditor) resource ) ) {
- tabIterator.remove();
- close( tab );
- }
- else {
- closable = false;
- }
- }
- }
-
- return closable;
- }
-
- /**
- * Calls the tab's {@link Tab#getOnClosed()} handler to carry out a close
- * event.
- *
- * @param tab The {@link Tab} that was closed.
- */
- private void close( final Tab tab ) {
- assert tab != null;
-
- final var handler = tab.getOnClosed();
-
- if( handler != null ) {
- handler.handle( new ActionEvent() );
- }
- }
-
- /**
- * Closes the active tab; delegates to {@link #canClose(TextResource)}.
- */
- public void close() {
- final var editor = getActiveTextEditor();
-
- if( canClose( editor ) ) {
- close( editor );
- }
- }
-
- /**
- * Closes the given {@link TextResource}. This must not be called from within
- * a loop that iterates over the tab panes using {@code forEach}, lest a
- * concurrent modification exception be thrown.
- *
- * @param resource The {@link TextResource} to close, without confirming with
- * the user.
- */
- private void close( final TextResource resource ) {
- getTab( resource ).ifPresent(
- ( tab ) -> {
- close( tab );
- tab.getTabPane().getTabs().remove( tab );
- }
- );
- }
-
- /**
- * Answers whether the given {@link TextResource} may be closed.
- *
- * @param editor The {@link TextResource} to try closing.
- * @return {@code true} when the editor may be closed; {@code false} when
- * the user has requested to keep the editor open.
- */
- private boolean canClose( final TextResource editor ) {
- final var editorTab = getTab( editor );
- final var canClose = new AtomicBoolean( true );
-
- if( editor.isModified() ) {
- final var filename = new StringBuilder();
- editorTab.ifPresent( ( tab ) -> filename.append( tab.getText() ) );
-
- final var message = sNotifier.createNotification(
- Messages.get( "Alert.file.close.title" ),
- Messages.get( "Alert.file.close.text" ),
- filename.toString()
- );
-
- final var dialog = sNotifier.createConfirmation( getWindow(), message );
-
- dialog.showAndWait().ifPresent(
- save -> canClose.set( save == YES ? editor.save() : save == NO )
- );
- }
-
- return canClose.get();
- }
-
- private ObjectProperty<TextEditor> createActiveTextEditor() {
- final var editor = new SimpleObjectProperty<TextEditor>();
-
- editor.addListener( ( c, o, n ) -> {
- if( n != null ) {
- mPreview.setBaseUri( n.getPath() );
- process( n );
- }
- } );
-
- return editor;
- }
-
- /**
- * Adds the HTML preview tab to its own, singular tab pane.
- */
- public void viewPreview() {
- viewTab( mPreview, TEXT_HTML, "Pane.preview.title" );
- }
-
- /**
- * Adds the document outline tab to its own, singular tab pane.
- */
- public void viewOutline() {
- viewTab( mOutline, APP_DOCUMENT_OUTLINE, "Pane.outline.title" );
- }
-
- public void viewStatistics() {
- viewTab( mStatistics, APP_DOCUMENT_STATISTICS, "Pane.statistics.title" );
- }
-
- public void viewFiles() {
- try {
- final var factory = new FilePickerFactory( getWorkspace() );
- final var fileManager = factory.createModeless();
- viewTab( fileManager, APP_FILE_MANAGER, "Pane.files.title" );
- } catch( final Exception ex ) {
- clue( ex );
- }
- }
-
- private void viewTab(
- final Node node, final MediaType mediaType, final String key ) {
- final var tabPane = obtainTabPane( mediaType );
-
- for( final var tab : tabPane.getTabs() ) {
- if( tab.getContent() == node ) {
- return;
- }
- }
-
- tabPane.getTabs().add( createTab( get( key ), node ) );
- addTabPane( tabPane );
- }
-
- public void viewRefresh() {
- mPreview.refresh();
- }
-
- /**
- * Returns the tab that contains the given {@link TextEditor}.
- *
- * @param editor The {@link TextEditor} instance to find amongst the tabs.
- * @return The first tab having content that matches the given tab.
- */
- private Optional<Tab> getTab( final TextResource editor ) {
- return mTabPanes.stream()
- .flatMap( pane -> pane.getTabs().stream() )
- .filter( tab -> editor.equals( tab.getContent() ) )
- .findFirst();
- }
-
- /**
- * Creates a new {@link DefinitionEditor} wrapped in a listener that
- * is used to detect when the active {@link DefinitionEditor} has changed.
- * Upon changing, the variables are interpolated and the active text editor
- * is refreshed.
- *
- * @param textEditor 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> textEditor ) {
- final var defEditor = new SimpleObjectProperty<>(
- createDefinitionEditor()
- );
-
- defEditor.addListener( ( c, o, n ) -> process( textEditor.get() ) );
-
- return defEditor;
- }
-
- private Tab createTab( final String filename, final Node node ) {
- return new DetachableTab( filename, node );
- }
-
- private Tab createTab( final File file ) {
- final var r = createTextResource( file );
- final var tab = createTab( r.getFilename(), r.getNode() );
-
- r.modifiedProperty().addListener(
- ( c, o, n ) -> tab.setText( r.getFilename() + (n ? "*" : "") )
- );
-
- // This is called when either the tab is closed by the user clicking on
- // the tab's close icon or when closing (all) from the file menu.
- tab.setOnClosed(
- ( __ ) -> getRecentFiles().remove( file.getAbsolutePath() )
- );
-
- // When closing a tab, give focus to the newly revealed tab.
- tab.selectedProperty().addListener( ( c, o, n ) -> {
- if( n != null && n ) {
- final var pane = tab.getTabPane();
-
- if( pane != null ) {
- pane.requestFocus();
- }
- }
- } );
-
- tab.tabPaneProperty().addListener( ( cPane, oPane, nPane ) -> {
- if( nPane != null ) {
- nPane.focusedProperty().addListener( ( c, o, n ) -> {
- if( n != null && n ) {
- final var selected = nPane.getSelectionModel().getSelectedItem();
- final var node = selected.getContent();
- node.requestFocus();
- }
- } );
- }
- } );
-
- return tab;
- }
-
- /**
- * Creates bins for the different {@link MediaType}s, which eventually are
- * added to the UI as separate tab panes. If ever a general-purpose scene
- * exporter is developed to serialize a scene to an FXML file, this could
- * be replaced by such a class.
- * <p>
- * When binning the files, this makes sure that at least one file exists
- * for every type. If the user has opted to close a particular type (such
- * as the definition pane), the view will suppressed elsewhere.
- * </p>
- * <p>
- * The order that the binned files are returned will be reflected in the
- * order that the corresponding panes are rendered in the UI.
- * </p>
- *
- * @param paths The file paths to bin according to their type.
- * @return An in-order list of files, first by structured definition files,
- * then by plain text documents.
- */
- private List<File> collect( final SetProperty<String> paths ) {
- // Treat all files destined for the text editor as plain text documents
- // so that they are added to the same pane. Grouping by TEXT_PLAIN is a
- // bit arbitrary, but means explicitly capturing TEXT_PLAIN isn't needed.
- final Function<MediaType, MediaType> bin =
- m -> PLAIN_TEXT_FORMAT.contains( m ) ? TEXT_PLAIN : m;
-
- // Create two groups: YAML files and plain text files.
- final var bins = paths
- .stream()
- .collect(
- groupingBy( path -> bin.apply( MediaType.fromFilename( path ) ) )
- );
-
- bins.putIfAbsent( TEXT_YAML, List.of( DEFINITION_DEFAULT.toString() ) );
- bins.putIfAbsent( TEXT_PLAIN, List.of( DOCUMENT_DEFAULT.toString() ) );
-
- final var result = new ArrayList<File>( paths.size() );
-
- // Ensure that the same types are listed together (keep insertion order).
- bins.forEach( ( mediaType, files ) -> result.addAll(
- files.stream().map( File::new ).collect( Collectors.toList() ) )
- );
-
- return result;
- }
-
- /**
- * Force the active editor to update, which will cause the processor
- * to re-evaluate the interpolated definition map thereby updating the
- * preview pane.
- *
- * @param editor Contains the source document to update in the preview pane.
- */
- private void process( final TextEditor editor ) {
- // Ensure processing does not run on the JavaFX thread, which frees the
- // text editor immediately for caret movement. The preview will have a
- // slight delay when catching up to the caret position.
- final var task = new Task<Void>() {
- @Override
- public Void call() {
- try {
- final var p = mProcessors.getOrDefault( editor, IDENTITY );
- p.apply( editor == null ? "" : editor.getText() );
- } catch( final Exception ex ) {
- clue( ex );
- }
-
- return null;
- }
- };
-
- task.setOnSucceeded(
- e -> invokeLater( () -> mPreview.scrollTo( CARET_ID ) )
- );
-
- // Prevents multiple process requests from executing simultaneously (due
- // to having a restricted queue size).
- sExecutor.execute( task );
- }
-
- /**
- * Lazily creates a {@link TabPane} configured to listen for tab select
- * events. The tab pane is associated with a given media type so that
- * similar files can be grouped together.
- *
- * @param mediaType The media type to associate with the tab pane.
- * @return An instance of {@link TabPane} that will handle tab docking.
- */
- private TabPane obtainTabPane( final MediaType mediaType ) {
- for( final var pane : mTabPanes ) {
- for( final var tab : pane.getTabs() ) {
- final var node = tab.getContent();
-
- if( node instanceof TextResource r && r.supports( mediaType ) ) {
- return pane;
- }
- }
- }
-
- final var pane = createTabPane();
- mTabPanes.add( pane );
- return pane;
- }
-
- /**
- * Creates an initialized {@link TabPane} instance.
- *
- * @return A new {@link TabPane} with all listeners configured.
- */
- private TabPane createTabPane() {
- final var tabPane = new DetachableTabPane();
-
- initStageOwnerFactory( tabPane );
- initTabListener( tabPane );
-
- return tabPane;
- }
-
- /**
- * When any {@link DetachableTabPane} is detached from the main window,
- * the stage owner factory must be given its parent window, which will
- * own the child window. The parent window is the {@link MainPane}'s
- * {@link Scene}'s {@link Window} instance.
- *
- * <p>
- * This will derives the new title from the main window title, incrementing
- * the window count to help uniquely identify the child windows.
- * </p>
- *
- * @param tabPane A new {@link DetachableTabPane} to configure.
- */
- private void initStageOwnerFactory( final DetachableTabPane tabPane ) {
- tabPane.setStageOwnerFactory( ( stage ) -> {
- final var title = get(
- "Detach.tab.title",
- ((Stage) getWindow()).getTitle(), ++mWindowCount
- );
- stage.setTitle( title );
-
- return getScene().getWindow();
- } );
- }
-
- /**
- * Responsible for configuring the content of each {@link DetachableTab} when
- * it is added to the given {@link DetachableTabPane} instance.
- * <p>
- * For {@link TextEditor} contents, an instance of {@link ScrollEventHandler}
- * is initialized to perform synchronized scrolling between the editor and
- * its preview window. Additionally, the last tab in the tab pane's list of
- * tabs is given focus.
- * </p>
- * <p>
- * Note that multiple tabs can be added simultaneously.
- * </p>
- *
- * @param tabPane A new {@link TabPane} to configure.
- */
- private void initTabListener( final TabPane tabPane ) {
- tabPane.getTabs().addListener(
- ( final ListChangeListener.Change<? extends Tab> listener ) -> {
- while( listener.next() ) {
- if( listener.wasAdded() ) {
- final var tabs = listener.getAddedSubList();
-
- tabs.forEach( ( tab ) -> {
- final var node = tab.getContent();
-
- if( node instanceof TextEditor ) {
- initScrollEventListener( tab );
- }
- } );
-
- // Select and give focus to the last tab opened.
- final var index = tabs.size() - 1;
- if( index >= 0 ) {
- final var tab = tabs.get( index );
- tabPane.getSelectionModel().select( tab );
- tab.getContent().requestFocus();
- }
- }
- }
- }
- );
- }
-
- /**
- * Synchronizes scrollbar positions between the given {@link Tab} that
- * contains an instance of {@link TextEditor} and {@link HtmlPreview} pane.
- *
- * @param tab The container for an instance of {@link TextEditor}.
- */
- private void initScrollEventListener( final Tab tab ) {
- final var editor = (TextEditor) tab.getContent();
- final var scrollPane = editor.getScrollPane();
- final var scrollBar = mPreview.getVerticalScrollBar();
- final var handler = new ScrollEventHandler( scrollPane, scrollBar );
- handler.enabledProperty().bind( tab.selectedProperty() );
- }
-
- private void addTabPane( final int index, final TabPane tabPane ) {
- final var items = getItems();
- if( !items.contains( tabPane ) ) {
- items.add( index, tabPane );
- }
- }
-
- private void addTabPane( final TabPane tabPane ) {
- addTabPane( getItems().size(), tabPane );
- }
-
- public ProcessorContext createProcessorContext() {
- return createProcessorContext( null, NONE );
- }
-
- public ProcessorContext createProcessorContext(
- final Path exportPath, final ExportFormat format ) {
- final var textEditor = getActiveTextEditor();
- return createProcessorContext(
- textEditor.getPath(), exportPath, format, textEditor.getCaret() );
- }
-
- private ProcessorContext createProcessorContext(
- final Path inputPath, final Caret caret ) {
- return createProcessorContext( inputPath, null, NONE, caret );
- }
-
- /**
- * @param inputPath Used by {@link ProcessorFactory} to determine
- * {@link Processor} type to create based on file type.
- * @param outputPath Used when exporting to a PDF file (binary).
- * @param format Used when processors export to a new text format.
- * @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 inputPath,
- final Path outputPath,
- final ExportFormat format,
- final Caret caret ) {
- return builder()
- .with( Mutator::setInputPath, inputPath )
- .with( Mutator::setOutputPath, outputPath )
- .with( Mutator::setExportFormat, format )
- .with( Mutator::setHtmlPreview, mPreview )
- .with( Mutator::setTextDefinition, mActiveDefinitionEditor )
- .with( Mutator::setWorkspace, mWorkspace )
- .with( Mutator::setCaret, caret )
- .build();
- }
-
- private TextResource createTextResource( final File file ) {
- // TODO: Create PlainTextEditor that's returned by default.
- return MediaType.valueFrom( file ) == TEXT_YAML
- ? createDefinitionEditor( file )
- : createMarkdownEditor( file );
- }
-
- /**
- * Creates an instance of {@link MarkdownEditor} that listens for both
- * caret change events and text change events. Text change events must
- * take priority over caret change events because it's possible to change
- * the text without moving the caret (e.g., delete selected text).
- *
- * @param inputFile The file containing contents for the text editor.
- * @return A non-null text editor.
- */
- private TextResource createMarkdownEditor( final File inputFile ) {
- final var inputPath = inputFile.toPath();
- final var editor = new MarkdownEditor( inputFile, getWorkspace() );
- final var caret = editor.getCaret();
- final var context = createProcessorContext( inputPath, caret );
-
- mProcessors.computeIfAbsent( editor, p -> createProcessors( context ) );
-
- editor.addDirtyListener( ( c, o, n ) -> {
- if( n ) {
- // Reset the status to OK after changing the text.
- clue();
-
- // Processing the text may update the status bar.
- process( getActiveTextEditor() );
- }
- } );
-
- editor.addEventListener(
- keyPressed( SPACE, CONTROL_DOWN ), this::autoinsert
- );
-
- // Set the active editor, which refreshes the preview panel.
- mActiveTextEditor.set( editor );
-
- return editor;
- }
-
- /**
- * Delegates to {@link #autoinsert()}.
- *
- * @param event Ignored.
- */
- private void autoinsert( final KeyEvent event ) {
- autoinsert();
- }
-
- /**
- * Finds a node that matches the word at the caret, then inserts the
- * corresponding definition. The definition token delimiters depend on
- * the type of file being edited.
- */
- public void autoinsert() {
- final var definitions = getActiveTextDefinition();
- final var editor = getActiveTextEditor();
- final var mediaType = editor.getMediaType();
- final var operator = createSigilOperator( mediaType );
-
- DefinitionNameInjector.autoinsert( editor, definitions, operator );
- }
-
- private TextDefinition createDefinitionEditor() {
- return createDefinitionEditor( DEFINITION_DEFAULT );
- }
-
- private TextDefinition createDefinitionEditor( final File file ) {
- final var editor = new DefinitionEditor(
- file, createTreeTransformer(), getWorkspace().createYamlSigilOperator() );
- editor.addTreeChangeHandler( mTreeHandler );
- return editor;
- }
-
- private TreeTransformer createTreeTransformer() {
- return new YamlTreeTransformer();
- }
-
- private Tooltip createTooltip( final File file ) {
- final var path = file.toPath();
- final var tooltip = new Tooltip( path.toString() );
-
- tooltip.setShowDelay( millis( 200 ) );
- return tooltip;
- }
-
- public TextEditor getActiveTextEditor() {
- return mActiveTextEditor.get();
- }
-
- public ReadOnlyObjectProperty<TextEditor> activeTextEditorProperty() {
- return mActiveTextEditor;
- }
-
- public TextDefinition getActiveTextDefinition() {
- return mActiveDefinitionEditor.get();
+ private final ObjectProperty<TextEditor> mTextEditor =
+ 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> mDefinitionEditor;
+
+ /**
+ * Called when the definition data is changed.
+ */
+ private final EventHandler<TreeModificationEvent<Event>> mTreeHandler =
+ event -> {
+ process( getTextEditor() );
+ save( getTextDefinition() );
+ };
+
+ /**
+ * 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;
+
+ private final DocumentStatistics mStatistics;
+
+ /**
+ * 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;
+ mPreview = new HtmlPreview( workspace );
+ mStatistics = new DocumentStatistics( workspace );
+ mTextEditor.set( new MarkdownEditor( workspace ) );
+ mDefinitionEditor = createActiveDefinitionEditor( mTextEditor );
+
+ open( collect( getRecentFiles() ) );
+ viewPreview();
+ setDividerPositions( calculateDividerPositions() );
+
+ // Once the main scene's window regains focus, update the active definition
+ // editor to the currently selected tab.
+ runLater( () -> getWindow().setOnCloseRequest( ( event ) -> {
+ // Order matters here. We want to close all the tabs to ensure each
+ // is saved, but after they are closed, the workspace should still
+ // retain the list of files that were open. If this line came after
+ // closing, then restarting the application would list no files.
+ mWorkspace.save();
+
+ if( closeAll() ) {
+ Platform.exit();
+ System.exit( 0 );
+ }
+ else {
+ event.consume();
+ }
+ } ) );
+
+ register( this );
+ initAutosave( workspace );
+ }
+
+ @Subscribe
+ public void handle( final TextEditorFocusEvent event ) {
+ mTextEditor.set( event.get() );
+ }
+
+ @Subscribe
+ public void handle( final TextDefinitionFocusEvent event ) {
+ mDefinitionEditor.set( event.get() );
+ }
+
+ /**
+ * Typically called when a file name is clicked in the preview panel.
+ *
+ * @param event The event to process, must contain a valid file reference.
+ */
+ @Subscribe
+ public void handle( final FileOpenEvent event ) {
+ final File eventFile;
+ final var eventUri = event.getUri();
+
+ if( eventUri.isAbsolute() ) {
+ eventFile = new File( eventUri.getPath() );
+ }
+ else {
+ final var activeFile = getTextEditor().getFile();
+ final var parent = activeFile.getParentFile();
+
+ if( parent == null ) {
+ clue( new FileNotFoundException( eventUri.getPath() ) );
+ return;
+ }
+ else {
+ final var parentPath = parent.getAbsolutePath();
+ eventFile = Path.of( parentPath, eventUri.getPath() ).toFile();
+ }
+ }
+
+ runLater( () -> open( eventFile ) );
+ }
+
+ @Subscribe
+ public void handle( final CaretNavigationEvent event ) {
+ runLater( () -> {
+ final var textArea = getTextEditor().getTextArea();
+ textArea.moveTo( event.getOffset() );
+ textArea.requestFollowCaret();
+ textArea.requestFocus();
+ } );
+ }
+
+ @Subscribe
+ @SuppressWarnings( "unused" )
+ public void handle( final ExportFailedEvent event ) {
+ final var os = getProperty( "os.name" );
+ final var arch = getProperty( "os.arch" ).toLowerCase();
+ final var bits = getProperty( "sun.arch.data.model" );
+
+ final var title = Messages.get( "Alert.typesetter.missing.title" );
+ final var header = Messages.get( "Alert.typesetter.missing.header" );
+ final var version = Messages.get(
+ "Alert.typesetter.missing.version",
+ os,
+ arch
+ .replaceAll( "amd.*|i.*|x86.*", "X86" )
+ .replaceAll( "mips.*", "MIPS" )
+ .replaceAll( "armv.*", "ARM" ),
+ bits );
+ final var text = Messages.get( "Alert.typesetter.missing.installer.text" );
+
+ // Download and install ConTeXt for {0} {1} {2}-bit
+ final var content = format( "%s %s", text, version );
+ final var flowPane = new FlowPane();
+ final var link = new Hyperlink( text );
+ final var label = new Label( version );
+ flowPane.getChildren().addAll( link, label );
+
+ final var alert = new Alert( ERROR, content, OK );
+ alert.setTitle( title );
+ alert.setHeaderText( header );
+ alert.getDialogPane().contentProperty().set( flowPane );
+ alert.setGraphic( ICON_DIALOG_NODE );
+
+ link.setOnAction( ( e ) -> {
+ alert.close();
+ final var url = Messages.get( "Alert.typesetter.missing.installer.url" );
+ runLater( () -> HyperlinkOpenEvent.fire( url ) );
+ } );
+
+ alert.showAndWait();
+ }
+
+ private void initAutosave( final Workspace workspace ) {
+ final var rate = workspace.integerProperty( KEY_EDITOR_AUTOSAVE );
+
+ rate.addListener(
+ ( c, o, n ) -> {
+ final var taskRef = mSaveTask.get();
+
+ // Prevent multiple autosaves from running.
+ if( taskRef != null ) {
+ taskRef.cancel( false );
+ }
+
+ initAutosave( rate );
+ }
+ );
+
+ // Start the save listener (avoids duplicating some code).
+ initAutosave( rate );
+ }
+
+ private void initAutosave( final IntegerProperty rate ) {
+ mSaveTask.set(
+ mSaver.scheduleAtFixedRate(
+ () -> {
+ if( getTextEditor().isModified() ) {
+ // Ensure the modified indicator is cleared by running on EDT.
+ runLater( this::save );
+ }
+ }, 0, rate.intValue(), SECONDS
+ )
+ );
+ }
+
+ /**
+ * TODO: Load divider positions from exported settings, see
+ * {@link #collect(SetProperty)} comment.
+ */
+ private double[] calculateDividerPositions() {
+ final var ratio = 100f / getItems().size() / 100;
+ final var positions = getDividerPositions();
+
+ for( int i = 0; i < positions.length; i++ ) {
+ positions[ i ] = ratio * i;
+ }
+
+ return positions;
+ }
+
+ /**
+ * Opens all the files into the application, provided the paths are unique.
+ * This may only be called for any type of files that a user can edit
+ * (i.e., update and persist), such as definitions and text files.
+ *
+ * @param files The list of files to open.
+ */
+ public void open( final List<File> files ) {
+ files.forEach( this::open );
+ }
+
+ /**
+ * This opens the given file. Since the preview pane is not a file that
+ * can be opened, it is safe to add a listener to the detachable pane.
+ *
+ * @param inputFile The file to open.
+ */
+ private void open( final File inputFile ) {
+ final var tab = createTab( inputFile );
+ final var node = tab.getContent();
+ final var mediaType = MediaType.valueFrom( inputFile );
+ final var tabPane = obtainTabPane( mediaType );
+
+ tab.setTooltip( createTooltip( inputFile ) );
+ tabPane.setFocusTraversable( false );
+ tabPane.setTabClosingPolicy( ALL_TABS );
+ tabPane.getTabs().add( tab );
+
+ // Attach the tab scene factory for new tab panes.
+ if( !getItems().contains( tabPane ) ) {
+ addTabPane(
+ node instanceof TextDefinition ? 0 : getItems().size(), tabPane
+ );
+ }
+
+ getRecentFiles().add( inputFile.getAbsolutePath() );
+ }
+
+ /**
+ * 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(
+ ( tp ) -> tp.getTabs().forEach( ( tab ) -> {
+ final var node = tab.getContent();
+ if( node instanceof final TextEditor editor ) {
+ save( editor );
+ }
+ } )
+ );
+ }
+
+ /**
+ * Requests that the active {@link TextEditor} saves itself. Don't bother
+ * checking if modified first because if the user swaps external media from
+ * an external source (e.g., USB thumb drive), save should not second-guess
+ * the user: save always re-saves. Also, it's less code.
+ */
+ public void save() {
+ save( getTextEditor() );
+ }
+
+ /**
+ * Saves the active {@link TextEditor} under a new name.
+ *
+ * @param files The new active editor {@link File} reference, must contain
+ * at least one element.
+ */
+ public void saveAs( final List<File> files ) {
+ assert files != null;
+ assert !files.isEmpty();
+ final var editor = getTextEditor();
+ final var tab = getTab( editor );
+ final var file = files.get( 0 );
+
+ 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 tabPane : mTabPanes ) {
+ final var tabIterator = tabPane.getTabs().iterator();
+
+ while( tabIterator.hasNext() ) {
+ final var tab = tabIterator.next();
+ final var resource = tab.getContent();
+
+ // The definition panes auto-save, so being specific here prevents
+ // closing the definitions in the situation where the user wants to
+ // continue editing (i.e., possibly save unsaved work).
+ if( !(resource instanceof TextEditor) ) {
+ continue;
+ }
+
+ if( canClose( (TextEditor) resource ) ) {
+ tabIterator.remove();
+ close( tab );
+ }
+ else {
+ closable = false;
+ }
+ }
+ }
+
+ return closable;
+ }
+
+ /**
+ * Calls the tab's {@link Tab#getOnClosed()} handler to carry out a close
+ * event.
+ *
+ * @param tab The {@link Tab} that was closed.
+ */
+ private void close( final Tab tab ) {
+ assert tab != null;
+
+ final var handler = tab.getOnClosed();
+
+ if( handler != null ) {
+ handler.handle( new ActionEvent() );
+ }
+ }
+
+ /**
+ * Closes the active tab; delegates to {@link #canClose(TextResource)}.
+ */
+ public void close() {
+ final var editor = getTextEditor();
+
+ if( canClose( editor ) ) {
+ close( editor );
+ }
+ }
+
+ /**
+ * Closes the given {@link TextResource}. This must not be called from within
+ * a loop that iterates over the tab panes using {@code forEach}, lest a
+ * concurrent modification exception be thrown.
+ *
+ * @param resource The {@link TextResource} to close, without confirming with
+ * the user.
+ */
+ private void close( final TextResource resource ) {
+ getTab( resource ).ifPresent(
+ ( tab ) -> {
+ close( tab );
+ tab.getTabPane().getTabs().remove( tab );
+ }
+ );
+ }
+
+ /**
+ * Answers whether the given {@link TextResource} may be closed.
+ *
+ * @param editor The {@link TextResource} to try closing.
+ * @return {@code true} when the editor may be closed; {@code false} when
+ * the user has requested to keep the editor open.
+ */
+ private boolean canClose( final TextResource editor ) {
+ final var editorTab = getTab( editor );
+ final var canClose = new AtomicBoolean( true );
+
+ if( editor.isModified() ) {
+ final var filename = new StringBuilder();
+ editorTab.ifPresent( ( tab ) -> filename.append( tab.getText() ) );
+
+ final var message = sNotifier.createNotification(
+ Messages.get( "Alert.file.close.title" ),
+ Messages.get( "Alert.file.close.text" ),
+ filename.toString()
+ );
+
+ final var dialog = sNotifier.createConfirmation( getWindow(), message );
+
+ dialog.showAndWait().ifPresent(
+ save -> canClose.set( save == YES ? editor.save() : save == NO )
+ );
+ }
+
+ return canClose.get();
+ }
+
+ private ObjectProperty<TextEditor> createActiveTextEditor() {
+ final var editor = new SimpleObjectProperty<TextEditor>();
+
+ editor.addListener( ( c, o, n ) -> {
+ if( n != null ) {
+ mPreview.setBaseUri( n.getPath() );
+ process( n );
+ }
+ } );
+
+ return editor;
+ }
+
+ /**
+ * Adds the HTML preview tab to its own, singular tab pane.
+ */
+ public void viewPreview() {
+ viewTab( mPreview, TEXT_HTML, "Pane.preview.title" );
+ }
+
+ /**
+ * Adds the document outline tab to its own, singular tab pane.
+ */
+ public void viewOutline() {
+ viewTab( mOutline, APP_DOCUMENT_OUTLINE, "Pane.outline.title" );
+ }
+
+ public void viewStatistics() {
+ viewTab( mStatistics, APP_DOCUMENT_STATISTICS, "Pane.statistics.title" );
+ }
+
+ public void viewFiles() {
+ try {
+ final var factory = new FilePickerFactory( getWorkspace() );
+ final var fileManager = factory.createModeless();
+ viewTab( fileManager, APP_FILE_MANAGER, "Pane.files.title" );
+ } catch( final Exception ex ) {
+ clue( ex );
+ }
+ }
+
+ private void viewTab(
+ final Node node, final MediaType mediaType, final String key ) {
+ final var tabPane = obtainTabPane( mediaType );
+
+ for( final var tab : tabPane.getTabs() ) {
+ if( tab.getContent() == node ) {
+ return;
+ }
+ }
+
+ tabPane.getTabs().add( createTab( get( key ), node ) );
+ addTabPane( tabPane );
+ }
+
+ public void viewRefresh() {
+ mPreview.refresh();
+ }
+
+ /**
+ * Returns the tab that contains the given {@link TextEditor}.
+ *
+ * @param editor The {@link TextEditor} instance to find amongst the tabs.
+ * @return The first tab having content that matches the given tab.
+ */
+ private Optional<Tab> getTab( final TextResource editor ) {
+ return mTabPanes.stream()
+ .flatMap( pane -> pane.getTabs().stream() )
+ .filter( tab -> editor.equals( tab.getContent() ) )
+ .findFirst();
+ }
+
+ /**
+ * Creates a new {@link DefinitionEditor} wrapped in a listener that
+ * is used to detect when the active {@link DefinitionEditor} has changed.
+ * Upon changing, the variables are interpolated and the active text editor
+ * is refreshed.
+ *
+ * @param textEditor 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> textEditor ) {
+ final var defEditor = new SimpleObjectProperty<>(
+ createDefinitionEditor()
+ );
+
+ defEditor.addListener( ( c, o, n ) -> process( textEditor.get() ) );
+
+ return defEditor;
+ }
+
+ private Tab createTab( final String filename, final Node node ) {
+ return new DetachableTab( filename, node );
+ }
+
+ private Tab createTab( final File file ) {
+ final var r = createTextResource( file );
+ final var tab = createTab( r.getFilename(), r.getNode() );
+
+ r.modifiedProperty().addListener(
+ ( c, o, n ) -> tab.setText( r.getFilename() + (n ? "*" : "") )
+ );
+
+ // This is called when either the tab is closed by the user clicking on
+ // the tab's close icon or when closing (all) from the file menu.
+ tab.setOnClosed(
+ ( __ ) -> getRecentFiles().remove( file.getAbsolutePath() )
+ );
+
+ // When closing a tab, give focus to the newly revealed tab.
+ tab.selectedProperty().addListener( ( c, o, n ) -> {
+ if( n != null && n ) {
+ final var pane = tab.getTabPane();
+
+ if( pane != null ) {
+ pane.requestFocus();
+ }
+ }
+ } );
+
+ tab.tabPaneProperty().addListener( ( cPane, oPane, nPane ) -> {
+ if( nPane != null ) {
+ nPane.focusedProperty().addListener( ( c, o, n ) -> {
+ if( n != null && n ) {
+ final var selected = nPane.getSelectionModel().getSelectedItem();
+ final var node = selected.getContent();
+ node.requestFocus();
+ }
+ } );
+ }
+ } );
+
+ return tab;
+ }
+
+ /**
+ * Creates bins for the different {@link MediaType}s, which eventually are
+ * added to the UI as separate tab panes. If ever a general-purpose scene
+ * exporter is developed to serialize a scene to an FXML file, this could
+ * be replaced by such a class.
+ * <p>
+ * When binning the files, this makes sure that at least one file exists
+ * for every type. If the user has opted to close a particular type (such
+ * as the definition pane), the view will suppressed elsewhere.
+ * </p>
+ * <p>
+ * The order that the binned files are returned will be reflected in the
+ * order that the corresponding panes are rendered in the UI.
+ * </p>
+ *
+ * @param paths The file paths to bin according to their type.
+ * @return An in-order list of files, first by structured definition files,
+ * then by plain text documents.
+ */
+ private List<File> collect( final SetProperty<String> paths ) {
+ // Treat all files destined for the text editor as plain text documents
+ // so that they are added to the same pane. Grouping by TEXT_PLAIN is a
+ // bit arbitrary, but means explicitly capturing TEXT_PLAIN isn't needed.
+ final Function<MediaType, MediaType> bin =
+ m -> PLAIN_TEXT_FORMAT.contains( m ) ? TEXT_PLAIN : m;
+
+ // Create two groups: YAML files and plain text files.
+ final var bins = paths
+ .stream()
+ .collect(
+ groupingBy( path -> bin.apply( MediaType.fromFilename( path ) ) )
+ );
+
+ bins.putIfAbsent( TEXT_YAML, List.of( DEFINITION_DEFAULT.toString() ) );
+ bins.putIfAbsent( TEXT_PLAIN, List.of( DOCUMENT_DEFAULT.toString() ) );
+
+ final var result = new ArrayList<File>( paths.size() );
+
+ // Ensure that the same types are listed together (keep insertion order).
+ bins.forEach( ( mediaType, files ) -> result.addAll(
+ files.stream().map( File::new ).collect( Collectors.toList() ) )
+ );
+
+ return result;
+ }
+
+ /**
+ * Force the active editor to update, which will cause the processor
+ * to re-evaluate the interpolated definition map thereby updating the
+ * preview pane.
+ *
+ * @param editor Contains the source document to update in the preview pane.
+ */
+ private void process( final TextEditor editor ) {
+ // Ensure processing does not run on the JavaFX thread, which frees the
+ // text editor immediately for caret movement. The preview will have a
+ // slight delay when catching up to the caret position.
+ final var task = new Task<Void>() {
+ @Override
+ public Void call() {
+ try {
+ final var p = mProcessors.getOrDefault( editor, IDENTITY );
+ p.apply( editor == null ? "" : editor.getText() );
+ } catch( final Exception ex ) {
+ clue( ex );
+ }
+
+ return null;
+ }
+ };
+
+ task.setOnSucceeded(
+ e -> invokeLater( () -> mPreview.scrollTo( CARET_ID ) )
+ );
+
+ // Prevents multiple process requests from executing simultaneously (due
+ // to having a restricted queue size).
+ sExecutor.execute( task );
+ }
+
+ /**
+ * Lazily creates a {@link TabPane} configured to listen for tab select
+ * events. The tab pane is associated with a given media type so that
+ * similar files can be grouped together.
+ *
+ * @param mediaType The media type to associate with the tab pane.
+ * @return An instance of {@link TabPane} that will handle tab docking.
+ */
+ private TabPane obtainTabPane( final MediaType mediaType ) {
+ for( final var pane : mTabPanes ) {
+ for( final var tab : pane.getTabs() ) {
+ final var node = tab.getContent();
+
+ if( node instanceof TextResource r && r.supports( mediaType ) ) {
+ return pane;
+ }
+ }
+ }
+
+ final var pane = createTabPane();
+ mTabPanes.add( pane );
+ return pane;
+ }
+
+ /**
+ * Creates an initialized {@link TabPane} instance.
+ *
+ * @return A new {@link TabPane} with all listeners configured.
+ */
+ private TabPane createTabPane() {
+ final var tabPane = new DetachableTabPane();
+
+ initStageOwnerFactory( tabPane );
+ initTabListener( tabPane );
+
+ return tabPane;
+ }
+
+ /**
+ * When any {@link DetachableTabPane} is detached from the main window,
+ * the stage owner factory must be given its parent window, which will
+ * own the child window. The parent window is the {@link MainPane}'s
+ * {@link Scene}'s {@link Window} instance.
+ *
+ * <p>
+ * This will derives the new title from the main window title, incrementing
+ * the window count to help uniquely identify the child windows.
+ * </p>
+ *
+ * @param tabPane A new {@link DetachableTabPane} to configure.
+ */
+ private void initStageOwnerFactory( final DetachableTabPane tabPane ) {
+ tabPane.setStageOwnerFactory( ( stage ) -> {
+ final var title = get(
+ "Detach.tab.title",
+ ((Stage) getWindow()).getTitle(), ++mWindowCount
+ );
+ stage.setTitle( title );
+
+ return getScene().getWindow();
+ } );
+ }
+
+ /**
+ * Responsible for configuring the content of each {@link DetachableTab} when
+ * it is added to the given {@link DetachableTabPane} instance.
+ * <p>
+ * For {@link TextEditor} contents, an instance of {@link ScrollEventHandler}
+ * is initialized to perform synchronized scrolling between the editor and
+ * its preview window. Additionally, the last tab in the tab pane's list of
+ * tabs is given focus.
+ * </p>
+ * <p>
+ * Note that multiple tabs can be added simultaneously.
+ * </p>
+ *
+ * @param tabPane A new {@link TabPane} to configure.
+ */
+ private void initTabListener( final TabPane tabPane ) {
+ tabPane.getTabs().addListener(
+ ( final ListChangeListener.Change<? extends Tab> listener ) -> {
+ while( listener.next() ) {
+ if( listener.wasAdded() ) {
+ final var tabs = listener.getAddedSubList();
+
+ tabs.forEach( ( tab ) -> {
+ final var node = tab.getContent();
+
+ if( node instanceof TextEditor ) {
+ initScrollEventListener( tab );
+ }
+ } );
+
+ // Select and give focus to the last tab opened.
+ final var index = tabs.size() - 1;
+ if( index >= 0 ) {
+ final var tab = tabs.get( index );
+ tabPane.getSelectionModel().select( tab );
+ tab.getContent().requestFocus();
+ }
+ }
+ }
+ }
+ );
+ }
+
+ /**
+ * Synchronizes scrollbar positions between the given {@link Tab} that
+ * contains an instance of {@link TextEditor} and {@link HtmlPreview} pane.
+ *
+ * @param tab The container for an instance of {@link TextEditor}.
+ */
+ private void initScrollEventListener( final Tab tab ) {
+ final var editor = (TextEditor) tab.getContent();
+ final var scrollPane = editor.getScrollPane();
+ final var scrollBar = mPreview.getVerticalScrollBar();
+ final var handler = new ScrollEventHandler( scrollPane, scrollBar );
+ handler.enabledProperty().bind( tab.selectedProperty() );
+ }
+
+ private void addTabPane( final int index, final TabPane tabPane ) {
+ final var items = getItems();
+ if( !items.contains( tabPane ) ) {
+ items.add( index, tabPane );
+ }
+ }
+
+ private void addTabPane( final TabPane tabPane ) {
+ addTabPane( getItems().size(), tabPane );
+ }
+
+ public ProcessorContext createProcessorContext() {
+ return createProcessorContext( null, NONE );
+ }
+
+ public ProcessorContext createProcessorContext(
+ final Path exportPath, final ExportFormat format ) {
+ final var textEditor = getTextEditor();
+ return createProcessorContext(
+ textEditor.getPath(), exportPath, format, textEditor.getCaret() );
+ }
+
+ private ProcessorContext createProcessorContext(
+ final Path inputPath, final Caret caret ) {
+ return createProcessorContext( inputPath, null, NONE, caret );
+ }
+
+ /**
+ * @param inputPath Used by {@link ProcessorFactory} to determine
+ * {@link Processor} type to create based on file type.
+ * @param outputPath Used when exporting to a PDF file (binary).
+ * @param format Used when processors export to a new text format.
+ * @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 inputPath,
+ final Path outputPath,
+ final ExportFormat format,
+ final Caret caret ) {
+ return builder()
+ .with( Mutator::setInputPath, inputPath )
+ .with( Mutator::setOutputPath, outputPath )
+ .with( Mutator::setExportFormat, format )
+ .with( Mutator::setHtmlPreview, mPreview )
+ .with( Mutator::setDefinitions, this::getDefinitions )
+ .with( Mutator::setWorkspace, mWorkspace )
+ .with( Mutator::setCaret, caret )
+ .build();
+ }
+
+ private TextResource createTextResource( final File file ) {
+ // TODO: Create PlainTextEditor that's returned by default.
+ return MediaType.valueFrom( file ) == TEXT_YAML
+ ? createDefinitionEditor( file )
+ : createMarkdownEditor( file );
+ }
+
+ /**
+ * Creates an instance of {@link MarkdownEditor} that listens for both
+ * caret change events and text change events. Text change events must
+ * take priority over caret change events because it's possible to change
+ * the text without moving the caret (e.g., delete selected text).
+ *
+ * @param inputFile The file containing contents for the text editor.
+ * @return A non-null text editor.
+ */
+ private TextResource createMarkdownEditor( final File inputFile ) {
+ final var inputPath = inputFile.toPath();
+ final var editor = new MarkdownEditor( inputFile, getWorkspace() );
+ final var caret = editor.getCaret();
+ final var context = createProcessorContext( inputPath, caret );
+
+ mProcessors.computeIfAbsent( editor, p -> createProcessors( context ) );
+
+ editor.addDirtyListener( ( c, o, n ) -> {
+ if( n ) {
+ // Reset the status to OK after changing the text.
+ clue();
+
+ // Processing the text may update the status bar.
+ process( getTextEditor() );
+ }
+ } );
+
+ editor.addEventListener(
+ keyPressed( SPACE, CONTROL_DOWN ), this::autoinsert
+ );
+
+ // Set the active editor, which refreshes the preview panel.
+ mTextEditor.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 = getTextDefinition();
+ final var editor = getTextEditor();
+ final var mediaType = editor.getMediaType();
+ final var operator = createSigilOperator( mediaType );
+
+ DefinitionNameInjector.autoinsert( editor, definitions, operator );
+ }
+
+ private TextDefinition createDefinitionEditor() {
+ return createDefinitionEditor( DEFINITION_DEFAULT );
+ }
+
+ private TextDefinition createDefinitionEditor( final File file ) {
+ final var editor = new DefinitionEditor(
+ file, createTreeTransformer(), getWorkspace().createYamlSigilOperator() );
+ editor.addTreeChangeHandler( mTreeHandler );
+ return editor;
+ }
+
+ private TreeTransformer createTreeTransformer() {
+ return new YamlTreeTransformer();
+ }
+
+ private Tooltip createTooltip( final File file ) {
+ final var path = file.toPath();
+ final var tooltip = new Tooltip( path.toString() );
+
+ tooltip.setShowDelay( millis( 200 ) );
+ return tooltip;
+ }
+
+ /**
+ * Returns the active text editor.
+ *
+ * @return The text editor that currently has focus.
+ */
+ public TextEditor getTextEditor() {
+ return mTextEditor.get();
+ }
+
+ /**
+ * Returns the active text editor property.
+ *
+ * @return The property container for the active text editor.
+ */
+ public ReadOnlyObjectProperty<TextEditor> textEditorProperty() {
+ return mTextEditor;
+ }
+
+ /**
+ * Returns the active text definition editor.
+ *
+ * @return The property container for the active definition editor.
+ */
+ public TextDefinition getTextDefinition() {
+ return mDefinitionEditor.get();
+ }
+
+ /**
+ * Returns the interpolated variable definitions.
+ *
+ * @return The key-value pairs, fully interpolated.
+ */
+ private Map<String, String> getDefinitions() {
+ return getTextDefinition().getDefinitions();
}
src/main/java/com/keenwrite/MainScene.java
*/
private CaretListener createCaretListener( final MainPane mainPane ) {
- return new CaretListener( mainPane.activeTextEditorProperty() );
+ return new CaretListener( mainPane.textEditorProperty() );
}
src/main/java/com/keenwrite/cmdline/Arguments.java
public ProcessorContext createProcessorContext() {
final var format = ExportFormat.valueFrom( mFormatType, mFormatSubtype );
+
return ProcessorContext
.builder()
src/main/java/com/keenwrite/processors/PdfProcessor.java
package com.keenwrite.processors;
+import com.keenwrite.typesetting.Typesetter;
+
import java.io.IOException;
import static com.keenwrite.Bootstrap.APP_TITLE_LOWERCASE;
import static com.keenwrite.events.StatusEvent.clue;
import static com.keenwrite.io.MediaType.TEXT_XML;
import static com.keenwrite.preferences.WorkspaceKeys.*;
import static com.keenwrite.typesetting.Typesetter.Mutator;
-import static com.keenwrite.typesetting.Typesetter.builder;
import static java.nio.file.Files.deleteIfExists;
import static java.nio.file.Files.writeString;
final var workspace = mContext.getWorkspace();
final var document = TEXT_XML.createTemporaryFile( APP_TITLE_LOWERCASE );
- final var typesetter = builder()
+ final var typesetter = Typesetter
+ .builder()
.with( Mutator::setInputPath,
writeString( document, xhtml ) )
src/main/java/com/keenwrite/processors/ProcessorContext.java
import com.keenwrite.ExportFormat;
import com.keenwrite.constants.Constants;
-import com.keenwrite.editors.TextDefinition;
import com.keenwrite.io.FileType;
import com.keenwrite.preferences.Workspace;
import com.keenwrite.preview.HtmlPreview;
import com.keenwrite.util.GenericBuilder;
-import javafx.beans.property.ObjectProperty;
import java.io.File;
import java.nio.file.Path;
import java.util.Map;
+import java.util.concurrent.Callable;
import static com.keenwrite.AbstractFileFactory.lookup;
private final Mutator mMutator;
+ /**
+ * Responsible for populating the instance variables required by the
+ * context.
+ */
public static class Mutator {
private HtmlPreview mHtmlPreview;
- private ObjectProperty<TextDefinition> mTextDefinition;
private Path mInputPath;
private Path mOutputPath;
- private Caret mCaret;
private ExportFormat mExportFormat;
+ private Callable<Map<String, String>> mDefinitions;
+ private Caret mCaret;
private Workspace mWorkspace;
public void setHtmlPreview( final HtmlPreview htmlPreview ) {
mHtmlPreview = htmlPreview;
- }
-
- public void setTextDefinition(
- final ObjectProperty<TextDefinition> textDefinition ) {
- mTextDefinition = textDefinition;
}
public void setOutputPath( final File outputPath ) {
setOutputPath( outputPath.toPath() );
+ }
+
+ /**
+ * Sets the list of fully interpolated key-value pairs to use when
+ * substituting variable names back into the document as variable values.
+ * This uses a {@link Callable} reference so that GUI and command-line
+ * usage can insert their respective behaviours. That is, this method
+ * prevents coupling the GUI to the CLI.
+ *
+ * @param definitions Defines how to retrieve the definitions.
+ */
+ public void setDefinitions(
+ final Callable<Map<String, String>> definitions ) {
+ mDefinitions = definitions;
}
public static GenericBuilder<Mutator, ProcessorContext> builder() {
- return GenericBuilder.of(
- Mutator::new,
- ProcessorContext::new
- );
+ return GenericBuilder.of( Mutator::new, ProcessorContext::new );
}
/**
- * @param inputPath Path to the document to process.
- * @param outputPath Fully qualified filename to use when exporting.
- * @param format Indicate configuration options for export format.
- * @param preview Where to display the final (HTML) output.
- * @param textDefinition Source for fully expanded interpolated strings.
- * @param workspace Persistent user preferences settings.
- * @param caret Location of the caret in the edited document,
- * which is used to synchronize the scrollbars.
+ * @param inputPath Path to the document to process.
+ * @param outputPath Fully qualified filename to use when exporting.
+ * @param format Indicate configuration options for export format.
+ * @param preview Where to display the final (HTML) output.
+ * @param definitions Source for fully expanded interpolated strings.
+ * @param workspace Persistent user preferences settings.
+ * @param caret Location of the caret in the edited document,
+ * which is used to synchronize the scrollbars.
* @return A context that may be used for processing documents.
*/
public static ProcessorContext create(
final Path inputPath,
final Path outputPath,
final ExportFormat format,
final HtmlPreview preview,
- final ObjectProperty<TextDefinition> textDefinition,
+ final Callable<Map<String, String>> definitions,
final Workspace workspace,
final Caret caret ) {
- return builder()
+ return ProcessorContext
+ .builder()
.with( Mutator::setInputPath, inputPath )
.with( Mutator::setOutputPath, outputPath )
.with( Mutator::setExportFormat, format )
.with( Mutator::setHtmlPreview, preview )
- .with( Mutator::setTextDefinition, textDefinition )
+ .with( Mutator::setDefinitions, definitions )
.with( Mutator::setWorkspace, workspace )
.with( Mutator::setCaret, caret )
*/
Map<String, String> getResolvedMap() {
- return mMutator.mTextDefinition.get().getDefinitions();
+ try {
+ return mMutator.mDefinitions.call();
+ } catch( final Exception ex ) {
+ // If this happens, it is a programming error because the definitions
+ // list must always return a valid map of variable names to values.
+ throw new RuntimeException( ex );
+ }
}
src/main/java/com/keenwrite/ui/actions/GuiCommands.java
// 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() {
- pickFiles( FILE_OPEN_MULTIPLE ).ifPresent( l -> getMainPane().open( l ) );
- }
-
- public void file_close() {
- getMainPane().close();
- }
-
- public void file_close_all() {
- getMainPane().closeAll();
- }
-
- public void file_save() {
- getMainPane().save();
- }
-
- public void file_save_as() {
- pickFiles( FILE_SAVE_AS ).ifPresent( l -> getMainPane().saveAs( l ) );
- }
-
- public void file_save_all() {
- getMainPane().saveAll();
- }
-
- /**
- * Converts the actively edited file in the given file format.
- *
- * @param format The destination file format.
- */
- private void file_export( final ExportFormat format ) {
- file_export( format, false );
- }
-
- /**
- * Converts one or more files into the given file format. If {@code dir}
- * is set to true, this will first append all files in the same directory
- * as the actively edited file.
- *
- * @param format The destination file format.
- * @param dir Export all files in the actively edited file's directory.
- */
- private void file_export( final ExportFormat format, final boolean dir ) {
- final var main = getMainPane();
- final var editor = main.getActiveTextEditor();
- final var exported = getWorkspace().fileProperty( KEY_UI_RECENT_EXPORT );
- final var filename = format.toExportFilename( editor.getPath() );
- final var selection = pickFiles(
- Constants.PDF_DEFAULT.getName().equals( exported.get().getName() )
- ? filename
- : exported.get(), FILE_EXPORT
- );
-
- selection.ifPresent( ( files ) -> {
- editor.save();
-
- final var file = files.get( 0 );
- final var path = file.toPath();
- final var document = dir ? append( editor ) : editor.getText();
- final var context = main.createProcessorContext( path, format );
-
- final var task = new Task<Path>() {
- @Override
- protected Path call() throws Exception {
- final var chain = createProcessors( context );
- final var export = chain.apply( document );
-
- // Processors can export binary files. In such cases, processors
- // return null to prevent further processing.
- return export == null ? null : writeString( path, export );
- }
- };
-
- task.setOnSucceeded(
- e -> {
- // Remember the exported file name for next time.
- exported.setValue( file );
-
- final var result = task.getValue();
-
- // Binary formats must notify users of success independently.
- if( result != null ) {
- clue( "Main.status.export.success", result );
- }
- }
- );
-
- task.setOnFailed( e -> {
- final var ex = task.getException();
- clue( ex );
-
- if( ex instanceof TypeNotPresentException ) {
- fireExportFailedEvent();
- }
- } );
-
- sExecutor.execute( task );
- } );
- }
-
- /**
- * @param dir {@code true} means to export all files in the active file
- * editor's directory; {@code false} means to export only the
- * actively edited file.
- */
- private void file_export_pdf( final boolean dir ) {
- final var workspace = getWorkspace();
- final var themes = workspace.toFile( KEY_TYPESET_CONTEXT_THEMES_PATH );
- final var theme = workspace.stringProperty(
- KEY_TYPESET_CONTEXT_THEME_SELECTION );
-
- if( Typesetter.canRun() ) {
- // If the typesetter is installed, allow the user to select a theme. If
- // the themes aren't installed, a status message will appear.
- if( ThemePicker.choose( themes, theme ) ) {
- file_export( APPLICATION_PDF, dir );
- }
- }
- else {
- fireExportFailedEvent();
- }
- }
-
- public void file_export_pdf() {
- file_export_pdf( false );
- }
-
- public void file_export_pdf_dir() {
- file_export_pdf( true );
- }
-
- 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_xhtml_tex() {
- file_export( XHTML_TEX );
- }
-
- public void file_export_markdown() {
- file_export( MARKDOWN_PLAIN );
- }
-
- private void fireExportFailedEvent() {
- runLater( ExportFailedEvent::fire );
- }
-
- 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 = getMainScene().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() {
- try {
- new PreferencesController( getWorkspace() ).show();
- } catch( final Exception ex ) {
- clue( ex );
- }
- }
-
- public void format_bold() {
- getActiveTextEditor().bold();
- }
-
- public void format_italic() {
- getActiveTextEditor().italic();
- }
-
- public void format_monospace() {
- getActiveTextEditor().monospace();
- }
-
- 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() {
- insertObject( createLinkDialog() );
- }
-
- public void insert_image() {
- insertObject( createImageDialog() );
- }
-
- private void insertObject( final Dialog<String> dialog ) {
- final var textArea = getActiveTextEditor().getTextArea();
- dialog.showAndWait().ifPresent( textArea::replaceSelection );
- }
-
- private Dialog<String> createLinkDialog() {
- return new LinkDialog( getWindow(), createHyperlinkModel() );
- }
-
- private Dialog<String> createImageDialog() {
- final var path = getActiveTextEditor().getPath();
- final var parentDir = path.getParent();
- return new ImageDialog( getWindow(), parentDir );
- }
-
- /**
- * Returns one of: selected text, word under cursor, or parsed hyperlink from
- * the Markdown AST.
- *
- * @return An instance containing the link URL and display text.
- */
- private HyperlinkModel createHyperlinkModel() {
- final var context = getMainPane().createProcessorContext();
- final var editor = getActiveTextEditor();
- final var textArea = editor.getTextArea();
- final var selectedText = textArea.getSelectedText();
-
- // Convert current paragraph to Markdown nodes.
- final var mp = MarkdownProcessor.create( context );
- final var p = textArea.getCurrentParagraph();
- final var paragraph = textArea.getText( p );
- final var node = mp.toNode( paragraph );
- final var visitor = new LinkVisitor( textArea.getCaretColumn() );
- final var link = visitor.process( node );
-
- if( link != null ) {
- textArea.selectRange( p, link.getStartOffset(), p, link.getEndOffset() );
- }
-
- return createHyperlinkModel( link, selectedText );
- }
-
- private HyperlinkModel createHyperlinkModel(
- final Link link, final String selection ) {
-
- return link == null
- ? new HyperlinkModel( selection, "https://localhost" )
- : new HyperlinkModel( link );
- }
-
- 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() {
- getMainPane().viewRefresh();
- }
-
- public void view_preview() {
- getMainPane().viewPreview();
- }
-
- public void view_outline() {
- getMainPane().viewOutline();
- }
-
- public void view_files() {getMainPane().viewFiles();}
-
- public void view_statistics() {
- getMainPane().viewStatistics();
- }
-
- public void view_menubar() {
- getMainScene().toggleMenuBar();
- }
-
- public void view_toolbar() {
- getMainScene().toggleToolBar();
- }
-
- public void view_statusbar() {
- getMainScene().toggleStatusBar();
- }
-
- public void view_log() {
- mLogView.view();
- }
-
- public void help_about() {
- final var alert = new Alert( INFORMATION );
- final var prefix = "Dialog.about.";
- alert.setTitle( get( prefix + "title", APP_TITLE ) );
- alert.setHeaderText( get( prefix + "header", APP_TITLE ) );
- alert.setContentText( get( prefix + "content", APP_YEAR, APP_VERSION ) );
- alert.setGraphic( ICON_DIALOG_NODE );
- alert.initOwner( getWindow() );
- alert.showAndWait();
- }
-
- /**
- * Concatenates all the files in the same directory as the given file into
- * a string. The extension is determined by the given file name pattern; the
- * order files are concatenated is based on their numeric sort order (this
- * avoids lexicographic sorting).
- * <p>
- * If the parent path to the file being edited in the text editor cannot
- * be found then this will return the editor's text, without iterating through
- * the parent directory. (Should never happen, but who knows?)
- * </p>
- * <p>
- * New lines are automatically appended to separate each file.
- * </p>
- *
- * @param editor The text editor containing
- * @return All files in the same directory as the file being edited
- * concatenated into a single string.
- */
- private String append( final TextEditor editor ) {
- final var pattern = editor.getPath();
- final var parent = pattern.getParent();
-
- // Short-circuit because nothing else can be done.
- if( parent == null ) {
- clue( "Main.status.export.concat.parent", pattern );
- return editor.getText();
- }
-
- final var filename = pattern.getFileName().toString();
- final var extension = getExtension( filename );
-
- if( extension.isBlank() ) {
- clue( "Main.status.export.concat.extension", filename );
- return editor.getText();
- }
-
- try {
- final var glob = "**/*." + extension;
- final ArrayList<Path> files = new ArrayList<>();
- walk( parent, glob, files::add );
- files.sort( new AlphanumComparator<>() );
-
- final var text = new StringBuilder( DOCUMENT_LENGTH );
-
- files.forEach( ( file ) -> {
- try {
- clue( "Main.status.export.concat", file );
- text.append( readString( file ) );
- } catch( final IOException ex ) {
- clue( "Main.status.export.concat.io", file );
- }
- } );
-
- return text.toString();
- } catch( final Throwable t ) {
- clue( t );
- return editor.getText();
- }
- }
-
- private Optional<List<File>> pickFiles( final Options... options ) {
- return createPicker( options ).choose();
- }
-
- private Optional<List<File>> pickFiles(
- final File filename, final Options... options ) {
- final var picker = createPicker( options );
- picker.setInitialFilename( filename );
- return picker.choose();
- }
-
- private FilePicker createPicker( final Options... options ) {
- final var factory = new FilePickerFactory( getWorkspace() );
- return factory.createModal( getWindow(), options );
- }
-
- private TextEditor getActiveTextEditor() {
- return getMainPane().getActiveTextEditor();
- }
-
- private TextDefinition getActiveTextDefinition() {
- return getMainPane().getActiveTextDefinition();
+ mMainPane.textEditorProperty().addListener(
+ ( c, o, n ) -> mSearchModel.search( getActiveTextEditor().getText() )
+ );
+ }
+
+ public void file_new() {
+ getMainPane().newTextEditor();
+ }
+
+ public void file_open() {
+ pickFiles( FILE_OPEN_MULTIPLE ).ifPresent( l -> getMainPane().open( l ) );
+ }
+
+ public void file_close() {
+ getMainPane().close();
+ }
+
+ public void file_close_all() {
+ getMainPane().closeAll();
+ }
+
+ public void file_save() {
+ getMainPane().save();
+ }
+
+ public void file_save_as() {
+ pickFiles( FILE_SAVE_AS ).ifPresent( l -> getMainPane().saveAs( l ) );
+ }
+
+ public void file_save_all() {
+ getMainPane().saveAll();
+ }
+
+ /**
+ * Converts the actively edited file in the given file format.
+ *
+ * @param format The destination file format.
+ */
+ private void file_export( final ExportFormat format ) {
+ file_export( format, false );
+ }
+
+ /**
+ * Converts one or more files into the given file format. If {@code dir}
+ * is set to true, this will first append all files in the same directory
+ * as the actively edited file.
+ *
+ * @param format The destination file format.
+ * @param dir Export all files in the actively edited file's directory.
+ */
+ private void file_export( final ExportFormat format, final boolean dir ) {
+ final var main = getMainPane();
+ final var editor = main.getTextEditor();
+ final var exported = getWorkspace().fileProperty( KEY_UI_RECENT_EXPORT );
+ final var filename = format.toExportFilename( editor.getPath() );
+ final var selection = pickFiles(
+ Constants.PDF_DEFAULT.getName().equals( exported.get().getName() )
+ ? filename
+ : exported.get(), FILE_EXPORT
+ );
+
+ selection.ifPresent( ( files ) -> {
+ editor.save();
+
+ final var file = files.get( 0 );
+ final var path = file.toPath();
+ final var document = dir ? append( editor ) : editor.getText();
+ final var context = main.createProcessorContext( path, format );
+
+ final var task = new Task<Path>() {
+ @Override
+ protected Path call() throws Exception {
+ final var chain = createProcessors( context );
+ final var export = chain.apply( document );
+
+ // Processors can export binary files. In such cases, processors
+ // return null to prevent further processing.
+ return export == null ? null : writeString( path, export );
+ }
+ };
+
+ task.setOnSucceeded(
+ e -> {
+ // Remember the exported file name for next time.
+ exported.setValue( file );
+
+ final var result = task.getValue();
+
+ // Binary formats must notify users of success independently.
+ if( result != null ) {
+ clue( "Main.status.export.success", result );
+ }
+ }
+ );
+
+ task.setOnFailed( e -> {
+ final var ex = task.getException();
+ clue( ex );
+
+ if( ex instanceof TypeNotPresentException ) {
+ fireExportFailedEvent();
+ }
+ } );
+
+ sExecutor.execute( task );
+ } );
+ }
+
+ /**
+ * @param dir {@code true} means to export all files in the active file
+ * editor's directory; {@code false} means to export only the
+ * actively edited file.
+ */
+ private void file_export_pdf( final boolean dir ) {
+ final var workspace = getWorkspace();
+ final var themes = workspace.toFile( KEY_TYPESET_CONTEXT_THEMES_PATH );
+ final var theme = workspace.stringProperty(
+ KEY_TYPESET_CONTEXT_THEME_SELECTION );
+
+ if( Typesetter.canRun() ) {
+ // If the typesetter is installed, allow the user to select a theme. If
+ // the themes aren't installed, a status message will appear.
+ if( ThemePicker.choose( themes, theme ) ) {
+ file_export( APPLICATION_PDF, dir );
+ }
+ }
+ else {
+ fireExportFailedEvent();
+ }
+ }
+
+ public void file_export_pdf() {
+ file_export_pdf( false );
+ }
+
+ public void file_export_pdf_dir() {
+ file_export_pdf( true );
+ }
+
+ 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_xhtml_tex() {
+ file_export( XHTML_TEX );
+ }
+
+ public void file_export_markdown() {
+ file_export( MARKDOWN_PLAIN );
+ }
+
+ private void fireExportFailedEvent() {
+ runLater( ExportFailedEvent::fire );
+ }
+
+ 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 = getMainScene().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() {
+ try {
+ new PreferencesController( getWorkspace() ).show();
+ } catch( final Exception ex ) {
+ clue( ex );
+ }
+ }
+
+ public void format_bold() {
+ getActiveTextEditor().bold();
+ }
+
+ public void format_italic() {
+ getActiveTextEditor().italic();
+ }
+
+ public void format_monospace() {
+ getActiveTextEditor().monospace();
+ }
+
+ 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() {
+ insertObject( createLinkDialog() );
+ }
+
+ public void insert_image() {
+ insertObject( createImageDialog() );
+ }
+
+ private void insertObject( final Dialog<String> dialog ) {
+ final var textArea = getActiveTextEditor().getTextArea();
+ dialog.showAndWait().ifPresent( textArea::replaceSelection );
+ }
+
+ private Dialog<String> createLinkDialog() {
+ return new LinkDialog( getWindow(), createHyperlinkModel() );
+ }
+
+ private Dialog<String> createImageDialog() {
+ final var path = getActiveTextEditor().getPath();
+ final var parentDir = path.getParent();
+ return new ImageDialog( getWindow(), parentDir );
+ }
+
+ /**
+ * Returns one of: selected text, word under cursor, or parsed hyperlink from
+ * the Markdown AST.
+ *
+ * @return An instance containing the link URL and display text.
+ */
+ private HyperlinkModel createHyperlinkModel() {
+ final var context = getMainPane().createProcessorContext();
+ final var editor = getActiveTextEditor();
+ final var textArea = editor.getTextArea();
+ final var selectedText = textArea.getSelectedText();
+
+ // Convert current paragraph to Markdown nodes.
+ final var mp = MarkdownProcessor.create( context );
+ final var p = textArea.getCurrentParagraph();
+ final var paragraph = textArea.getText( p );
+ final var node = mp.toNode( paragraph );
+ final var visitor = new LinkVisitor( textArea.getCaretColumn() );
+ final var link = visitor.process( node );
+
+ if( link != null ) {
+ textArea.selectRange( p, link.getStartOffset(), p, link.getEndOffset() );
+ }
+
+ return createHyperlinkModel( link, selectedText );
+ }
+
+ private HyperlinkModel createHyperlinkModel(
+ final Link link, final String selection ) {
+
+ return link == null
+ ? new HyperlinkModel( selection, "https://localhost" )
+ : new HyperlinkModel( link );
+ }
+
+ 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() {
+ getMainPane().viewRefresh();
+ }
+
+ public void view_preview() {
+ getMainPane().viewPreview();
+ }
+
+ public void view_outline() {
+ getMainPane().viewOutline();
+ }
+
+ public void view_files() {getMainPane().viewFiles();}
+
+ public void view_statistics() {
+ getMainPane().viewStatistics();
+ }
+
+ public void view_menubar() {
+ getMainScene().toggleMenuBar();
+ }
+
+ public void view_toolbar() {
+ getMainScene().toggleToolBar();
+ }
+
+ public void view_statusbar() {
+ getMainScene().toggleStatusBar();
+ }
+
+ public void view_log() {
+ mLogView.view();
+ }
+
+ public void help_about() {
+ final var alert = new Alert( INFORMATION );
+ final var prefix = "Dialog.about.";
+ alert.setTitle( get( prefix + "title", APP_TITLE ) );
+ alert.setHeaderText( get( prefix + "header", APP_TITLE ) );
+ alert.setContentText( get( prefix + "content", APP_YEAR, APP_VERSION ) );
+ alert.setGraphic( ICON_DIALOG_NODE );
+ alert.initOwner( getWindow() );
+ alert.showAndWait();
+ }
+
+ /**
+ * Concatenates all the files in the same directory as the given file into
+ * a string. The extension is determined by the given file name pattern; the
+ * order files are concatenated is based on their numeric sort order (this
+ * avoids lexicographic sorting).
+ * <p>
+ * If the parent path to the file being edited in the text editor cannot
+ * be found then this will return the editor's text, without iterating through
+ * the parent directory. (Should never happen, but who knows?)
+ * </p>
+ * <p>
+ * New lines are automatically appended to separate each file.
+ * </p>
+ *
+ * @param editor The text editor containing
+ * @return All files in the same directory as the file being edited
+ * concatenated into a single string.
+ */
+ private String append( final TextEditor editor ) {
+ final var pattern = editor.getPath();
+ final var parent = pattern.getParent();
+
+ // Short-circuit because nothing else can be done.
+ if( parent == null ) {
+ clue( "Main.status.export.concat.parent", pattern );
+ return editor.getText();
+ }
+
+ final var filename = pattern.getFileName().toString();
+ final var extension = getExtension( filename );
+
+ if( extension.isBlank() ) {
+ clue( "Main.status.export.concat.extension", filename );
+ return editor.getText();
+ }
+
+ try {
+ final var glob = "**/*." + extension;
+ final ArrayList<Path> files = new ArrayList<>();
+ walk( parent, glob, files::add );
+ files.sort( new AlphanumComparator<>() );
+
+ final var text = new StringBuilder( DOCUMENT_LENGTH );
+
+ files.forEach( ( file ) -> {
+ try {
+ clue( "Main.status.export.concat", file );
+ text.append( readString( file ) );
+ } catch( final IOException ex ) {
+ clue( "Main.status.export.concat.io", file );
+ }
+ } );
+
+ return text.toString();
+ } catch( final Throwable t ) {
+ clue( t );
+ return editor.getText();
+ }
+ }
+
+ private Optional<List<File>> pickFiles( final Options... options ) {
+ return createPicker( options ).choose();
+ }
+
+ private Optional<List<File>> pickFiles(
+ final File filename, final Options... options ) {
+ final var picker = createPicker( options );
+ picker.setInitialFilename( filename );
+ return picker.choose();
+ }
+
+ private FilePicker createPicker( final Options... options ) {
+ final var factory = new FilePickerFactory( getWorkspace() );
+ return factory.createModal( getWindow(), options );
+ }
+
+ private TextEditor getActiveTextEditor() {
+ return getMainPane().getTextEditor();
+ }
+
+ private TextDefinition getActiveTextDefinition() {
+ return getMainPane().getTextDefinition();
}
Delta1497 lines added, 1454 lines removed, 43-line increase