| 35 | 35 | import com.panemu.tiwulfx.control.dock.DetachableTab; |
| 36 | 36 | import com.panemu.tiwulfx.control.dock.DetachableTabPane; |
| 37 | | import javafx.application.Platform; |
| 38 | | import javafx.beans.property.*; |
| 39 | | import javafx.collections.ListChangeListener; |
| 40 | | import javafx.concurrent.Task; |
| 41 | | import javafx.event.ActionEvent; |
| 42 | | import javafx.event.Event; |
| 43 | | import javafx.event.EventHandler; |
| 44 | | import javafx.scene.Node; |
| 45 | | import javafx.scene.Scene; |
| 46 | | import javafx.scene.control.SplitPane; |
| 47 | | import javafx.scene.control.Tab; |
| 48 | | import javafx.scene.control.TabPane; |
| 49 | | import javafx.scene.control.Tooltip; |
| 50 | | import javafx.scene.control.TreeItem.TreeModificationEvent; |
| 51 | | import javafx.scene.input.KeyEvent; |
| 52 | | import javafx.stage.Stage; |
| 53 | | import javafx.stage.Window; |
| 54 | | import org.greenrobot.eventbus.Subscribe; |
| 55 | | |
| 56 | | import java.io.File; |
| 57 | | import java.io.FileNotFoundException; |
| 58 | | import java.nio.file.Path; |
| 59 | | import java.util.*; |
| 60 | | import java.util.concurrent.ExecutorService; |
| 61 | | import java.util.concurrent.ScheduledExecutorService; |
| 62 | | import java.util.concurrent.ScheduledFuture; |
| 63 | | import java.util.concurrent.atomic.AtomicBoolean; |
| 64 | | import java.util.concurrent.atomic.AtomicReference; |
| 65 | | import java.util.function.Consumer; |
| 66 | | import java.util.function.Function; |
| 67 | | import java.util.stream.Collectors; |
| 68 | | |
| 69 | | import static com.keenwrite.ExportFormat.NONE; |
| 70 | | import static com.keenwrite.Launcher.terminate; |
| 71 | | import static com.keenwrite.Messages.get; |
| 72 | | import static com.keenwrite.constants.Constants.*; |
| 73 | | import static com.keenwrite.events.Bus.register; |
| 74 | | import static com.keenwrite.events.StatusEvent.clue; |
| 75 | | import static com.keenwrite.io.MediaType.*; |
| 76 | | import static com.keenwrite.io.MediaType.TypeName.TEXT; |
| 77 | | import static com.keenwrite.preferences.AppKeys.*; |
| 78 | | import static com.keenwrite.processors.IdentityProcessor.IDENTITY; |
| 79 | | import static com.keenwrite.processors.ProcessorContext.Mutator; |
| 80 | | import static com.keenwrite.processors.ProcessorContext.builder; |
| 81 | | import static com.keenwrite.processors.ProcessorFactory.createProcessors; |
| 82 | | import static java.awt.Desktop.getDesktop; |
| 83 | | import static java.util.concurrent.Executors.newFixedThreadPool; |
| 84 | | import static java.util.concurrent.Executors.newScheduledThreadPool; |
| 85 | | import static java.util.concurrent.TimeUnit.SECONDS; |
| 86 | | import static java.util.stream.Collectors.groupingBy; |
| 87 | | import static javafx.application.Platform.runLater; |
| 88 | | import static javafx.scene.control.ButtonType.NO; |
| 89 | | import static javafx.scene.control.ButtonType.YES; |
| 90 | | import static javafx.scene.control.TabPane.TabClosingPolicy.ALL_TABS; |
| 91 | | import static javafx.scene.input.KeyCode.ENTER; |
| 92 | | import static javafx.scene.input.KeyCode.SPACE; |
| 93 | | import static javafx.scene.input.KeyCombination.ALT_DOWN; |
| 94 | | import static javafx.scene.input.KeyCombination.CONTROL_DOWN; |
| 95 | | import static javafx.util.Duration.millis; |
| 96 | | import static javax.swing.SwingUtilities.invokeLater; |
| 97 | | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; |
| 98 | | |
| 99 | | /** |
| 100 | | * Responsible for wiring together the main application components for a |
| 101 | | * particular {@link Workspace} (project). These include the definition views, |
| 102 | | * text editors, and preview pane along with any corresponding controllers. |
| 103 | | */ |
| 104 | | public final class MainPane extends SplitPane { |
| 105 | | |
| 106 | | private static final ExecutorService sExecutor = newFixedThreadPool( 1 ); |
| 107 | | private static final Notifier sNotifier = Services.load( Notifier.class ); |
| 108 | | |
| 109 | | /** |
| 110 | | * Used when opening files to determine how each file should be binned and |
| 111 | | * therefore what tab pane to be opened within. |
| 112 | | */ |
| 113 | | private static final Set<MediaType> PLAIN_TEXT_FORMAT = Set.of( |
| 114 | | TEXT_MARKDOWN, TEXT_R_MARKDOWN, UNDEFINED |
| 115 | | ); |
| 116 | | |
| 117 | | private final ScheduledExecutorService mSaver = newScheduledThreadPool( 1 ); |
| 118 | | private final AtomicReference<ScheduledFuture<?>> mSaveTask = |
| 119 | | new AtomicReference<>(); |
| 120 | | |
| 121 | | /** |
| 122 | | * Prevents re-instantiation of processing classes. |
| 123 | | */ |
| 124 | | private final Map<TextResource, Processor<String>> mProcessors = |
| 125 | | new HashMap<>(); |
| 126 | | |
| 127 | | private final Workspace mWorkspace; |
| 128 | | |
| 129 | | /** |
| 130 | | * Groups similar file type tabs together. |
| 131 | | */ |
| 132 | | private final List<TabPane> mTabPanes = new ArrayList<>(); |
| 133 | | |
| 134 | | /** |
| 135 | | * Renders the actively selected plain text editor tab. |
| 136 | | */ |
| 137 | | private final HtmlPreview mPreview; |
| 138 | | |
| 139 | | /** |
| 140 | | * Provides an interactive document outline. |
| 141 | | */ |
| 142 | | private final DocumentOutline mOutline = new DocumentOutline(); |
| 143 | | |
| 144 | | /** |
| 145 | | * Changing the active editor fires the value changed event. This allows |
| 146 | | * refreshes to happen when external definitions are modified and need to |
| 147 | | * trigger the processing chain. |
| 148 | | */ |
| 149 | | private final ObjectProperty<TextEditor> mTextEditor = |
| 150 | | createActiveTextEditor(); |
| 151 | | |
| 152 | | /** |
| 153 | | * Changing the active definition editor fires the value changed event. This |
| 154 | | * allows refreshes to happen when external definitions are modified and need |
| 155 | | * to trigger the processing chain. |
| 156 | | */ |
| 157 | | private final ObjectProperty<TextDefinition> mDefinitionEditor; |
| 158 | | |
| 159 | | private final ObjectProperty<SpellChecker> mSpellChecker; |
| 160 | | |
| 161 | | private final TextEditorSpellChecker mEditorSpeller; |
| 162 | | |
| 163 | | /** |
| 164 | | * Called when the definition data is changed. |
| 165 | | */ |
| 166 | | private final EventHandler<TreeModificationEvent<Event>> mTreeHandler = |
| 167 | | event -> { |
| 168 | | process( getTextEditor() ); |
| 169 | | save( getTextDefinition() ); |
| 170 | | }; |
| 171 | | |
| 172 | | /** |
| 173 | | * Tracks the number of detached tab panels opened into their own windows, |
| 174 | | * which allows unique identification of subordinate windows by their title. |
| 175 | | * It is doubtful more than 128 windows, much less 256, will be created. |
| 176 | | */ |
| 177 | | private byte mWindowCount; |
| 178 | | |
| 179 | | private final VariableNameInjector mVariableNameInjector; |
| 180 | | |
| 181 | | private final RBootstrapController mRBootstrapController; |
| 182 | | |
| 183 | | private final DocumentStatistics mStatistics; |
| 184 | | |
| 185 | | @SuppressWarnings( {"FieldCanBeLocal", "unused"} ) |
| 186 | | private final TypesetterInstaller mInstallWizard; |
| 187 | | |
| 188 | | /** |
| 189 | | * Adds all content panels to the main user interface. This will load the |
| 190 | | * configuration settings from the workspace to reproduce the settings from |
| 191 | | * a previous session. |
| 192 | | */ |
| 193 | | public MainPane( final Workspace workspace ) { |
| 194 | | mWorkspace = workspace; |
| 195 | | mSpellChecker = createSpellChecker(); |
| 196 | | mEditorSpeller = createTextEditorSpellChecker( mSpellChecker ); |
| 197 | | mPreview = new HtmlPreview( workspace ); |
| 198 | | mStatistics = new DocumentStatistics( workspace ); |
| 199 | | mTextEditor.set( createMarkdownEditor( DOCUMENT_DEFAULT ) ); |
| 200 | | mDefinitionEditor = createActiveDefinitionEditor( mTextEditor ); |
| 201 | | mVariableNameInjector = new VariableNameInjector( mWorkspace ); |
| 202 | | mRBootstrapController = new RBootstrapController( |
| 203 | | mWorkspace, this::getDefinitions ); |
| 204 | | |
| 205 | | open( collect( getRecentFiles() ) ); |
| 206 | | viewPreview(); |
| 207 | | setDividerPositions( calculateDividerPositions() ); |
| 208 | | |
| 209 | | // Once the main scene's window regains focus, update the active definition |
| 210 | | // editor to the currently selected tab. |
| 211 | | runLater( () -> getWindow().setOnCloseRequest( event -> { |
| 212 | | // Order matters: Open file names must be persisted before closing all. |
| 213 | | mWorkspace.save(); |
| 214 | | |
| 215 | | if( closeAll() ) { |
| 216 | | Platform.exit(); |
| 217 | | terminate( 0 ); |
| 218 | | } |
| 219 | | |
| 220 | | event.consume(); |
| 221 | | } ) ); |
| 222 | | |
| 223 | | register( this ); |
| 224 | | initAutosave( workspace ); |
| 225 | | |
| 226 | | restoreSession(); |
| 227 | | runLater( this::restoreFocus ); |
| 228 | | |
| 229 | | mInstallWizard = new TypesetterInstaller( workspace ); |
| 230 | | } |
| 231 | | |
| 232 | | /** |
| 233 | | * Called when spellchecking can be run. This will reload the dictionary |
| 234 | | * into memory once, and then re-use it for all the existing text editors. |
| 235 | | * |
| 236 | | * @param event The event to process, having a populated word-frequency map. |
| 237 | | */ |
| 238 | | @Subscribe |
| 239 | | public void handle( final LexiconLoadedEvent event ) { |
| 240 | | final var lexicon = event.getLexicon(); |
| 241 | | |
| 242 | | try { |
| 243 | | final var checker = SymSpellSpeller.forLexicon( lexicon ); |
| 244 | | mSpellChecker.set( checker ); |
| 245 | | } catch( final Exception ex ) { |
| 246 | | clue( ex ); |
| 247 | | } |
| 248 | | } |
| 249 | | |
| 250 | | @Subscribe |
| 251 | | public void handle( final TextEditorFocusEvent event ) { |
| 252 | | mTextEditor.set( event.get() ); |
| 253 | | } |
| 254 | | |
| 255 | | @Subscribe |
| 256 | | public void handle( final TextDefinitionFocusEvent event ) { |
| 257 | | mDefinitionEditor.set( event.get() ); |
| 258 | | } |
| 259 | | |
| 260 | | /** |
| 261 | | * Typically called when a file name is clicked in the preview panel. |
| 262 | | * |
| 263 | | * @param event The event to process, must contain a valid file reference. |
| 264 | | */ |
| 265 | | @Subscribe |
| 266 | | public void handle( final FileOpenEvent event ) { |
| 267 | | final File eventFile; |
| 268 | | final var eventUri = event.getUri(); |
| 269 | | |
| 270 | | if( eventUri.isAbsolute() ) { |
| 271 | | eventFile = new File( eventUri.getPath() ); |
| 272 | | } |
| 273 | | else { |
| 274 | | final var activeFile = getTextEditor().getFile(); |
| 275 | | final var parent = activeFile.getParentFile(); |
| 276 | | |
| 277 | | if( parent == null ) { |
| 278 | | clue( new FileNotFoundException( eventUri.getPath() ) ); |
| 279 | | return; |
| 280 | | } |
| 281 | | else { |
| 282 | | final var parentPath = parent.getAbsolutePath(); |
| 283 | | eventFile = Path.of( parentPath, eventUri.getPath() ).toFile(); |
| 284 | | } |
| 285 | | } |
| 286 | | |
| 287 | | final var mediaType = MediaTypeExtension.fromFile( eventFile ); |
| 288 | | |
| 289 | | runLater( () -> { |
| 290 | | // Open text files locally. |
| 291 | | if( mediaType.isType( TEXT ) ) { |
| 292 | | open( eventFile ); |
| 293 | | } |
| 294 | | else { |
| 295 | | try { |
| 296 | | // Delegate opening all other file types to the operating system. |
| 297 | | getDesktop().open( eventFile ); |
| 298 | | } catch( final Exception ex ) { |
| 299 | | clue( ex ); |
| 300 | | } |
| 301 | | } |
| 302 | | } ); |
| 303 | | } |
| 304 | | |
| 305 | | @Subscribe |
| 306 | | public void handle( final CaretNavigationEvent event ) { |
| 307 | | runLater( () -> { |
| 308 | | final var textArea = getTextEditor(); |
| 309 | | textArea.moveTo( event.getOffset() ); |
| 310 | | textArea.requestFocus(); |
| 311 | | } ); |
| 312 | | } |
| 313 | | |
| 314 | | @Subscribe |
| 315 | | public void handle( final InsertDefinitionEvent<String> event ) { |
| 316 | | final var leaf = event.getLeaf(); |
| 317 | | final var editor = mTextEditor.get(); |
| 318 | | |
| 319 | | mVariableNameInjector.insert( editor, leaf ); |
| 320 | | } |
| 321 | | |
| 322 | | private void initAutosave( final Workspace workspace ) { |
| 323 | | final var rate = workspace.integerProperty( KEY_EDITOR_AUTOSAVE ); |
| 324 | | |
| 325 | | rate.addListener( |
| 326 | | ( c, o, n ) -> { |
| 327 | | final var taskRef = mSaveTask.get(); |
| 328 | | |
| 329 | | // Prevent multiple autosaves from running. |
| 330 | | if( taskRef != null ) { |
| 331 | | taskRef.cancel( false ); |
| 332 | | } |
| 333 | | |
| 334 | | initAutosave( rate ); |
| 335 | | } |
| 336 | | ); |
| 337 | | |
| 338 | | // Start the save listener (avoids duplicating some code). |
| 339 | | initAutosave( rate ); |
| 340 | | } |
| 341 | | |
| 342 | | private void initAutosave( final IntegerProperty rate ) { |
| 343 | | mSaveTask.set( |
| 344 | | mSaver.scheduleAtFixedRate( |
| 345 | | () -> { |
| 346 | | if( getTextEditor().isModified() ) { |
| 347 | | // Ensure the modified indicator is cleared by running on EDT. |
| 348 | | runLater( this::save ); |
| 349 | | } |
| 350 | | }, 0, rate.intValue(), SECONDS |
| 351 | | ) |
| 352 | | ); |
| 353 | | } |
| 354 | | |
| 355 | | /** |
| 356 | | * TODO: Load divider positions from exported settings, see |
| 357 | | * {@link #collect(SetProperty)} comment. |
| 358 | | */ |
| 359 | | private double[] calculateDividerPositions() { |
| 360 | | final var ratio = 100f / getItems().size() / 100; |
| 361 | | final var positions = getDividerPositions(); |
| 362 | | |
| 363 | | for( int i = 0; i < positions.length; i++ ) { |
| 364 | | positions[ i ] = ratio * i; |
| 365 | | } |
| 366 | | |
| 367 | | return positions; |
| 368 | | } |
| 369 | | |
| 370 | | /** |
| 371 | | * Opens all the files into the application, provided the paths are unique. |
| 372 | | * This may only be called for any type of files that a user can edit |
| 373 | | * (i.e., update and persist), such as definitions and text files. |
| 374 | | * |
| 375 | | * @param files The list of files to open. |
| 376 | | */ |
| 377 | | public void open( final List<File> files ) { |
| 378 | | files.forEach( this::open ); |
| 379 | | } |
| 380 | | |
| 381 | | /** |
| 382 | | * This opens the given file. Since the preview pane is not a file that |
| 383 | | * can be opened, it is safe to add a listener to the detachable pane. |
| 384 | | * This will exit early if the given file is not a regular file (i.e., a |
| 385 | | * directory). |
| 386 | | * |
| 387 | | * @param inputFile The file to open. |
| 388 | | */ |
| 389 | | private void open( final File inputFile ) { |
| 390 | | // Prevent opening directories (a non-existent "untitled.md" is fine). |
| 391 | | if( !inputFile.isFile() && inputFile.exists() ) { |
| 392 | | return; |
| 393 | | } |
| 394 | | |
| 395 | | final var tab = createTab( inputFile ); |
| 396 | | final var node = tab.getContent(); |
| 397 | | final var mediaType = MediaType.valueFrom( inputFile ); |
| 398 | | final var tabPane = obtainTabPane( mediaType ); |
| 399 | | |
| 400 | | tab.setTooltip( createTooltip( inputFile ) ); |
| 401 | | tabPane.setFocusTraversable( false ); |
| 402 | | tabPane.setTabClosingPolicy( ALL_TABS ); |
| 403 | | tabPane.getTabs().add( tab ); |
| 404 | | |
| 405 | | // Attach the tab scene factory for new tab panes. |
| 406 | | if( !getItems().contains( tabPane ) ) { |
| 407 | | addTabPane( |
| 408 | | node instanceof TextDefinition ? 0 : getItems().size(), tabPane |
| 409 | | ); |
| 410 | | } |
| 411 | | |
| 412 | | if( inputFile.isFile() ) { |
| 413 | | getRecentFiles().add( inputFile.getAbsolutePath() ); |
| 414 | | } |
| 415 | | } |
| 416 | | |
| 417 | | /** |
| 418 | | * Gives focus to the most recently edited document and attempts to move |
| 419 | | * the caret to the most recently known offset into said document. |
| 420 | | */ |
| 421 | | private void restoreSession() { |
| 422 | | final var workspace = getWorkspace(); |
| 423 | | final var file = workspace.fileProperty( KEY_UI_RECENT_DOCUMENT ); |
| 424 | | final var offset = workspace.integerProperty( KEY_UI_RECENT_OFFSET ); |
| 425 | | |
| 426 | | for( final var pane : mTabPanes ) { |
| 427 | | for( final var tab : pane.getTabs() ) { |
| 428 | | final var tooltip = tab.getTooltip(); |
| 429 | | |
| 430 | | if( tooltip != null ) { |
| 431 | | final var tabName = tooltip.getText(); |
| 432 | | final var fileName = file.getValue().toString(); |
| 433 | | |
| 434 | | if( tabName.equalsIgnoreCase( fileName ) ) { |
| 435 | | final var node = tab.getContent(); |
| 436 | | |
| 437 | | pane.getSelectionModel().select( tab ); |
| 438 | | node.requestFocus(); |
| 439 | | |
| 440 | | if( node instanceof TextEditor editor ) { |
| 441 | | editor.moveTo( offset.getValue() ); |
| 442 | | } |
| 443 | | |
| 444 | | break; |
| 445 | | } |
| 446 | | } |
| 447 | | } |
| 448 | | } |
| 449 | | } |
| 450 | | |
| 451 | | /** |
| 452 | | * Sets the focus to the middle pane, which contains the text editor tabs. |
| 453 | | */ |
| 454 | | private void restoreFocus() { |
| 455 | | // Work around a bug where focusing directly on the middle pane results |
| 456 | | // in the R engine not loading variables properly. |
| 457 | | mTabPanes.get( 0 ).requestFocus(); |
| 458 | | |
| 459 | | // This is the only line that should be required. |
| 460 | | mTabPanes.get( 1 ).requestFocus(); |
| 461 | | } |
| 462 | | |
| 463 | | /** |
| 464 | | * Opens a new text editor document using the default document file name. |
| 465 | | */ |
| 466 | | public void newTextEditor() { |
| 467 | | open( DOCUMENT_DEFAULT ); |
| 468 | | } |
| 469 | | |
| 470 | | /** |
| 471 | | * Opens a new definition editor document using the default definition |
| 472 | | * file name. |
| 473 | | */ |
| 474 | | public void newDefinitionEditor() { |
| 475 | | open( DEFINITION_DEFAULT ); |
| 476 | | } |
| 477 | | |
| 478 | | /** |
| 479 | | * Iterates over all tab panes to find all {@link TextEditor}s and request |
| 480 | | * that they save themselves. |
| 481 | | */ |
| 482 | | public void saveAll() { |
| 483 | | iterateEditors( this::save ); |
| 484 | | } |
| 485 | | |
| 486 | | /** |
| 487 | | * Requests that the active {@link TextEditor} saves itself. Don't bother |
| 488 | | * checking if modified first because if the user swaps external media from |
| 489 | | * an external source (e.g., USB thumb drive), save should not second-guess |
| 490 | | * the user: save always re-saves. Also, it's less code. |
| 491 | | */ |
| 492 | | public void save() { |
| 493 | | save( getTextEditor() ); |
| 494 | | } |
| 495 | | |
| 496 | | /** |
| 497 | | * Saves the active {@link TextEditor} under a new name. |
| 498 | | * |
| 499 | | * @param files The new active editor {@link File} reference, must contain |
| 500 | | * at least one element. |
| 501 | | */ |
| 502 | | public void saveAs( final List<File> files ) { |
| 503 | | assert files != null; |
| 504 | | assert !files.isEmpty(); |
| 505 | | final var editor = getTextEditor(); |
| 506 | | final var tab = getTab( editor ); |
| 507 | | final var file = files.get( 0 ); |
| 508 | | |
| 509 | | // If the file type has changed, refresh the processors. |
| 510 | | final var mediaType = MediaType.valueFrom( file ); |
| 511 | | final var typeChanged = !editor.isMediaType( mediaType ); |
| 512 | | |
| 513 | | if( typeChanged ) { |
| 514 | | removeProcessor( editor ); |
| 515 | | } |
| 516 | | |
| 517 | | editor.rename( file ); |
| 518 | | tab.ifPresent( t -> { |
| 519 | | t.setText( editor.getFilename() ); |
| 520 | | t.setTooltip( createTooltip( file ) ); |
| 521 | | } ); |
| 522 | | |
| 523 | | if( typeChanged ) { |
| 524 | | updateProcessors( editor ); |
| 525 | | process( editor ); |
| 526 | | } |
| 527 | | |
| 528 | | save(); |
| 529 | | } |
| 530 | | |
| 531 | | /** |
| 532 | | * Saves the given {@link TextResource} to a file. This is typically used |
| 533 | | * to save either an instance of {@link TextEditor} or {@link TextDefinition}. |
| 534 | | * |
| 535 | | * @param resource The resource to export. |
| 536 | | */ |
| 537 | | private void save( final TextResource resource ) { |
| 538 | | try { |
| 539 | | resource.save(); |
| 540 | | } catch( final Exception ex ) { |
| 541 | | clue( ex ); |
| 542 | | sNotifier.alert( |
| 543 | | getWindow(), resource.getPath(), "TextResource.saveFailed", ex |
| 544 | | ); |
| 545 | | } |
| 546 | | } |
| 547 | | |
| 548 | | /** |
| 549 | | * Closes all open {@link TextEditor}s; all {@link TextDefinition}s stay open. |
| 550 | | * |
| 551 | | * @return {@code true} when all editors, modified or otherwise, were |
| 552 | | * permitted to close; {@code false} when one or more editors were modified |
| 553 | | * and the user requested no closing. |
| 554 | | */ |
| 555 | | public boolean closeAll() { |
| 556 | | var closable = true; |
| 557 | | |
| 558 | | for( final var tabPane : mTabPanes ) { |
| 559 | | final var tabIterator = tabPane.getTabs().iterator(); |
| 560 | | |
| 561 | | while( tabIterator.hasNext() ) { |
| 562 | | final var tab = tabIterator.next(); |
| 563 | | final var resource = tab.getContent(); |
| 564 | | |
| 565 | | // The definition panes auto-save, so being specific here prevents |
| 566 | | // closing the definitions in the situation where the user wants to |
| 567 | | // continue editing (i.e., possibly save unsaved work). |
| 568 | | if( !(resource instanceof TextEditor) ) { |
| 569 | | continue; |
| 570 | | } |
| 571 | | |
| 572 | | if( canClose( (TextEditor) resource ) ) { |
| 573 | | tabIterator.remove(); |
| 574 | | close( tab ); |
| 575 | | } |
| 576 | | else { |
| 577 | | closable = false; |
| 578 | | } |
| 579 | | } |
| 580 | | } |
| 581 | | |
| 582 | | return closable; |
| 583 | | } |
| 584 | | |
| 585 | | /** |
| 586 | | * Calls the tab's {@link Tab#getOnClosed()} handler to carry out a close |
| 587 | | * event. |
| 588 | | * |
| 589 | | * @param tab The {@link Tab} that was closed. |
| 590 | | */ |
| 591 | | private void close( final Tab tab ) { |
| 592 | | assert tab != null; |
| 593 | | |
| 594 | | final var handler = tab.getOnClosed(); |
| 595 | | |
| 596 | | if( handler != null ) { |
| 597 | | handler.handle( new ActionEvent() ); |
| 598 | | } |
| 599 | | } |
| 600 | | |
| 601 | | /** |
| 602 | | * Closes the active tab; delegates to {@link #canClose(TextResource)}. |
| 603 | | */ |
| 604 | | public void close() { |
| 605 | | final var editor = getTextEditor(); |
| 606 | | |
| 607 | | if( canClose( editor ) ) { |
| 608 | | close( editor ); |
| 609 | | } |
| 610 | | } |
| 611 | | |
| 612 | | /** |
| 613 | | * Closes the given {@link TextResource}. This must not be called from within |
| 614 | | * a loop that iterates over the tab panes using {@code forEach}, lest a |
| 615 | | * concurrent modification exception be thrown. |
| 616 | | * |
| 617 | | * @param resource The {@link TextResource} to close, without confirming with |
| 618 | | * the user. |
| 619 | | */ |
| 620 | | private void close( final TextResource resource ) { |
| 621 | | getTab( resource ).ifPresent( |
| 622 | | tab -> { |
| 623 | | close( tab ); |
| 624 | | tab.getTabPane().getTabs().remove( tab ); |
| 625 | | } |
| 626 | | ); |
| 627 | | } |
| 628 | | |
| 629 | | /** |
| 630 | | * Answers whether the given {@link TextResource} may be closed. |
| 631 | | * |
| 632 | | * @param editor The {@link TextResource} to try closing. |
| 633 | | * @return {@code true} when the editor may be closed; {@code false} when |
| 634 | | * the user has requested to keep the editor open. |
| 635 | | */ |
| 636 | | private boolean canClose( final TextResource editor ) { |
| 637 | | final var editorTab = getTab( editor ); |
| 638 | | final var canClose = new AtomicBoolean( true ); |
| 639 | | |
| 640 | | if( editor.isModified() ) { |
| 641 | | final var filename = new StringBuilder(); |
| 642 | | editorTab.ifPresent( tab -> filename.append( tab.getText() ) ); |
| 643 | | |
| 644 | | final var message = sNotifier.createNotification( |
| 645 | | Messages.get( "Alert.file.close.title" ), |
| 646 | | Messages.get( "Alert.file.close.text" ), |
| 647 | | filename.toString() |
| 648 | | ); |
| 649 | | |
| 650 | | final var dialog = sNotifier.createConfirmation( getWindow(), message ); |
| 651 | | |
| 652 | | dialog.showAndWait().ifPresent( |
| 653 | | save -> canClose.set( save == YES ? editor.save() : save == NO ) |
| 654 | | ); |
| 655 | | } |
| 656 | | |
| 657 | | return canClose.get(); |
| 658 | | } |
| 659 | | |
| 660 | | private void iterateEditors( final Consumer<TextEditor> consumer ) { |
| 661 | | mTabPanes.forEach( |
| 662 | | tp -> tp.getTabs().forEach( tab -> { |
| 663 | | final var node = tab.getContent(); |
| 664 | | |
| 665 | | if( node instanceof final TextEditor editor ) { |
| 666 | | consumer.accept( editor ); |
| 667 | | } |
| 668 | | } ) |
| 669 | | ); |
| 670 | | } |
| 671 | | |
| 672 | | private ObjectProperty<TextEditor> createActiveTextEditor() { |
| 673 | | final var editor = new SimpleObjectProperty<TextEditor>(); |
| 674 | | |
| 675 | | editor.addListener( ( c, o, n ) -> { |
| 676 | | if( n != null ) { |
| 677 | | mPreview.setBaseUri( n.getPath() ); |
| 678 | | process( n ); |
| 679 | | } |
| 680 | | } ); |
| 681 | | |
| 682 | | return editor; |
| 683 | | } |
| 684 | | |
| 685 | | /** |
| 686 | | * Adds the HTML preview tab to its own, singular tab pane. |
| 687 | | */ |
| 688 | | public void viewPreview() { |
| 689 | | viewTab( mPreview, TEXT_HTML, "Pane.preview.title" ); |
| 690 | | } |
| 691 | | |
| 692 | | /** |
| 693 | | * Adds the document outline tab to its own, singular tab pane. |
| 694 | | */ |
| 695 | | public void viewOutline() { |
| 696 | | viewTab( mOutline, APP_DOCUMENT_OUTLINE, "Pane.outline.title" ); |
| 697 | | } |
| 698 | | |
| 699 | | public void viewStatistics() { |
| 700 | | viewTab( mStatistics, APP_DOCUMENT_STATISTICS, "Pane.statistics.title" ); |
| 701 | | } |
| 702 | | |
| 703 | | public void viewFiles() { |
| 704 | | try { |
| 705 | | final var factory = new FilePickerFactory( getWorkspace() ); |
| 706 | | final var fileManager = factory.createModeless(); |
| 707 | | viewTab( fileManager, APP_FILE_MANAGER, "Pane.files.title" ); |
| 708 | | } catch( final Exception ex ) { |
| 709 | | clue( ex ); |
| 710 | | } |
| 711 | | } |
| 712 | | |
| 713 | | private void viewTab( |
| 714 | | final Node node, final MediaType mediaType, final String key ) { |
| 715 | | final var tabPane = obtainTabPane( mediaType ); |
| 716 | | |
| 717 | | for( final var tab : tabPane.getTabs() ) { |
| 718 | | if( tab.getContent() == node ) { |
| 719 | | return; |
| 720 | | } |
| 721 | | } |
| 722 | | |
| 723 | | tabPane.getTabs().add( createTab( get( key ), node ) ); |
| 724 | | addTabPane( tabPane ); |
| 725 | | } |
| 726 | | |
| 727 | | public void viewRefresh() { |
| 728 | | mPreview.refresh(); |
| 729 | | Engine.clear(); |
| 730 | | mRBootstrapController.update(); |
| 731 | | } |
| 732 | | |
| 733 | | /** |
| 734 | | * Returns the tab that contains the given {@link TextEditor}. |
| 735 | | * |
| 736 | | * @param editor The {@link TextEditor} instance to find amongst the tabs. |
| 737 | | * @return The first tab having content that matches the given tab. |
| 738 | | */ |
| 739 | | private Optional<Tab> getTab( final TextResource editor ) { |
| 740 | | return mTabPanes.stream() |
| 741 | | .flatMap( pane -> pane.getTabs().stream() ) |
| 742 | | .filter( tab -> editor.equals( tab.getContent() ) ) |
| 743 | | .findFirst(); |
| 744 | | } |
| 745 | | |
| 746 | | /** |
| 747 | | * Creates a new {@link DefinitionEditor} wrapped in a listener that |
| 748 | | * is used to detect when the active {@link DefinitionEditor} has changed. |
| 749 | | * Upon changing, the variables are interpolated and the active text editor |
| 750 | | * is refreshed. |
| 751 | | * |
| 752 | | * @param textEditor Text editor to update with the revised resolved map. |
| 753 | | * @return A newly configured property that represents the active |
| 754 | | * {@link DefinitionEditor}, never null. |
| 755 | | */ |
| 756 | | private ObjectProperty<TextDefinition> createActiveDefinitionEditor( |
| 757 | | final ObjectProperty<TextEditor> textEditor ) { |
| 758 | | final var defEditor = new SimpleObjectProperty<>( |
| 759 | | createDefinitionEditor() |
| 760 | | ); |
| 761 | | |
| 762 | | defEditor.addListener( ( c, o, n ) -> { |
| 763 | | final var editor = textEditor.get(); |
| 764 | | |
| 765 | | if( editor.isMediaType( TEXT_R_MARKDOWN ) ) { |
| 766 | | // Initialize R before the editor is added. |
| 767 | | mRBootstrapController.update(); |
| 768 | | } |
| 769 | | |
| 770 | | process( editor ); |
| 771 | | } ); |
| 772 | | |
| 773 | | return defEditor; |
| 774 | | } |
| 775 | | |
| 776 | | private Tab createTab( final String filename, final Node node ) { |
| 777 | | return new DetachableTab( filename, node ); |
| 778 | | } |
| 779 | | |
| 780 | | private Tab createTab( final File file ) { |
| 781 | | final var r = createTextResource( file ); |
| 782 | | final var tab = createTab( r.getFilename(), r.getNode() ); |
| 783 | | |
| 784 | | r.modifiedProperty().addListener( |
| 785 | | ( c, o, n ) -> tab.setText( r.getFilename() + (n ? "*" : "") ) |
| 786 | | ); |
| 787 | | |
| 788 | | // This is called when either the tab is closed by the user clicking on |
| 789 | | // the tab's close icon or when closing (all) from the file menu. |
| 790 | | tab.setOnClosed( |
| 791 | | __ -> getRecentFiles().remove( file.getAbsolutePath() ) |
| 792 | | ); |
| 793 | | |
| 794 | | // When closing a tab, give focus to the newly revealed tab. |
| 795 | | tab.selectedProperty().addListener( ( c, o, n ) -> { |
| 796 | | if( n != null && n ) { |
| 797 | | final var pane = tab.getTabPane(); |
| 798 | | |
| 799 | | if( pane != null ) { |
| 800 | | pane.requestFocus(); |
| 801 | | } |
| 802 | | } |
| 803 | | } ); |
| 804 | | |
| 805 | | tab.tabPaneProperty().addListener( ( cPane, oPane, nPane ) -> { |
| 806 | | if( nPane != null ) { |
| 807 | | nPane.focusedProperty().addListener( ( c, o, n ) -> { |
| 808 | | if( n != null && n ) { |
| 809 | | final var selected = nPane.getSelectionModel().getSelectedItem(); |
| 810 | | final var node = selected.getContent(); |
| 811 | | node.requestFocus(); |
| 812 | | } |
| 813 | | } ); |
| 814 | | } |
| 815 | | } ); |
| 816 | | |
| 817 | | return tab; |
| 818 | | } |
| 819 | | |
| 820 | | /** |
| 821 | | * Creates bins for the different {@link MediaType}s, which eventually are |
| 822 | | * added to the UI as separate tab panes. If ever a general-purpose scene |
| 823 | | * exporter is developed to serialize a scene to an FXML file, this could |
| 824 | | * be replaced by such a class. |
| 825 | | * <p> |
| 826 | | * When binning the files, this makes sure that at least one file exists |
| 827 | | * for every type. If the user has opted to close a particular type (such |
| 828 | | * as the definition pane), the view will suppressed elsewhere. |
| 829 | | * </p> |
| 830 | | * <p> |
| 831 | | * The order that the binned files are returned will be reflected in the |
| 832 | | * order that the corresponding panes are rendered in the UI. |
| 833 | | * </p> |
| 834 | | * |
| 835 | | * @param paths The file paths to bin according to their type. |
| 836 | | * @return An in-order list of files, first by structured definition files, |
| 837 | | * then by plain text documents. |
| 838 | | */ |
| 839 | | private List<File> collect( final SetProperty<String> paths ) { |
| 840 | | // Treat all files destined for the text editor as plain text documents |
| 841 | | // so that they are added to the same pane. Grouping by TEXT_PLAIN is a |
| 842 | | // bit arbitrary, but means explicitly capturing TEXT_PLAIN isn't needed. |
| 843 | | final Function<MediaType, MediaType> bin = |
| 844 | | m -> PLAIN_TEXT_FORMAT.contains( m ) ? TEXT_PLAIN : m; |
| 845 | | |
| 846 | | // Create two groups: YAML files and plain text files. The order that |
| 847 | | // the elements are listed in the enumeration for media types determines |
| 848 | | // what files are loaded first. Variable definitions come before all other |
| 849 | | // plain text documents. |
| 850 | | final var bins = paths |
| 851 | | .stream() |
| 852 | | .collect( |
| 853 | | groupingBy( |
| 854 | | path -> bin.apply( MediaType.fromFilename( path ) ), |
| 855 | | () -> new TreeMap<>( Enum::compareTo ), |
| 856 | | Collectors.toList() |
| 857 | | ) |
| 858 | | ); |
| 859 | | |
| 860 | | bins.putIfAbsent( TEXT_YAML, List.of( DEFINITION_DEFAULT.toString() ) ); |
| 861 | | bins.putIfAbsent( TEXT_PLAIN, List.of( DOCUMENT_DEFAULT.toString() ) ); |
| 862 | | |
| 863 | | final var result = new LinkedList<File>(); |
| 864 | | |
| 865 | | // Ensure that the same types are listed together (keep insertion order). |
| 866 | | bins.forEach( ( mediaType, files ) -> result.addAll( |
| 867 | | files.stream().map( File::new ).toList() ) |
| 868 | | ); |
| 869 | | |
| 870 | | return result; |
| 871 | | } |
| 872 | | |
| 873 | | /** |
| 874 | | * Force the active editor to update, which will cause the processor |
| 875 | | * to re-evaluate the interpolated definition map thereby updating the |
| 876 | | * preview pane. |
| 877 | | * |
| 878 | | * @param editor Contains the source document to update in the preview pane. |
| 879 | | */ |
| 880 | | private void process( final TextEditor editor ) { |
| 881 | | // Ensure processing does not run on the JavaFX thread, which frees the |
| 882 | | // text editor immediately for caret movement. The preview will have a |
| 883 | | // slight delay when catching up to the caret position. |
| 884 | | final var task = new Task<Void>() { |
| 885 | | @Override |
| 886 | | public Void call() { |
| 887 | | try { |
| 888 | | final var p = mProcessors.getOrDefault( editor, IDENTITY ); |
| 889 | | p.apply( editor == null ? "" : editor.getText() ); |
| 890 | | } catch( final Exception ex ) { |
| 891 | | clue( ex ); |
| 892 | | } |
| 893 | | |
| 894 | | return null; |
| 895 | | } |
| 896 | | }; |
| 897 | | |
| 898 | | // TODO: Each time the editor successfully runs the processor the task is |
| 899 | | // considered successful. Due to the rapid-fire nature of processing |
| 900 | | // (e.g., keyboard navigation, fast typing), it isn't necessary to |
| 901 | | // scroll each time. |
| 902 | | // The algorithm: |
| 903 | | // 1. Peek at the oldest time. |
| 904 | | // 2. If the difference between the oldest time and current time exceeds |
| 905 | | // 250 milliseconds, then invoke the scrolling. |
| 906 | | // 3. Insert the current time into the circular queue. |
| 907 | | task.setOnSucceeded( |
| 908 | | e -> invokeLater( () -> mPreview.scrollTo( CARET_ID ) ) |
| 909 | | ); |
| 910 | | |
| 911 | | // Prevents multiple process requests from executing simultaneously (due |
| 912 | | // to having a restricted queue size). |
| 913 | | sExecutor.execute( task ); |
| 914 | | } |
| 915 | | |
| 916 | | /** |
| 917 | | * Lazily creates a {@link TabPane} configured to listen for tab select |
| 918 | | * events. The tab pane is associated with a given media type so that |
| 919 | | * similar files can be grouped together. |
| 920 | | * |
| 921 | | * @param mediaType The media type to associate with the tab pane. |
| 922 | | * @return An instance of {@link TabPane} that will handle tab docking. |
| 923 | | */ |
| 924 | | private TabPane obtainTabPane( final MediaType mediaType ) { |
| 925 | | for( final var pane : mTabPanes ) { |
| 926 | | for( final var tab : pane.getTabs() ) { |
| 927 | | final var node = tab.getContent(); |
| 928 | | |
| 929 | | if( node instanceof TextResource r && r.supports( mediaType ) ) { |
| 930 | | return pane; |
| 931 | | } |
| 932 | | } |
| 933 | | } |
| 934 | | |
| 935 | | final var pane = createTabPane(); |
| 936 | | mTabPanes.add( pane ); |
| 937 | | return pane; |
| 938 | | } |
| 939 | | |
| 940 | | /** |
| 941 | | * Creates an initialized {@link TabPane} instance. |
| 942 | | * |
| 943 | | * @return A new {@link TabPane} with all listeners configured. |
| 944 | | */ |
| 945 | | private TabPane createTabPane() { |
| 946 | | final var tabPane = new DetachableTabPane(); |
| 947 | | |
| 948 | | initStageOwnerFactory( tabPane ); |
| 949 | | initTabListener( tabPane ); |
| 950 | | |
| 951 | | return tabPane; |
| 952 | | } |
| 953 | | |
| 954 | | /** |
| 955 | | * When any {@link DetachableTabPane} is detached from the main window, |
| 956 | | * the stage owner factory must be given its parent window, which will |
| 957 | | * own the child window. The parent window is the {@link MainPane}'s |
| 958 | | * {@link Scene}'s {@link Window} instance. |
| 959 | | * |
| 960 | | * <p> |
| 961 | | * This will derives the new title from the main window title, incrementing |
| 962 | | * the window count to help uniquely identify the child windows. |
| 963 | | * </p> |
| 964 | | * |
| 965 | | * @param tabPane A new {@link DetachableTabPane} to configure. |
| 966 | | */ |
| 967 | | private void initStageOwnerFactory( final DetachableTabPane tabPane ) { |
| 968 | | tabPane.setStageOwnerFactory( stage -> { |
| 969 | | final var title = get( |
| 970 | | "Detach.tab.title", |
| 971 | | ((Stage) getWindow()).getTitle(), ++mWindowCount |
| 972 | | ); |
| 973 | | stage.setTitle( title ); |
| 974 | | |
| 975 | | return getScene().getWindow(); |
| 976 | | } ); |
| 977 | | } |
| 978 | | |
| 979 | | /** |
| 980 | | * Responsible for configuring the content of each {@link DetachableTab} when |
| 981 | | * it is added to the given {@link DetachableTabPane} instance. |
| 982 | | * <p> |
| 983 | | * For {@link TextEditor} contents, an instance of {@link ScrollEventHandler} |
| 984 | | * is initialized to perform synchronized scrolling between the editor and |
| 985 | | * its preview window. Additionally, the last tab in the tab pane's list of |
| 986 | | * tabs is given focus. |
| 987 | | * </p> |
| 988 | | * <p> |
| 989 | | * Note that multiple tabs can be added simultaneously. |
| 990 | | * </p> |
| 991 | | * |
| 992 | | * @param tabPane A new {@link TabPane} to configure. |
| 993 | | */ |
| 994 | | private void initTabListener( final TabPane tabPane ) { |
| 995 | | tabPane.getTabs().addListener( |
| 996 | | ( final ListChangeListener.Change<? extends Tab> listener ) -> { |
| 997 | | while( listener.next() ) { |
| 998 | | if( listener.wasAdded() ) { |
| 999 | | final var tabs = listener.getAddedSubList(); |
| 1000 | | |
| 1001 | | tabs.forEach( tab -> { |
| 1002 | | final var node = tab.getContent(); |
| 1003 | | |
| 1004 | | if( node instanceof TextEditor ) { |
| 1005 | | initScrollEventListener( tab ); |
| 1006 | | } |
| 1007 | | } ); |
| 1008 | | |
| 1009 | | // Select and give focus to the last tab opened. |
| 1010 | | final var index = tabs.size() - 1; |
| 1011 | | if( index >= 0 ) { |
| 1012 | | final var tab = tabs.get( index ); |
| 1013 | | tabPane.getSelectionModel().select( tab ); |
| 1014 | | tab.getContent().requestFocus(); |
| 1015 | | } |
| 1016 | | } |
| 1017 | | } |
| 1018 | | } |
| 1019 | | ); |
| 1020 | | } |
| 1021 | | |
| 1022 | | /** |
| 1023 | | * Synchronizes scrollbar positions between the given {@link Tab} that |
| 1024 | | * contains an instance of {@link TextEditor} and {@link HtmlPreview} pane. |
| 1025 | | * |
| 1026 | | * @param tab The container for an instance of {@link TextEditor}. |
| 1027 | | */ |
| 1028 | | private void initScrollEventListener( final Tab tab ) { |
| 1029 | | final var editor = (TextEditor) tab.getContent(); |
| 1030 | | final var scrollPane = editor.getScrollPane(); |
| 1031 | | final var scrollBar = mPreview.getVerticalScrollBar(); |
| 1032 | | final var handler = new ScrollEventHandler( scrollPane, scrollBar ); |
| 1033 | | |
| 1034 | | handler.enabledProperty().bind( tab.selectedProperty() ); |
| 1035 | | } |
| 1036 | | |
| 1037 | | private void addTabPane( final int index, final TabPane tabPane ) { |
| 1038 | | final var items = getItems(); |
| 1039 | | |
| 1040 | | if( !items.contains( tabPane ) ) { |
| 1041 | | items.add( index, tabPane ); |
| 1042 | | } |
| 1043 | | } |
| 1044 | | |
| 1045 | | private void addTabPane( final TabPane tabPane ) { |
| 1046 | | addTabPane( getItems().size(), tabPane ); |
| 1047 | | } |
| 1048 | | |
| 1049 | | private GenericBuilder<Mutator, ProcessorContext> processorContextBuilder() { |
| 1050 | | final var w = getWorkspace(); |
| 1051 | | |
| 1052 | | return builder() |
| 1053 | | .with( Mutator::setDefinitions, this::getDefinitions ) |
| 1054 | | .with( Mutator::setLocale, w::getLocale ) |
| 1055 | | .with( Mutator::setMetadata, w::getMetadata ) |
| 1056 | | .with( Mutator::setThemesDir, w::getThemesPath ) |
| 1057 | | .with( Mutator::setCachesDir, |
| 1058 | | () -> w.getFile( KEY_CACHES_DIR ) ) |
| 1059 | | .with( Mutator::setImagesDir, |
| 1060 | | () -> w.getFile( KEY_IMAGES_DIR ) ) |
| 1061 | | .with( Mutator::setImageOrder, |
| 1062 | | () -> w.getString( KEY_IMAGES_ORDER ) ) |
| 1063 | | .with( Mutator::setImageServer, |
| 1064 | | () -> w.getString( KEY_IMAGES_SERVER ) ) |
| 1065 | | .with( Mutator::setFontsDir, |
| 1066 | | () -> w.getFile( KEY_TYPESET_CONTEXT_FONTS_DIR ) ) |
| 1067 | | .with( Mutator::setCaret, |
| 1068 | | () -> getTextEditor().getCaret() ) |
| 1069 | | .with( Mutator::setSigilBegan, |
| 1070 | | () -> w.getString( KEY_DEF_DELIM_BEGAN ) ) |
| 1071 | | .with( Mutator::setSigilEnded, |
| 1072 | | () -> w.getString( KEY_DEF_DELIM_ENDED ) ) |
| 1073 | | .with( Mutator::setRScript, |
| 1074 | | () -> w.getString( KEY_R_SCRIPT ) ) |
| 1075 | | .with( Mutator::setRWorkingDir, |
| 1076 | | () -> w.getFile( KEY_R_DIR ).toPath() ) |
| 1077 | | .with( Mutator::setCurlQuotes, |
| 1078 | | () -> w.getBoolean( KEY_TYPESET_TYPOGRAPHY_QUOTES ) ) |
| 1079 | | .with( Mutator::setAutoRemove, |
| 1080 | | () -> w.getBoolean( KEY_TYPESET_CONTEXT_CLEAN ) ); |
| 1081 | | } |
| 1082 | | |
| 1083 | | public ProcessorContext createProcessorContext() { |
| 1084 | | return createProcessorContextBuilder( NONE ).build(); |
| 1085 | | } |
| 1086 | | |
| 1087 | | private GenericBuilder<Mutator, ProcessorContext> createProcessorContextBuilder( |
| 1088 | | final ExportFormat format ) { |
| 1089 | | final var textEditor = getTextEditor(); |
| 1090 | | final var sourcePath = textEditor.getPath(); |
| 1091 | | |
| 1092 | | return processorContextBuilder() |
| 1093 | | .with( Mutator::setSourcePath, sourcePath ) |
| 1094 | | .with( Mutator::setExportFormat, format ); |
| 1095 | | } |
| 1096 | | |
| 1097 | | /** |
| 1098 | | * @param targetPath Used when exporting to a PDF file (binary). |
| 1099 | | * @param format Used when processors export to a new text format. |
| 1100 | | * @return A new {@link ProcessorContext} to use when creating an instance of |
| 1101 | | * {@link Processor}. |
| 1102 | | */ |
| 1103 | | public ProcessorContext createProcessorContext( |
| 1104 | | final Path targetPath, final ExportFormat format ) { |
| 1105 | | assert targetPath != null; |
| 1106 | | assert format != null; |
| 1107 | | |
| 1108 | | return createProcessorContextBuilder( format ) |
| 1109 | | .with( Mutator::setTargetPath, targetPath ) |
| 1110 | | .build(); |
| 1111 | | } |
| 1112 | | |
| 1113 | | /** |
| 1114 | | * @param sourcePath Used by {@link ProcessorFactory} to determine |
| 1115 | | * {@link Processor} type to create based on file type. |
| 1116 | | * @return A new {@link ProcessorContext} to use when creating an instance of |
| 1117 | | * {@link Processor}. |
| 1118 | | */ |
| 1119 | | private ProcessorContext createProcessorContext( final Path sourcePath ) { |
| 1120 | | return processorContextBuilder() |
| 1121 | | .with( Mutator::setSourcePath, sourcePath ) |
| 1122 | | .with( Mutator::setExportFormat, NONE ) |
| 1123 | | .build(); |
| 1124 | | } |
| 1125 | | |
| 1126 | | private TextResource createTextResource( final File file ) { |
| 1127 | | // TODO: Create PlainTextEditor that's returned by default. |
| 1128 | | return MediaType.valueFrom( file ) == TEXT_YAML |
| 1129 | | ? createDefinitionEditor( file ) |
| 1130 | | : createMarkdownEditor( file ); |
| 1131 | | } |
| 1132 | | |
| 1133 | | /** |
| 1134 | | * Creates an instance of {@link MarkdownEditor} that listens for both |
| 1135 | | * caret change events and text change events. Text change events must |
| 1136 | | * take priority over caret change events because it's possible to change |
| 1137 | | * the text without moving the caret (e.g., delete selected text). |
| 1138 | | * |
| 1139 | | * @param inputFile The file containing contents for the text editor. |
| 1140 | | * @return A non-null text editor. |
| 1141 | | */ |
| 1142 | | private MarkdownEditor createMarkdownEditor( final File inputFile ) { |
| 1143 | | final var editor = new MarkdownEditor( inputFile, getWorkspace() ); |
| 1144 | | |
| 1145 | | updateProcessors( editor ); |
| 1146 | | |
| 1147 | | // Listener for editor modifications or caret position changes. |
| 1148 | | editor.addDirtyListener( ( c, o, n ) -> { |
| 1149 | | if( n ) { |
| 1150 | | // Reset the status bar after changing the text. |
| 1151 | | clue(); |
| 1152 | | |
| 1153 | | // Processing the text may update the status bar. |
| 1154 | | process( getTextEditor() ); |
| 1155 | | |
| 1156 | | // Update the caret position in the status bar. |
| 1157 | | CaretMovedEvent.fire( editor.getCaret() ); |
| 1158 | | } |
| 1159 | | } ); |
| 1160 | | |
| 1161 | | editor.addEventListener( |
| 1162 | | keyPressed( SPACE, CONTROL_DOWN ), this::autoinsert |
| 1163 | | ); |
| 1164 | | |
| 1165 | | editor.addEventListener( |
| 1166 | | keyPressed( ENTER, ALT_DOWN ), event -> mEditorSpeller.autofix( editor ) |
| 1167 | | ); |
| 1168 | | |
| 1169 | | final var textArea = editor.getTextArea(); |
| 1170 | | |
| 1171 | | // Spell check when the paragraph changes. |
| 1172 | | textArea |
| 1173 | | .plainTextChanges() |
| 1174 | | .filter( p -> !p.isIdentity() ) |
| 1175 | | .subscribe( change -> mEditorSpeller.checkParagraph( textArea, change ) ); |
| 1176 | | |
| 1177 | | // Store the caret position to restore it after restarting the application. |
| 1178 | | textArea.caretPositionProperty().addListener( |
| 1179 | | ( c, o, n ) -> |
| 1180 | | getWorkspace().integerProperty( KEY_UI_RECENT_OFFSET ).setValue( n ) |
| 1181 | | ); |
| 1182 | | |
| 1183 | | // Set the active editor, which refreshes the preview panel. |
| 1184 | | mTextEditor.set( editor ); |
| 1185 | | |
| 1186 | | // Check the entire document after the spellchecker is initialized (with |
| 1187 | | // a valid lexicon) so that only the current paragraph need be scanned |
| 1188 | | // while editing. (Technically, only the most recently modified word must |
| 1189 | | // be scanned.) |
| 1190 | | mSpellChecker.addListener( |
| 1191 | | ( c, o, n ) -> runLater( |
| 1192 | | () -> iterateEditors( mEditorSpeller::checkDocument ) |
| 1193 | | ) |
| 1194 | | ); |
| 1195 | | |
| 1196 | | // Check the entire document after it has been loaded. |
| 1197 | | mEditorSpeller.checkDocument( mTextEditor.get() ); |
| 1198 | | |
| 1199 | | return editor; |
| 1200 | | } |
| 1201 | | |
| 1202 | | /** |
| 1203 | | * Creates a processor for an editor, provided one doesn't already exist. |
| 1204 | | * |
| 1205 | | * @param editor The editor that potentially requires an associated processor. |
| 1206 | | */ |
| 1207 | | private void updateProcessors( final TextEditor editor ) { |
| 1208 | | final var path = editor.getFile().toPath(); |
| 1209 | | |
| 1210 | | mProcessors.computeIfAbsent( |
| 1211 | | editor, p -> createProcessors( |
| 1212 | | createProcessorContext( path ), |
| 1213 | | createHtmlPreviewProcessor() |
| 1214 | | ) |
| 1215 | | ); |
| 1216 | | } |
| 1217 | | |
| 1218 | | /** |
| 1219 | | * Removes a processor for an editor. This is required because a file may |
| 1220 | | * change type while editing (e.g., from plain Markdown to R Markdown). |
| 1221 | | * In the case that an editor's type changes, its associated processor must |
| 1222 | | * be changed accordingly. |
| 1223 | | * |
| 1224 | | * @param editor The editor that potentially requires an associated processor. |
| 1225 | | */ |
| 1226 | | private void removeProcessor( final TextEditor editor ) { |
| 1227 | | mProcessors.remove( editor ); |
| 1228 | | } |
| 1229 | | |
| 1230 | | /** |
| 1231 | | * Creates a {@link Processor} capable of rendering an HTML document onto |
| 1232 | | * a GUI widget. |
| 1233 | | * |
| 1234 | | * @return The {@link Processor} for rendering an HTML document. |
| 1235 | | */ |
| 1236 | | private Processor<String> createHtmlPreviewProcessor() { |
| 1237 | | return new HtmlPreviewProcessor( getPreview() ); |
| 1238 | | } |
| 1239 | | |
| 1240 | | /** |
| 1241 | | * Creates a spellchecker that accepts all words as correct. This allows |
| 1242 | | * the spellchecker property to be initialized to a known valid value. |
| 1243 | | * |
| 1244 | | * @return A wrapped {@link PermissiveSpeller}. |
| 1245 | | */ |
| 1246 | | private ObjectProperty<SpellChecker> createSpellChecker() { |
| 1247 | | return new SimpleObjectProperty<>( new PermissiveSpeller() ); |
| 1248 | | } |
| 1249 | | |
| 1250 | | private TextEditorSpellChecker createTextEditorSpellChecker( |
| 1251 | | final ObjectProperty<SpellChecker> spellChecker ) { |
| 1252 | | return new TextEditorSpellChecker( spellChecker ); |
| 1253 | | } |
| 1254 | | |
| 1255 | | /** |
| 1256 | | * Delegates to {@link #autoinsert()}. |
| 1257 | | * |
| 1258 | | * @param keyEvent Ignored. |
| 1259 | | */ |
| 1260 | | private void autoinsert( final KeyEvent keyEvent ) { |
| 1261 | | autoinsert(); |
| 1262 | | } |
| 1263 | | |
| 1264 | | /** |
| 1265 | | * Finds a node that matches the word at the caret, then inserts the |
| 1266 | | * corresponding definition. The definition token delimiters depend on |
| 1267 | | * the type of file being edited. |
| 1268 | | */ |
| 1269 | | public void autoinsert() { |
| 1270 | | mVariableNameInjector.autoinsert( getTextEditor(), getTextDefinition() ); |
| 1271 | | } |
| 1272 | | |
| 1273 | | private TextDefinition createDefinitionEditor() { |
| 1274 | | return createDefinitionEditor( DEFINITION_DEFAULT ); |
| 1275 | | } |
| 1276 | | |
| 1277 | | private TextDefinition createDefinitionEditor( final File file ) { |
| 1278 | | final var editor = new DefinitionEditor( file, createTreeTransformer() ); |
| 1279 | | |
| 1280 | | editor.addTreeChangeHandler( mTreeHandler ); |
| 1281 | | |
| 1282 | | return editor; |
| 1283 | | } |
| 1284 | | |
| 1285 | | private TreeTransformer createTreeTransformer() { |
| 1286 | | return new YamlTreeTransformer(); |
| 1287 | | } |
| 1288 | | |
| 1289 | | private Tooltip createTooltip( final File file ) { |
| 1290 | | final var path = file.toPath(); |
| 1291 | | final var tooltip = new Tooltip( path.toString() ); |
| 1292 | | |
| 1293 | | tooltip.setShowDelay( millis( 200 ) ); |
| 1294 | | |
| 1295 | | return tooltip; |
| 1296 | | } |
| 1297 | | |
| 1298 | | public HtmlPreview getPreview() { |
| 1299 | | return mPreview; |
| 1300 | | } |
| 1301 | | |
| 1302 | | /** |
| 1303 | | * Returns the active text editor. |
| 1304 | | * |
| 1305 | | * @return The text editor that currently has focus. |
| 1306 | | */ |
| 1307 | | public TextEditor getTextEditor() { |
| 1308 | | return mTextEditor.get(); |
| 1309 | | } |
| 1310 | | |
| 1311 | | /** |
| 1312 | | * Returns the active text editor property. |
| 1313 | | * |
| 1314 | | * @return The property container for the active text editor. |
| 1315 | | */ |
| 1316 | | public ReadOnlyObjectProperty<TextEditor> textEditorProperty() { |
| 1317 | | return mTextEditor; |
| 1318 | | } |
| 1319 | | |
| 1320 | | /** |
| 1321 | | * Returns the active text definition editor. |
| 1322 | | * |
| 1323 | | * @return The property container for the active definition editor. |
| 1324 | | */ |
| 1325 | | public TextDefinition getTextDefinition() { |
| 1326 | | return mDefinitionEditor.get(); |
| 1327 | | } |
| 1328 | | |
| 1329 | | /** |
| 1330 | | * Returns the active variable definitions, without any interpolation. |
| 1331 | | * Interpolation is a responsibility of {@link Processor} instances. |
| 1332 | | * |
| 1333 | | * @return The key-value pairs, not interpolated. |
| 1334 | | */ |
| 1335 | | private Map<String, String> getDefinitions() { |
| 1336 | | return getTextDefinition().getDefinitions(); |
| 37 | import javafx.beans.property.*; |
| 38 | import javafx.collections.ListChangeListener; |
| 39 | import javafx.concurrent.Task; |
| 40 | import javafx.event.ActionEvent; |
| 41 | import javafx.event.Event; |
| 42 | import javafx.event.EventHandler; |
| 43 | import javafx.scene.Node; |
| 44 | import javafx.scene.Scene; |
| 45 | import javafx.scene.control.SplitPane; |
| 46 | import javafx.scene.control.Tab; |
| 47 | import javafx.scene.control.TabPane; |
| 48 | import javafx.scene.control.Tooltip; |
| 49 | import javafx.scene.control.TreeItem.TreeModificationEvent; |
| 50 | import javafx.scene.input.KeyEvent; |
| 51 | import javafx.stage.Stage; |
| 52 | import javafx.stage.Window; |
| 53 | import org.greenrobot.eventbus.Subscribe; |
| 54 | |
| 55 | import java.io.File; |
| 56 | import java.io.FileNotFoundException; |
| 57 | import java.nio.file.Path; |
| 58 | import java.util.*; |
| 59 | import java.util.concurrent.ExecutorService; |
| 60 | import java.util.concurrent.ScheduledExecutorService; |
| 61 | import java.util.concurrent.ScheduledFuture; |
| 62 | import java.util.concurrent.atomic.AtomicBoolean; |
| 63 | import java.util.concurrent.atomic.AtomicReference; |
| 64 | import java.util.function.Consumer; |
| 65 | import java.util.function.Function; |
| 66 | import java.util.stream.Collectors; |
| 67 | |
| 68 | import static com.keenwrite.ExportFormat.NONE; |
| 69 | import static com.keenwrite.Launcher.terminate; |
| 70 | import static com.keenwrite.Messages.get; |
| 71 | import static com.keenwrite.constants.Constants.*; |
| 72 | import static com.keenwrite.events.Bus.register; |
| 73 | import static com.keenwrite.events.StatusEvent.clue; |
| 74 | import static com.keenwrite.io.MediaType.*; |
| 75 | import static com.keenwrite.io.MediaType.TypeName.TEXT; |
| 76 | import static com.keenwrite.preferences.AppKeys.*; |
| 77 | import static com.keenwrite.processors.IdentityProcessor.IDENTITY; |
| 78 | import static com.keenwrite.processors.ProcessorContext.Mutator; |
| 79 | import static com.keenwrite.processors.ProcessorContext.builder; |
| 80 | import static com.keenwrite.processors.ProcessorFactory.createProcessors; |
| 81 | import static java.awt.Desktop.getDesktop; |
| 82 | import static java.util.concurrent.Executors.newFixedThreadPool; |
| 83 | import static java.util.concurrent.Executors.newScheduledThreadPool; |
| 84 | import static java.util.concurrent.TimeUnit.SECONDS; |
| 85 | import static java.util.stream.Collectors.groupingBy; |
| 86 | import static javafx.application.Platform.exit; |
| 87 | import static javafx.application.Platform.runLater; |
| 88 | import static javafx.scene.control.ButtonType.NO; |
| 89 | import static javafx.scene.control.ButtonType.YES; |
| 90 | import static javafx.scene.control.TabPane.TabClosingPolicy.ALL_TABS; |
| 91 | import static javafx.scene.input.KeyCode.ENTER; |
| 92 | import static javafx.scene.input.KeyCode.SPACE; |
| 93 | import static javafx.scene.input.KeyCombination.ALT_DOWN; |
| 94 | import static javafx.scene.input.KeyCombination.CONTROL_DOWN; |
| 95 | import static javafx.util.Duration.millis; |
| 96 | import static javax.swing.SwingUtilities.invokeLater; |
| 97 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; |
| 98 | |
| 99 | /** |
| 100 | * Responsible for wiring together the main application components for a |
| 101 | * particular {@link Workspace} (project). These include the definition views, |
| 102 | * text editors, and preview pane along with any corresponding controllers. |
| 103 | */ |
| 104 | public final class MainPane extends SplitPane { |
| 105 | |
| 106 | private static final ExecutorService sExecutor = newFixedThreadPool( 1 ); |
| 107 | private static final Notifier sNotifier = Services.load( Notifier.class ); |
| 108 | |
| 109 | /** |
| 110 | * Used when opening files to determine how each file should be binned and |
| 111 | * therefore what tab pane to be opened within. |
| 112 | */ |
| 113 | private static final Set<MediaType> PLAIN_TEXT_FORMAT = Set.of( |
| 114 | TEXT_MARKDOWN, TEXT_R_MARKDOWN, UNDEFINED |
| 115 | ); |
| 116 | |
| 117 | private final ScheduledExecutorService mSaver = newScheduledThreadPool( 1 ); |
| 118 | private final AtomicReference<ScheduledFuture<?>> mSaveTask = |
| 119 | new AtomicReference<>(); |
| 120 | |
| 121 | /** |
| 122 | * Prevents re-instantiation of processing classes. |
| 123 | */ |
| 124 | private final Map<TextResource, Processor<String>> mProcessors = |
| 125 | new HashMap<>(); |
| 126 | |
| 127 | private final Workspace mWorkspace; |
| 128 | |
| 129 | /** |
| 130 | * Groups similar file type tabs together. |
| 131 | */ |
| 132 | private final List<TabPane> mTabPanes = new ArrayList<>(); |
| 133 | |
| 134 | /** |
| 135 | * Renders the actively selected plain text editor tab. |
| 136 | */ |
| 137 | private final HtmlPreview mPreview; |
| 138 | |
| 139 | /** |
| 140 | * Provides an interactive document outline. |
| 141 | */ |
| 142 | private final DocumentOutline mOutline = new DocumentOutline(); |
| 143 | |
| 144 | /** |
| 145 | * Changing the active editor fires the value changed event. This allows |
| 146 | * refreshes to happen when external definitions are modified and need to |
| 147 | * trigger the processing chain. |
| 148 | */ |
| 149 | private final ObjectProperty<TextEditor> mTextEditor = |
| 150 | createActiveTextEditor(); |
| 151 | |
| 152 | /** |
| 153 | * Changing the active definition editor fires the value changed event. This |
| 154 | * allows refreshes to happen when external definitions are modified and need |
| 155 | * to trigger the processing chain. |
| 156 | */ |
| 157 | private final ObjectProperty<TextDefinition> mDefinitionEditor; |
| 158 | |
| 159 | private final ObjectProperty<SpellChecker> mSpellChecker; |
| 160 | |
| 161 | private final TextEditorSpellChecker mEditorSpeller; |
| 162 | |
| 163 | /** |
| 164 | * Called when the definition data is changed. |
| 165 | */ |
| 166 | private final EventHandler<TreeModificationEvent<Event>> mTreeHandler = |
| 167 | event -> { |
| 168 | process( getTextEditor() ); |
| 169 | save( getTextDefinition() ); |
| 170 | }; |
| 171 | |
| 172 | /** |
| 173 | * Tracks the number of detached tab panels opened into their own windows, |
| 174 | * which allows unique identification of subordinate windows by their title. |
| 175 | * It is doubtful more than 128 windows, much less 256, will be created. |
| 176 | */ |
| 177 | private byte mWindowCount; |
| 178 | |
| 179 | private final VariableNameInjector mVariableNameInjector; |
| 180 | |
| 181 | private final RBootstrapController mRBootstrapController; |
| 182 | |
| 183 | private final DocumentStatistics mStatistics; |
| 184 | |
| 185 | @SuppressWarnings( {"FieldCanBeLocal", "unused"} ) |
| 186 | private final TypesetterInstaller mInstallWizard; |
| 187 | |
| 188 | /** |
| 189 | * Adds all content panels to the main user interface. This will load the |
| 190 | * configuration settings from the workspace to reproduce the settings from |
| 191 | * a previous session. |
| 192 | */ |
| 193 | public MainPane( final Workspace workspace ) { |
| 194 | mWorkspace = workspace; |
| 195 | mSpellChecker = createSpellChecker(); |
| 196 | mEditorSpeller = createTextEditorSpellChecker( mSpellChecker ); |
| 197 | mPreview = new HtmlPreview( workspace ); |
| 198 | mStatistics = new DocumentStatistics( workspace ); |
| 199 | mTextEditor.set( createMarkdownEditor( DOCUMENT_DEFAULT ) ); |
| 200 | mDefinitionEditor = createActiveDefinitionEditor( mTextEditor ); |
| 201 | mVariableNameInjector = new VariableNameInjector( mWorkspace ); |
| 202 | mRBootstrapController = new RBootstrapController( |
| 203 | mWorkspace, this::getDefinitions ); |
| 204 | |
| 205 | open( collect( getRecentFiles() ) ); |
| 206 | viewPreview(); |
| 207 | setDividerPositions( calculateDividerPositions() ); |
| 208 | |
| 209 | // Once the main scene's window regains focus, update the active definition |
| 210 | // editor to the currently selected tab. |
| 211 | runLater( () -> getWindow().setOnCloseRequest( event -> { |
| 212 | // Order matters: Open file names must be persisted before closing all. |
| 213 | mWorkspace.save(); |
| 214 | |
| 215 | if( closeAll() ) { |
| 216 | exit(); |
| 217 | terminate( 0 ); |
| 218 | } |
| 219 | |
| 220 | event.consume(); |
| 221 | } ) ); |
| 222 | |
| 223 | register( this ); |
| 224 | initAutosave( workspace ); |
| 225 | |
| 226 | restoreSession(); |
| 227 | runLater( this::restoreFocus ); |
| 228 | |
| 229 | mInstallWizard = new TypesetterInstaller( workspace ); |
| 230 | } |
| 231 | |
| 232 | /** |
| 233 | * Called when spellchecking can be run. This will reload the dictionary |
| 234 | * into memory once, and then re-use it for all the existing text editors. |
| 235 | * |
| 236 | * @param event The event to process, having a populated word-frequency map. |
| 237 | */ |
| 238 | @Subscribe |
| 239 | public void handle( final LexiconLoadedEvent event ) { |
| 240 | final var lexicon = event.getLexicon(); |
| 241 | |
| 242 | try { |
| 243 | final var checker = SymSpellSpeller.forLexicon( lexicon ); |
| 244 | mSpellChecker.set( checker ); |
| 245 | } catch( final Exception ex ) { |
| 246 | clue( ex ); |
| 247 | } |
| 248 | } |
| 249 | |
| 250 | @Subscribe |
| 251 | public void handle( final TextEditorFocusEvent event ) { |
| 252 | mTextEditor.set( event.get() ); |
| 253 | } |
| 254 | |
| 255 | @Subscribe |
| 256 | public void handle( final TextDefinitionFocusEvent event ) { |
| 257 | mDefinitionEditor.set( event.get() ); |
| 258 | } |
| 259 | |
| 260 | /** |
| 261 | * Typically called when a file name is clicked in the preview panel. |
| 262 | * |
| 263 | * @param event The event to process, must contain a valid file reference. |
| 264 | */ |
| 265 | @Subscribe |
| 266 | public void handle( final FileOpenEvent event ) { |
| 267 | final File eventFile; |
| 268 | final var eventUri = event.getUri(); |
| 269 | |
| 270 | if( eventUri.isAbsolute() ) { |
| 271 | eventFile = new File( eventUri.getPath() ); |
| 272 | } |
| 273 | else { |
| 274 | final var activeFile = getTextEditor().getFile(); |
| 275 | final var parent = activeFile.getParentFile(); |
| 276 | |
| 277 | if( parent == null ) { |
| 278 | clue( new FileNotFoundException( eventUri.getPath() ) ); |
| 279 | return; |
| 280 | } |
| 281 | else { |
| 282 | final var parentPath = parent.getAbsolutePath(); |
| 283 | eventFile = Path.of( parentPath, eventUri.getPath() ).toFile(); |
| 284 | } |
| 285 | } |
| 286 | |
| 287 | final var mediaType = MediaTypeExtension.fromFile( eventFile ); |
| 288 | |
| 289 | runLater( () -> { |
| 290 | // Open text files locally. |
| 291 | if( mediaType.isType( TEXT ) ) { |
| 292 | open( eventFile ); |
| 293 | } |
| 294 | else { |
| 295 | try { |
| 296 | // Delegate opening all other file types to the operating system. |
| 297 | getDesktop().open( eventFile ); |
| 298 | } catch( final Exception ex ) { |
| 299 | clue( ex ); |
| 300 | } |
| 301 | } |
| 302 | } ); |
| 303 | } |
| 304 | |
| 305 | @Subscribe |
| 306 | public void handle( final CaretNavigationEvent event ) { |
| 307 | runLater( () -> { |
| 308 | final var textArea = getTextEditor(); |
| 309 | textArea.moveTo( event.getOffset() ); |
| 310 | textArea.requestFocus(); |
| 311 | } ); |
| 312 | } |
| 313 | |
| 314 | @Subscribe |
| 315 | public void handle( final InsertDefinitionEvent<String> event ) { |
| 316 | final var leaf = event.getLeaf(); |
| 317 | final var editor = mTextEditor.get(); |
| 318 | |
| 319 | mVariableNameInjector.insert( editor, leaf ); |
| 320 | } |
| 321 | |
| 322 | private void initAutosave( final Workspace workspace ) { |
| 323 | final var rate = workspace.integerProperty( KEY_EDITOR_AUTOSAVE ); |
| 324 | |
| 325 | rate.addListener( |
| 326 | ( c, o, n ) -> { |
| 327 | final var taskRef = mSaveTask.get(); |
| 328 | |
| 329 | // Prevent multiple autosaves from running. |
| 330 | if( taskRef != null ) { |
| 331 | taskRef.cancel( false ); |
| 332 | } |
| 333 | |
| 334 | initAutosave( rate ); |
| 335 | } |
| 336 | ); |
| 337 | |
| 338 | // Start the save listener (avoids duplicating some code). |
| 339 | initAutosave( rate ); |
| 340 | } |
| 341 | |
| 342 | private void initAutosave( final IntegerProperty rate ) { |
| 343 | mSaveTask.set( |
| 344 | mSaver.scheduleAtFixedRate( |
| 345 | () -> { |
| 346 | if( getTextEditor().isModified() ) { |
| 347 | // Ensure the modified indicator is cleared by running on EDT. |
| 348 | runLater( this::save ); |
| 349 | } |
| 350 | }, 0, rate.intValue(), SECONDS |
| 351 | ) |
| 352 | ); |
| 353 | } |
| 354 | |
| 355 | /** |
| 356 | * TODO: Load divider positions from exported settings, see |
| 357 | * {@link #collect(SetProperty)} comment. |
| 358 | */ |
| 359 | private double[] calculateDividerPositions() { |
| 360 | final var ratio = 100f / getItems().size() / 100; |
| 361 | final var positions = getDividerPositions(); |
| 362 | |
| 363 | for( int i = 0; i < positions.length; i++ ) { |
| 364 | positions[ i ] = ratio * i; |
| 365 | } |
| 366 | |
| 367 | return positions; |
| 368 | } |
| 369 | |
| 370 | /** |
| 371 | * Opens all the files into the application, provided the paths are unique. |
| 372 | * This may only be called for any type of files that a user can edit |
| 373 | * (i.e., update and persist), such as definitions and text files. |
| 374 | * |
| 375 | * @param files The list of files to open. |
| 376 | */ |
| 377 | public void open( final List<File> files ) { |
| 378 | files.forEach( this::open ); |
| 379 | } |
| 380 | |
| 381 | /** |
| 382 | * This opens the given file. Since the preview pane is not a file that |
| 383 | * can be opened, it is safe to add a listener to the detachable pane. |
| 384 | * This will exit early if the given file is not a regular file (i.e., a |
| 385 | * directory). |
| 386 | * |
| 387 | * @param inputFile The file to open. |
| 388 | */ |
| 389 | private void open( final File inputFile ) { |
| 390 | // Prevent opening directories (a non-existent "untitled.md" is fine). |
| 391 | if( !inputFile.isFile() && inputFile.exists() ) { |
| 392 | return; |
| 393 | } |
| 394 | |
| 395 | final var tab = createTab( inputFile ); |
| 396 | final var node = tab.getContent(); |
| 397 | final var mediaType = MediaType.valueFrom( inputFile ); |
| 398 | final var tabPane = obtainTabPane( mediaType ); |
| 399 | |
| 400 | tab.setTooltip( createTooltip( inputFile ) ); |
| 401 | tabPane.setFocusTraversable( false ); |
| 402 | tabPane.setTabClosingPolicy( ALL_TABS ); |
| 403 | tabPane.getTabs().add( tab ); |
| 404 | |
| 405 | // Attach the tab scene factory for new tab panes. |
| 406 | if( !getItems().contains( tabPane ) ) { |
| 407 | addTabPane( |
| 408 | node instanceof TextDefinition ? 0 : getItems().size(), tabPane |
| 409 | ); |
| 410 | } |
| 411 | |
| 412 | if( inputFile.isFile() ) { |
| 413 | getRecentFiles().add( inputFile.getAbsolutePath() ); |
| 414 | } |
| 415 | } |
| 416 | |
| 417 | /** |
| 418 | * Gives focus to the most recently edited document and attempts to move |
| 419 | * the caret to the most recently known offset into said document. |
| 420 | */ |
| 421 | private void restoreSession() { |
| 422 | final var workspace = getWorkspace(); |
| 423 | final var file = workspace.fileProperty( KEY_UI_RECENT_DOCUMENT ); |
| 424 | final var offset = workspace.integerProperty( KEY_UI_RECENT_OFFSET ); |
| 425 | |
| 426 | for( final var pane : mTabPanes ) { |
| 427 | for( final var tab : pane.getTabs() ) { |
| 428 | final var tooltip = tab.getTooltip(); |
| 429 | |
| 430 | if( tooltip != null ) { |
| 431 | final var tabName = tooltip.getText(); |
| 432 | final var fileName = file.getValue().toString(); |
| 433 | |
| 434 | if( tabName.equalsIgnoreCase( fileName ) ) { |
| 435 | final var node = tab.getContent(); |
| 436 | |
| 437 | pane.getSelectionModel().select( tab ); |
| 438 | node.requestFocus(); |
| 439 | |
| 440 | if( node instanceof TextEditor editor ) { |
| 441 | runLater( () -> editor.moveTo( offset.getValue() ) ); |
| 442 | } |
| 443 | |
| 444 | break; |
| 445 | } |
| 446 | } |
| 447 | } |
| 448 | } |
| 449 | } |
| 450 | |
| 451 | /** |
| 452 | * Sets the focus to the middle pane, which contains the text editor tabs. |
| 453 | */ |
| 454 | private void restoreFocus() { |
| 455 | // Work around a bug where focusing directly on the middle pane results |
| 456 | // in the R engine not loading variables properly. |
| 457 | mTabPanes.get( 0 ).requestFocus(); |
| 458 | |
| 459 | // This is the only line that should be required. |
| 460 | mTabPanes.get( 1 ).requestFocus(); |
| 461 | } |
| 462 | |
| 463 | /** |
| 464 | * Opens a new text editor document using the default document file name. |
| 465 | */ |
| 466 | public void newTextEditor() { |
| 467 | open( DOCUMENT_DEFAULT ); |
| 468 | } |
| 469 | |
| 470 | /** |
| 471 | * Opens a new definition editor document using the default definition |
| 472 | * file name. |
| 473 | */ |
| 474 | @SuppressWarnings( "unused" ) |
| 475 | public void newDefinitionEditor() { |
| 476 | open( DEFINITION_DEFAULT ); |
| 477 | } |
| 478 | |
| 479 | /** |
| 480 | * Iterates over all tab panes to find all {@link TextEditor}s and request |
| 481 | * that they save themselves. |
| 482 | */ |
| 483 | public void saveAll() { |
| 484 | iterateEditors( this::save ); |
| 485 | } |
| 486 | |
| 487 | /** |
| 488 | * Requests that the active {@link TextEditor} saves itself. Don't bother |
| 489 | * checking if modified first because if the user swaps external media from |
| 490 | * an external source (e.g., USB thumb drive), save should not second-guess |
| 491 | * the user: save always re-saves. Also, it's less code. |
| 492 | */ |
| 493 | public void save() { |
| 494 | save( getTextEditor() ); |
| 495 | } |
| 496 | |
| 497 | /** |
| 498 | * Saves the active {@link TextEditor} under a new name. |
| 499 | * |
| 500 | * @param files The new active editor {@link File} reference, must contain |
| 501 | * at least one element. |
| 502 | */ |
| 503 | public void saveAs( final List<File> files ) { |
| 504 | assert files != null; |
| 505 | assert !files.isEmpty(); |
| 506 | final var editor = getTextEditor(); |
| 507 | final var tab = getTab( editor ); |
| 508 | final var file = files.get( 0 ); |
| 509 | |
| 510 | // If the file type has changed, refresh the processors. |
| 511 | final var mediaType = MediaType.valueFrom( file ); |
| 512 | final var typeChanged = !editor.isMediaType( mediaType ); |
| 513 | |
| 514 | if( typeChanged ) { |
| 515 | removeProcessor( editor ); |
| 516 | } |
| 517 | |
| 518 | editor.rename( file ); |
| 519 | tab.ifPresent( t -> { |
| 520 | t.setText( editor.getFilename() ); |
| 521 | t.setTooltip( createTooltip( file ) ); |
| 522 | } ); |
| 523 | |
| 524 | if( typeChanged ) { |
| 525 | updateProcessors( editor ); |
| 526 | process( editor ); |
| 527 | } |
| 528 | |
| 529 | save(); |
| 530 | } |
| 531 | |
| 532 | /** |
| 533 | * Saves the given {@link TextResource} to a file. This is typically used |
| 534 | * to save either an instance of {@link TextEditor} or {@link TextDefinition}. |
| 535 | * |
| 536 | * @param resource The resource to export. |
| 537 | */ |
| 538 | private void save( final TextResource resource ) { |
| 539 | try { |
| 540 | resource.save(); |
| 541 | } catch( final Exception ex ) { |
| 542 | clue( ex ); |
| 543 | sNotifier.alert( |
| 544 | getWindow(), resource.getPath(), "TextResource.saveFailed", ex |
| 545 | ); |
| 546 | } |
| 547 | } |
| 548 | |
| 549 | /** |
| 550 | * Closes all open {@link TextEditor}s; all {@link TextDefinition}s stay open. |
| 551 | * |
| 552 | * @return {@code true} when all editors, modified or otherwise, were |
| 553 | * permitted to close; {@code false} when one or more editors were modified |
| 554 | * and the user requested no closing. |
| 555 | */ |
| 556 | public boolean closeAll() { |
| 557 | var closable = true; |
| 558 | |
| 559 | for( final var tabPane : mTabPanes ) { |
| 560 | final var tabIterator = tabPane.getTabs().iterator(); |
| 561 | |
| 562 | while( tabIterator.hasNext() ) { |
| 563 | final var tab = tabIterator.next(); |
| 564 | final var resource = tab.getContent(); |
| 565 | |
| 566 | // The definition panes auto-save, so being specific here prevents |
| 567 | // closing the definitions in the situation where the user wants to |
| 568 | // continue editing (i.e., possibly save unsaved work). |
| 569 | if( !(resource instanceof TextEditor) ) { |
| 570 | continue; |
| 571 | } |
| 572 | |
| 573 | if( canClose( (TextEditor) resource ) ) { |
| 574 | tabIterator.remove(); |
| 575 | close( tab ); |
| 576 | } |
| 577 | else { |
| 578 | closable = false; |
| 579 | } |
| 580 | } |
| 581 | } |
| 582 | |
| 583 | return closable; |
| 584 | } |
| 585 | |
| 586 | /** |
| 587 | * Calls the tab's {@link Tab#getOnClosed()} handler to carry out a close |
| 588 | * event. |
| 589 | * |
| 590 | * @param tab The {@link Tab} that was closed. |
| 591 | */ |
| 592 | private void close( final Tab tab ) { |
| 593 | assert tab != null; |
| 594 | |
| 595 | final var handler = tab.getOnClosed(); |
| 596 | |
| 597 | if( handler != null ) { |
| 598 | handler.handle( new ActionEvent() ); |
| 599 | } |
| 600 | } |
| 601 | |
| 602 | /** |
| 603 | * Closes the active tab; delegates to {@link #canClose(TextResource)}. |
| 604 | */ |
| 605 | public void close() { |
| 606 | final var editor = getTextEditor(); |
| 607 | |
| 608 | if( canClose( editor ) ) { |
| 609 | close( editor ); |
| 610 | } |
| 611 | } |
| 612 | |
| 613 | /** |
| 614 | * Closes the given {@link TextResource}. This must not be called from within |
| 615 | * a loop that iterates over the tab panes using {@code forEach}, lest a |
| 616 | * concurrent modification exception be thrown. |
| 617 | * |
| 618 | * @param resource The {@link TextResource} to close, without confirming with |
| 619 | * the user. |
| 620 | */ |
| 621 | private void close( final TextResource resource ) { |
| 622 | getTab( resource ).ifPresent( |
| 623 | tab -> { |
| 624 | close( tab ); |
| 625 | tab.getTabPane().getTabs().remove( tab ); |
| 626 | } |
| 627 | ); |
| 628 | } |
| 629 | |
| 630 | /** |
| 631 | * Answers whether the given {@link TextResource} may be closed. |
| 632 | * |
| 633 | * @param editor The {@link TextResource} to try closing. |
| 634 | * @return {@code true} when the editor may be closed; {@code false} when |
| 635 | * the user has requested to keep the editor open. |
| 636 | */ |
| 637 | private boolean canClose( final TextResource editor ) { |
| 638 | final var editorTab = getTab( editor ); |
| 639 | final var canClose = new AtomicBoolean( true ); |
| 640 | |
| 641 | if( editor.isModified() ) { |
| 642 | final var filename = new StringBuilder(); |
| 643 | editorTab.ifPresent( tab -> filename.append( tab.getText() ) ); |
| 644 | |
| 645 | final var message = sNotifier.createNotification( |
| 646 | Messages.get( "Alert.file.close.title" ), |
| 647 | Messages.get( "Alert.file.close.text" ), |
| 648 | filename.toString() |
| 649 | ); |
| 650 | |
| 651 | final var dialog = sNotifier.createConfirmation( getWindow(), message ); |
| 652 | |
| 653 | dialog.showAndWait().ifPresent( |
| 654 | save -> canClose.set( save == YES ? editor.save() : save == NO ) |
| 655 | ); |
| 656 | } |
| 657 | |
| 658 | return canClose.get(); |
| 659 | } |
| 660 | |
| 661 | private void iterateEditors( final Consumer<TextEditor> consumer ) { |
| 662 | mTabPanes.forEach( |
| 663 | tp -> tp.getTabs().forEach( tab -> { |
| 664 | final var node = tab.getContent(); |
| 665 | |
| 666 | if( node instanceof final TextEditor editor ) { |
| 667 | consumer.accept( editor ); |
| 668 | } |
| 669 | } ) |
| 670 | ); |
| 671 | } |
| 672 | |
| 673 | private ObjectProperty<TextEditor> createActiveTextEditor() { |
| 674 | final var editor = new SimpleObjectProperty<TextEditor>(); |
| 675 | |
| 676 | editor.addListener( ( c, o, n ) -> { |
| 677 | if( n != null ) { |
| 678 | mPreview.setBaseUri( n.getPath() ); |
| 679 | process( n ); |
| 680 | } |
| 681 | } ); |
| 682 | |
| 683 | return editor; |
| 684 | } |
| 685 | |
| 686 | /** |
| 687 | * Adds the HTML preview tab to its own, singular tab pane. |
| 688 | */ |
| 689 | public void viewPreview() { |
| 690 | viewTab( mPreview, TEXT_HTML, "Pane.preview.title" ); |
| 691 | } |
| 692 | |
| 693 | /** |
| 694 | * Adds the document outline tab to its own, singular tab pane. |
| 695 | */ |
| 696 | public void viewOutline() { |
| 697 | viewTab( mOutline, APP_DOCUMENT_OUTLINE, "Pane.outline.title" ); |
| 698 | } |
| 699 | |
| 700 | public void viewStatistics() { |
| 701 | viewTab( mStatistics, APP_DOCUMENT_STATISTICS, "Pane.statistics.title" ); |
| 702 | } |
| 703 | |
| 704 | public void viewFiles() { |
| 705 | try { |
| 706 | final var factory = new FilePickerFactory( getWorkspace() ); |
| 707 | final var fileManager = factory.createModeless(); |
| 708 | viewTab( fileManager, APP_FILE_MANAGER, "Pane.files.title" ); |
| 709 | } catch( final Exception ex ) { |
| 710 | clue( ex ); |
| 711 | } |
| 712 | } |
| 713 | |
| 714 | private void viewTab( |
| 715 | final Node node, final MediaType mediaType, final String key ) { |
| 716 | final var tabPane = obtainTabPane( mediaType ); |
| 717 | |
| 718 | for( final var tab : tabPane.getTabs() ) { |
| 719 | if( tab.getContent() == node ) { |
| 720 | return; |
| 721 | } |
| 722 | } |
| 723 | |
| 724 | tabPane.getTabs().add( createTab( get( key ), node ) ); |
| 725 | addTabPane( tabPane ); |
| 726 | } |
| 727 | |
| 728 | public void viewRefresh() { |
| 729 | mPreview.refresh(); |
| 730 | Engine.clear(); |
| 731 | mRBootstrapController.update(); |
| 732 | } |
| 733 | |
| 734 | /** |
| 735 | * Returns the tab that contains the given {@link TextEditor}. |
| 736 | * |
| 737 | * @param editor The {@link TextEditor} instance to find amongst the tabs. |
| 738 | * @return The first tab having content that matches the given tab. |
| 739 | */ |
| 740 | private Optional<Tab> getTab( final TextResource editor ) { |
| 741 | return mTabPanes.stream() |
| 742 | .flatMap( pane -> pane.getTabs().stream() ) |
| 743 | .filter( tab -> editor.equals( tab.getContent() ) ) |
| 744 | .findFirst(); |
| 745 | } |
| 746 | |
| 747 | /** |
| 748 | * Creates a new {@link DefinitionEditor} wrapped in a listener that |
| 749 | * is used to detect when the active {@link DefinitionEditor} has changed. |
| 750 | * Upon changing, the variables are interpolated and the active text editor |
| 751 | * is refreshed. |
| 752 | * |
| 753 | * @param textEditor Text editor to update with the revised resolved map. |
| 754 | * @return A newly configured property that represents the active |
| 755 | * {@link DefinitionEditor}, never null. |
| 756 | */ |
| 757 | private ObjectProperty<TextDefinition> createActiveDefinitionEditor( |
| 758 | final ObjectProperty<TextEditor> textEditor ) { |
| 759 | final var defEditor = new SimpleObjectProperty<>( |
| 760 | createDefinitionEditor() |
| 761 | ); |
| 762 | |
| 763 | defEditor.addListener( ( c, o, n ) -> { |
| 764 | final var editor = textEditor.get(); |
| 765 | |
| 766 | if( editor.isMediaType( TEXT_R_MARKDOWN ) ) { |
| 767 | // Initialize R before the editor is added. |
| 768 | mRBootstrapController.update(); |
| 769 | } |
| 770 | |
| 771 | process( editor ); |
| 772 | } ); |
| 773 | |
| 774 | return defEditor; |
| 775 | } |
| 776 | |
| 777 | private Tab createTab( final String filename, final Node node ) { |
| 778 | return new DetachableTab( filename, node ); |
| 779 | } |
| 780 | |
| 781 | private Tab createTab( final File file ) { |
| 782 | final var r = createTextResource( file ); |
| 783 | final var tab = createTab( r.getFilename(), r.getNode() ); |
| 784 | |
| 785 | r.modifiedProperty().addListener( |
| 786 | ( c, o, n ) -> tab.setText( r.getFilename() + (n ? "*" : "") ) |
| 787 | ); |
| 788 | |
| 789 | // This is called when either the tab is closed by the user clicking on |
| 790 | // the tab's close icon or when closing (all) from the file menu. |
| 791 | tab.setOnClosed( |
| 792 | __ -> getRecentFiles().remove( file.getAbsolutePath() ) |
| 793 | ); |
| 794 | |
| 795 | // When closing a tab, give focus to the newly revealed tab. |
| 796 | tab.selectedProperty().addListener( ( c, o, n ) -> { |
| 797 | if( n != null && n ) { |
| 798 | final var pane = tab.getTabPane(); |
| 799 | |
| 800 | if( pane != null ) { |
| 801 | pane.requestFocus(); |
| 802 | } |
| 803 | } |
| 804 | } ); |
| 805 | |
| 806 | tab.tabPaneProperty().addListener( ( cPane, oPane, nPane ) -> { |
| 807 | if( nPane != null ) { |
| 808 | nPane.focusedProperty().addListener( ( c, o, n ) -> { |
| 809 | if( n != null && n ) { |
| 810 | final var selected = nPane.getSelectionModel().getSelectedItem(); |
| 811 | final var node = selected.getContent(); |
| 812 | node.requestFocus(); |
| 813 | } |
| 814 | } ); |
| 815 | } |
| 816 | } ); |
| 817 | |
| 818 | return tab; |
| 819 | } |
| 820 | |
| 821 | /** |
| 822 | * Creates bins for the different {@link MediaType}s, which eventually are |
| 823 | * added to the UI as separate tab panes. If ever a general-purpose scene |
| 824 | * exporter is developed to serialize a scene to an FXML file, this could |
| 825 | * be replaced by such a class. |
| 826 | * <p> |
| 827 | * When binning the files, this makes sure that at least one file exists |
| 828 | * for every type. If the user has opted to close a particular type (such |
| 829 | * as the definition pane), the view will suppressed elsewhere. |
| 830 | * </p> |
| 831 | * <p> |
| 832 | * The order that the binned files are returned will be reflected in the |
| 833 | * order that the corresponding panes are rendered in the UI. |
| 834 | * </p> |
| 835 | * |
| 836 | * @param paths The file paths to bin according to their type. |
| 837 | * @return An in-order list of files, first by structured definition files, |
| 838 | * then by plain text documents. |
| 839 | */ |
| 840 | private List<File> collect( final SetProperty<String> paths ) { |
| 841 | // Treat all files destined for the text editor as plain text documents |
| 842 | // so that they are added to the same pane. Grouping by TEXT_PLAIN is a |
| 843 | // bit arbitrary, but means explicitly capturing TEXT_PLAIN isn't needed. |
| 844 | final Function<MediaType, MediaType> bin = |
| 845 | m -> PLAIN_TEXT_FORMAT.contains( m ) ? TEXT_PLAIN : m; |
| 846 | |
| 847 | // Create two groups: YAML files and plain text files. The order that |
| 848 | // the elements are listed in the enumeration for media types determines |
| 849 | // what files are loaded first. Variable definitions come before all other |
| 850 | // plain text documents. |
| 851 | final var bins = paths |
| 852 | .stream() |
| 853 | .collect( |
| 854 | groupingBy( |
| 855 | path -> bin.apply( MediaType.fromFilename( path ) ), |
| 856 | () -> new TreeMap<>( Enum::compareTo ), |
| 857 | Collectors.toList() |
| 858 | ) |
| 859 | ); |
| 860 | |
| 861 | bins.putIfAbsent( TEXT_YAML, List.of( DEFINITION_DEFAULT.toString() ) ); |
| 862 | bins.putIfAbsent( TEXT_PLAIN, List.of( DOCUMENT_DEFAULT.toString() ) ); |
| 863 | |
| 864 | final var result = new LinkedList<File>(); |
| 865 | |
| 866 | // Ensure that the same types are listed together (keep insertion order). |
| 867 | bins.forEach( ( mediaType, files ) -> result.addAll( |
| 868 | files.stream().map( File::new ).toList() ) |
| 869 | ); |
| 870 | |
| 871 | return result; |
| 872 | } |
| 873 | |
| 874 | /** |
| 875 | * Force the active editor to update, which will cause the processor |
| 876 | * to re-evaluate the interpolated definition map thereby updating the |
| 877 | * preview pane. |
| 878 | * |
| 879 | * @param editor Contains the source document to update in the preview pane. |
| 880 | */ |
| 881 | private void process( final TextEditor editor ) { |
| 882 | // Ensure processing does not run on the JavaFX thread, which frees the |
| 883 | // text editor immediately for caret movement. The preview will have a |
| 884 | // slight delay when catching up to the caret position. |
| 885 | final var task = new Task<Void>() { |
| 886 | @Override |
| 887 | public Void call() { |
| 888 | try { |
| 889 | final var p = mProcessors.getOrDefault( editor, IDENTITY ); |
| 890 | p.apply( editor == null ? "" : editor.getText() ); |
| 891 | } catch( final Exception ex ) { |
| 892 | clue( ex ); |
| 893 | } |
| 894 | |
| 895 | return null; |
| 896 | } |
| 897 | }; |
| 898 | |
| 899 | // TODO: Each time the editor successfully runs the processor the task is |
| 900 | // considered successful. Due to the rapid-fire nature of processing |
| 901 | // (e.g., keyboard navigation, fast typing), it isn't necessary to |
| 902 | // scroll each time. |
| 903 | // The algorithm: |
| 904 | // 1. Peek at the oldest time. |
| 905 | // 2. If the difference between the oldest time and current time exceeds |
| 906 | // 250 milliseconds, then invoke the scrolling. |
| 907 | // 3. Insert the current time into the circular queue. |
| 908 | task.setOnSucceeded( |
| 909 | e -> invokeLater( () -> mPreview.scrollTo( CARET_ID ) ) |
| 910 | ); |
| 911 | |
| 912 | // Prevents multiple process requests from executing simultaneously (due |
| 913 | // to having a restricted queue size). |
| 914 | sExecutor.execute( task ); |
| 915 | } |
| 916 | |
| 917 | /** |
| 918 | * Lazily creates a {@link TabPane} configured to listen for tab select |
| 919 | * events. The tab pane is associated with a given media type so that |
| 920 | * similar files can be grouped together. |
| 921 | * |
| 922 | * @param mediaType The media type to associate with the tab pane. |
| 923 | * @return An instance of {@link TabPane} that will handle tab docking. |
| 924 | */ |
| 925 | private TabPane obtainTabPane( final MediaType mediaType ) { |
| 926 | for( final var pane : mTabPanes ) { |
| 927 | for( final var tab : pane.getTabs() ) { |
| 928 | final var node = tab.getContent(); |
| 929 | |
| 930 | if( node instanceof TextResource r && r.supports( mediaType ) ) { |
| 931 | return pane; |
| 932 | } |
| 933 | } |
| 934 | } |
| 935 | |
| 936 | final var pane = createTabPane(); |
| 937 | mTabPanes.add( pane ); |
| 938 | return pane; |
| 939 | } |
| 940 | |
| 941 | /** |
| 942 | * Creates an initialized {@link TabPane} instance. |
| 943 | * |
| 944 | * @return A new {@link TabPane} with all listeners configured. |
| 945 | */ |
| 946 | private TabPane createTabPane() { |
| 947 | final var tabPane = new DetachableTabPane(); |
| 948 | |
| 949 | initStageOwnerFactory( tabPane ); |
| 950 | initTabListener( tabPane ); |
| 951 | |
| 952 | return tabPane; |
| 953 | } |
| 954 | |
| 955 | /** |
| 956 | * When any {@link DetachableTabPane} is detached from the main window, |
| 957 | * the stage owner factory must be given its parent window, which will |
| 958 | * own the child window. The parent window is the {@link MainPane}'s |
| 959 | * {@link Scene}'s {@link Window} instance. |
| 960 | * |
| 961 | * <p> |
| 962 | * This will derives the new title from the main window title, incrementing |
| 963 | * the window count to help uniquely identify the child windows. |
| 964 | * </p> |
| 965 | * |
| 966 | * @param tabPane A new {@link DetachableTabPane} to configure. |
| 967 | */ |
| 968 | private void initStageOwnerFactory( final DetachableTabPane tabPane ) { |
| 969 | tabPane.setStageOwnerFactory( stage -> { |
| 970 | final var title = get( |
| 971 | "Detach.tab.title", |
| 972 | ((Stage) getWindow()).getTitle(), ++mWindowCount |
| 973 | ); |
| 974 | stage.setTitle( title ); |
| 975 | |
| 976 | return getScene().getWindow(); |
| 977 | } ); |
| 978 | } |
| 979 | |
| 980 | /** |
| 981 | * Responsible for configuring the content of each {@link DetachableTab} when |
| 982 | * it is added to the given {@link DetachableTabPane} instance. |
| 983 | * <p> |
| 984 | * For {@link TextEditor} contents, an instance of {@link ScrollEventHandler} |
| 985 | * is initialized to perform synchronized scrolling between the editor and |
| 986 | * its preview window. Additionally, the last tab in the tab pane's list of |
| 987 | * tabs is given focus. |
| 988 | * </p> |
| 989 | * <p> |
| 990 | * Note that multiple tabs can be added simultaneously. |
| 991 | * </p> |
| 992 | * |
| 993 | * @param tabPane A new {@link TabPane} to configure. |
| 994 | */ |
| 995 | private void initTabListener( final TabPane tabPane ) { |
| 996 | tabPane.getTabs().addListener( |
| 997 | ( final ListChangeListener.Change<? extends Tab> listener ) -> { |
| 998 | while( listener.next() ) { |
| 999 | if( listener.wasAdded() ) { |
| 1000 | final var tabs = listener.getAddedSubList(); |
| 1001 | |
| 1002 | tabs.forEach( tab -> { |
| 1003 | final var node = tab.getContent(); |
| 1004 | |
| 1005 | if( node instanceof TextEditor ) { |
| 1006 | initScrollEventListener( tab ); |
| 1007 | } |
| 1008 | } ); |
| 1009 | |
| 1010 | // Select and give focus to the last tab opened. |
| 1011 | final var index = tabs.size() - 1; |
| 1012 | if( index >= 0 ) { |
| 1013 | final var tab = tabs.get( index ); |
| 1014 | tabPane.getSelectionModel().select( tab ); |
| 1015 | tab.getContent().requestFocus(); |
| 1016 | } |
| 1017 | } |
| 1018 | } |
| 1019 | } |
| 1020 | ); |
| 1021 | } |
| 1022 | |
| 1023 | /** |
| 1024 | * Synchronizes scrollbar positions between the given {@link Tab} that |
| 1025 | * contains an instance of {@link TextEditor} and {@link HtmlPreview} pane. |
| 1026 | * |
| 1027 | * @param tab The container for an instance of {@link TextEditor}. |
| 1028 | */ |
| 1029 | private void initScrollEventListener( final Tab tab ) { |
| 1030 | final var editor = (TextEditor) tab.getContent(); |
| 1031 | final var scrollPane = editor.getScrollPane(); |
| 1032 | final var scrollBar = mPreview.getVerticalScrollBar(); |
| 1033 | final var handler = new ScrollEventHandler( scrollPane, scrollBar ); |
| 1034 | |
| 1035 | handler.enabledProperty().bind( tab.selectedProperty() ); |
| 1036 | } |
| 1037 | |
| 1038 | private void addTabPane( final int index, final TabPane tabPane ) { |
| 1039 | final var items = getItems(); |
| 1040 | |
| 1041 | if( !items.contains( tabPane ) ) { |
| 1042 | items.add( index, tabPane ); |
| 1043 | } |
| 1044 | } |
| 1045 | |
| 1046 | private void addTabPane( final TabPane tabPane ) { |
| 1047 | addTabPane( getItems().size(), tabPane ); |
| 1048 | } |
| 1049 | |
| 1050 | private GenericBuilder<Mutator, ProcessorContext> processorContextBuilder() { |
| 1051 | final var w = getWorkspace(); |
| 1052 | |
| 1053 | return builder() |
| 1054 | .with( Mutator::setDefinitions, this::getDefinitions ) |
| 1055 | .with( Mutator::setLocale, w::getLocale ) |
| 1056 | .with( Mutator::setMetadata, w::getMetadata ) |
| 1057 | .with( Mutator::setThemesDir, w::getThemesPath ) |
| 1058 | .with( Mutator::setCachesDir, |
| 1059 | () -> w.getFile( KEY_CACHES_DIR ) ) |
| 1060 | .with( Mutator::setImagesDir, |
| 1061 | () -> w.getFile( KEY_IMAGES_DIR ) ) |
| 1062 | .with( Mutator::setImageOrder, |
| 1063 | () -> w.getString( KEY_IMAGES_ORDER ) ) |
| 1064 | .with( Mutator::setImageServer, |
| 1065 | () -> w.getString( KEY_IMAGES_SERVER ) ) |
| 1066 | .with( Mutator::setFontsDir, |
| 1067 | () -> w.getFile( KEY_TYPESET_CONTEXT_FONTS_DIR ) ) |
| 1068 | .with( Mutator::setCaret, |
| 1069 | () -> getTextEditor().getCaret() ) |
| 1070 | .with( Mutator::setSigilBegan, |
| 1071 | () -> w.getString( KEY_DEF_DELIM_BEGAN ) ) |
| 1072 | .with( Mutator::setSigilEnded, |
| 1073 | () -> w.getString( KEY_DEF_DELIM_ENDED ) ) |
| 1074 | .with( Mutator::setRScript, |
| 1075 | () -> w.getString( KEY_R_SCRIPT ) ) |
| 1076 | .with( Mutator::setRWorkingDir, |
| 1077 | () -> w.getFile( KEY_R_DIR ).toPath() ) |
| 1078 | .with( Mutator::setCurlQuotes, |
| 1079 | () -> w.getBoolean( KEY_TYPESET_TYPOGRAPHY_QUOTES ) ) |
| 1080 | .with( Mutator::setAutoRemove, |
| 1081 | () -> w.getBoolean( KEY_TYPESET_CONTEXT_CLEAN ) ); |
| 1082 | } |
| 1083 | |
| 1084 | public ProcessorContext createProcessorContext() { |
| 1085 | return createProcessorContextBuilder( NONE ).build(); |
| 1086 | } |
| 1087 | |
| 1088 | private GenericBuilder<Mutator, ProcessorContext> createProcessorContextBuilder( |
| 1089 | final ExportFormat format ) { |
| 1090 | final var textEditor = getTextEditor(); |
| 1091 | final var sourcePath = textEditor.getPath(); |
| 1092 | |
| 1093 | return processorContextBuilder() |
| 1094 | .with( Mutator::setSourcePath, sourcePath ) |
| 1095 | .with( Mutator::setExportFormat, format ); |
| 1096 | } |
| 1097 | |
| 1098 | /** |
| 1099 | * @param targetPath Used when exporting to a PDF file (binary). |
| 1100 | * @param format Used when processors export to a new text format. |
| 1101 | * @return A new {@link ProcessorContext} to use when creating an instance of |
| 1102 | * {@link Processor}. |
| 1103 | */ |
| 1104 | public ProcessorContext createProcessorContext( |
| 1105 | final Path targetPath, final ExportFormat format ) { |
| 1106 | assert targetPath != null; |
| 1107 | assert format != null; |
| 1108 | |
| 1109 | return createProcessorContextBuilder( format ) |
| 1110 | .with( Mutator::setTargetPath, targetPath ) |
| 1111 | .build(); |
| 1112 | } |
| 1113 | |
| 1114 | /** |
| 1115 | * @param sourcePath Used by {@link ProcessorFactory} to determine |
| 1116 | * {@link Processor} type to create based on file type. |
| 1117 | * @return A new {@link ProcessorContext} to use when creating an instance of |
| 1118 | * {@link Processor}. |
| 1119 | */ |
| 1120 | private ProcessorContext createProcessorContext( final Path sourcePath ) { |
| 1121 | return processorContextBuilder() |
| 1122 | .with( Mutator::setSourcePath, sourcePath ) |
| 1123 | .with( Mutator::setExportFormat, NONE ) |
| 1124 | .build(); |
| 1125 | } |
| 1126 | |
| 1127 | private TextResource createTextResource( final File file ) { |
| 1128 | // TODO: Create PlainTextEditor that's returned by default. |
| 1129 | return MediaType.valueFrom( file ) == TEXT_YAML |
| 1130 | ? createDefinitionEditor( file ) |
| 1131 | : createMarkdownEditor( file ); |
| 1132 | } |
| 1133 | |
| 1134 | /** |
| 1135 | * Creates an instance of {@link MarkdownEditor} that listens for both |
| 1136 | * caret change events and text change events. Text change events must |
| 1137 | * take priority over caret change events because it's possible to change |
| 1138 | * the text without moving the caret (e.g., delete selected text). |
| 1139 | * |
| 1140 | * @param inputFile The file containing contents for the text editor. |
| 1141 | * @return A non-null text editor. |
| 1142 | */ |
| 1143 | private MarkdownEditor createMarkdownEditor( final File inputFile ) { |
| 1144 | final var editor = new MarkdownEditor( inputFile, getWorkspace() ); |
| 1145 | |
| 1146 | updateProcessors( editor ); |
| 1147 | |
| 1148 | // Listener for editor modifications or caret position changes. |
| 1149 | editor.addDirtyListener( ( c, o, n ) -> { |
| 1150 | if( n ) { |
| 1151 | // Reset the status bar after changing the text. |
| 1152 | clue(); |
| 1153 | |
| 1154 | // Processing the text may update the status bar. |
| 1155 | process( getTextEditor() ); |
| 1156 | |
| 1157 | // Update the caret position in the status bar. |
| 1158 | CaretMovedEvent.fire( editor.getCaret() ); |
| 1159 | } |
| 1160 | } ); |
| 1161 | |
| 1162 | editor.addEventListener( |
| 1163 | keyPressed( SPACE, CONTROL_DOWN ), this::autoinsert |
| 1164 | ); |
| 1165 | |
| 1166 | editor.addEventListener( |
| 1167 | keyPressed( ENTER, ALT_DOWN ), event -> mEditorSpeller.autofix( editor ) |
| 1168 | ); |
| 1169 | |
| 1170 | final var textArea = editor.getTextArea(); |
| 1171 | |
| 1172 | // Spell check when the paragraph changes. |
| 1173 | textArea |
| 1174 | .plainTextChanges() |
| 1175 | .filter( p -> !p.isIdentity() ) |
| 1176 | .subscribe( change -> mEditorSpeller.checkParagraph( textArea, change ) ); |
| 1177 | |
| 1178 | // Store the caret position to restore it after restarting the application. |
| 1179 | textArea.caretPositionProperty().addListener( |
| 1180 | ( c, o, n ) -> |
| 1181 | getWorkspace().integerProperty( KEY_UI_RECENT_OFFSET ).setValue( n ) |
| 1182 | ); |
| 1183 | |
| 1184 | // Set the active editor, which refreshes the preview panel. |
| 1185 | mTextEditor.set( editor ); |
| 1186 | |
| 1187 | // Check the entire document after the spellchecker is initialized (with |
| 1188 | // a valid lexicon) so that only the current paragraph need be scanned |
| 1189 | // while editing. (Technically, only the most recently modified word must |
| 1190 | // be scanned.) |
| 1191 | mSpellChecker.addListener( |
| 1192 | ( c, o, n ) -> runLater( |
| 1193 | () -> iterateEditors( mEditorSpeller::checkDocument ) |
| 1194 | ) |
| 1195 | ); |
| 1196 | |
| 1197 | // Check the entire document after it has been loaded. |
| 1198 | mEditorSpeller.checkDocument( mTextEditor.get() ); |
| 1199 | |
| 1200 | return editor; |
| 1201 | } |
| 1202 | |
| 1203 | /** |
| 1204 | * Creates a processor for an editor, provided one doesn't already exist. |
| 1205 | * |
| 1206 | * @param editor The editor that potentially requires an associated processor. |
| 1207 | */ |
| 1208 | private void updateProcessors( final TextEditor editor ) { |
| 1209 | final var path = editor.getFile().toPath(); |
| 1210 | |
| 1211 | mProcessors.computeIfAbsent( |
| 1212 | editor, p -> createProcessors( |
| 1213 | createProcessorContext( path ), |
| 1214 | createHtmlPreviewProcessor() |
| 1215 | ) |
| 1216 | ); |
| 1217 | } |
| 1218 | |
| 1219 | /** |
| 1220 | * Removes a processor for an editor. This is required because a file may |
| 1221 | * change type while editing (e.g., from plain Markdown to R Markdown). |
| 1222 | * In the case that an editor's type changes, its associated processor must |
| 1223 | * be changed accordingly. |
| 1224 | * |
| 1225 | * @param editor The editor that potentially requires an associated processor. |
| 1226 | */ |
| 1227 | private void removeProcessor( final TextEditor editor ) { |
| 1228 | mProcessors.remove( editor ); |
| 1229 | } |
| 1230 | |
| 1231 | /** |
| 1232 | * Creates a {@link Processor} capable of rendering an HTML document onto |
| 1233 | * a GUI widget. |
| 1234 | * |
| 1235 | * @return The {@link Processor} for rendering an HTML document. |
| 1236 | */ |
| 1237 | private Processor<String> createHtmlPreviewProcessor() { |
| 1238 | return new HtmlPreviewProcessor( getPreview() ); |
| 1239 | } |
| 1240 | |
| 1241 | /** |
| 1242 | * Creates a spellchecker that accepts all words as correct. This allows |
| 1243 | * the spellchecker property to be initialized to a known valid value. |
| 1244 | * |
| 1245 | * @return A wrapped {@link PermissiveSpeller}. |
| 1246 | */ |
| 1247 | private ObjectProperty<SpellChecker> createSpellChecker() { |
| 1248 | return new SimpleObjectProperty<>( new PermissiveSpeller() ); |
| 1249 | } |
| 1250 | |
| 1251 | private TextEditorSpellChecker createTextEditorSpellChecker( |
| 1252 | final ObjectProperty<SpellChecker> spellChecker ) { |
| 1253 | return new TextEditorSpellChecker( spellChecker ); |
| 1254 | } |
| 1255 | |
| 1256 | /** |
| 1257 | * Delegates to {@link #autoinsert()}. |
| 1258 | * |
| 1259 | * @param keyEvent Ignored. |
| 1260 | */ |
| 1261 | private void autoinsert( final KeyEvent keyEvent ) { |
| 1262 | autoinsert(); |
| 1263 | } |
| 1264 | |
| 1265 | /** |
| 1266 | * Finds a node that matches the word at the caret, then inserts the |
| 1267 | * corresponding definition. The definition token delimiters depend on |
| 1268 | * the type of file being edited. |
| 1269 | */ |
| 1270 | public void autoinsert() { |
| 1271 | mVariableNameInjector.autoinsert( getTextEditor(), getTextDefinition() ); |
| 1272 | } |
| 1273 | |
| 1274 | private TextDefinition createDefinitionEditor() { |
| 1275 | return createDefinitionEditor( DEFINITION_DEFAULT ); |
| 1276 | } |
| 1277 | |
| 1278 | private TextDefinition createDefinitionEditor( final File file ) { |
| 1279 | final var editor = new DefinitionEditor( file, createTreeTransformer() ); |
| 1280 | |
| 1281 | editor.addTreeChangeHandler( mTreeHandler ); |
| 1282 | |
| 1283 | return editor; |
| 1284 | } |
| 1285 | |
| 1286 | private TreeTransformer createTreeTransformer() { |
| 1287 | return new YamlTreeTransformer(); |
| 1288 | } |
| 1289 | |
| 1290 | private Tooltip createTooltip( final File file ) { |
| 1291 | final var path = file.toPath(); |
| 1292 | final var tooltip = new Tooltip( path.toString() ); |
| 1293 | |
| 1294 | tooltip.setShowDelay( millis( 200 ) ); |
| 1295 | |
| 1296 | return tooltip; |
| 1297 | } |
| 1298 | |
| 1299 | public HtmlPreview getPreview() { |
| 1300 | return mPreview; |
| 1301 | } |
| 1302 | |
| 1303 | /** |
| 1304 | * Returns the active text editor. |
| 1305 | * |
| 1306 | * @return The text editor that currently has focus. |
| 1307 | */ |
| 1308 | public TextEditor getTextEditor() { |
| 1309 | return mTextEditor.get(); |
| 1310 | } |
| 1311 | |
| 1312 | /** |
| 1313 | * Returns the active text editor property. |
| 1314 | * |
| 1315 | * @return The property container for the active text editor. |
| 1316 | */ |
| 1317 | public ReadOnlyObjectProperty<TextEditor> textEditorProperty() { |
| 1318 | return mTextEditor; |
| 1319 | } |
| 1320 | |
| 1321 | /** |
| 1322 | * Returns the active text definition editor. |
| 1323 | * |
| 1324 | * @return The property container for the active definition editor. |
| 1325 | */ |
| 1326 | public TextDefinition getTextDefinition() { |
| 1327 | return mDefinitionEditor == null ? null : mDefinitionEditor.get(); |
| 1328 | } |
| 1329 | |
| 1330 | /** |
| 1331 | * Returns the active variable definitions, without any interpolation. |
| 1332 | * Interpolation is a responsibility of {@link Processor} instances. |
| 1333 | * |
| 1334 | * @return The key-value pairs, not interpolated. |
| 1335 | */ |
| 1336 | private Map<String, String> getDefinitions() { |
| 1337 | final var definitions = getTextDefinition(); |
| 1338 | return definitions == null ? new HashMap<>() : definitions.getDefinitions(); |
| 1337 | 1339 | } |
| 1338 | 1340 | |