| 26 | 26 | import com.panemu.tiwulfx.control.dock.DetachableTab; |
| 27 | 27 | import com.panemu.tiwulfx.control.dock.DetachableTabPane; |
| 28 | | import javafx.beans.property.*; |
| 29 | | import javafx.collections.ListChangeListener; |
| 30 | | import javafx.event.ActionEvent; |
| 31 | | import javafx.event.Event; |
| 32 | | import javafx.event.EventHandler; |
| 33 | | import javafx.scene.Scene; |
| 34 | | import javafx.scene.control.SplitPane; |
| 35 | | import javafx.scene.control.Tab; |
| 36 | | import javafx.scene.control.Tooltip; |
| 37 | | import javafx.scene.control.TreeItem.TreeModificationEvent; |
| 38 | | import javafx.scene.input.KeyEvent; |
| 39 | | import javafx.stage.Stage; |
| 40 | | import javafx.stage.Window; |
| 41 | | |
| 42 | | import java.io.File; |
| 43 | | import java.nio.file.Path; |
| 44 | | import java.util.*; |
| 45 | | import java.util.concurrent.atomic.AtomicBoolean; |
| 46 | | import java.util.function.Function; |
| 47 | | import java.util.stream.Collectors; |
| 48 | | |
| 49 | | import static com.keenwrite.Constants.*; |
| 50 | | import static com.keenwrite.ExportFormat.NONE; |
| 51 | | import static com.keenwrite.Messages.get; |
| 52 | | import static com.keenwrite.StatusNotifier.clue; |
| 53 | | import static com.keenwrite.io.MediaType.*; |
| 54 | | import static com.keenwrite.preferences.Workspace.*; |
| 55 | | import static com.keenwrite.processors.ProcessorFactory.createProcessors; |
| 56 | | import static com.keenwrite.service.events.Notifier.NO; |
| 57 | | import static com.keenwrite.service.events.Notifier.YES; |
| 58 | | import static java.util.stream.Collectors.groupingBy; |
| 59 | | import static javafx.application.Platform.runLater; |
| 60 | | import static javafx.scene.control.TabPane.TabClosingPolicy.ALL_TABS; |
| 61 | | import static javafx.scene.input.KeyCode.SPACE; |
| 62 | | import static javafx.scene.input.KeyCombination.CONTROL_DOWN; |
| 63 | | import static javafx.util.Duration.millis; |
| 64 | | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; |
| 65 | | |
| 66 | | /** |
| 67 | | * Responsible for wiring together the main application components for a |
| 68 | | * particular workspace (project). These include the definition views, |
| 69 | | * text editors, and preview pane along with any corresponding controllers. |
| 70 | | */ |
| 71 | | public final class MainPane extends SplitPane { |
| 72 | | private static final Notifier sNotifier = Services.load( Notifier.class ); |
| 73 | | |
| 74 | | /** |
| 75 | | * Used when opening files to determine how each file should be binned and |
| 76 | | * therefore what tab pane to be opened within. |
| 77 | | */ |
| 78 | | private static final Set<MediaType> PLAIN_TEXT_FORMAT = Set.of( |
| 79 | | TEXT_MARKDOWN, TEXT_R_MARKDOWN, TEXT_R_XML, UNDEFINED |
| 80 | | ); |
| 81 | | |
| 82 | | /** |
| 83 | | * Prevents re-instantiation of processing classes. |
| 84 | | */ |
| 85 | | private final Map<TextResource, Processor<String>> mProcessors = |
| 86 | | new HashMap<>(); |
| 87 | | |
| 88 | | private final Workspace mWorkspace; |
| 89 | | |
| 90 | | /** |
| 91 | | * Groups similar file type tabs together. |
| 92 | | */ |
| 93 | | private final Map<MediaType, DetachableTabPane> mTabPanes = new HashMap<>(); |
| 94 | | |
| 95 | | /** |
| 96 | | * Stores definition names and values. |
| 97 | | */ |
| 98 | | private final Map<String, String> mResolvedMap = |
| 99 | | new HashMap<>( MAP_SIZE_DEFAULT ); |
| 100 | | |
| 101 | | /** |
| 102 | | * Renders the actively selected plain text editor tab. |
| 103 | | */ |
| 104 | | private final HtmlPreview mHtmlPreview; |
| 105 | | |
| 106 | | /** |
| 107 | | * Changing the active editor fires the value changed event. This allows |
| 108 | | * refreshes to happen when external definitions are modified and need to |
| 109 | | * trigger the processing chain. |
| 110 | | */ |
| 111 | | private final ObjectProperty<TextEditor> mActiveTextEditor = |
| 112 | | createActiveTextEditor(); |
| 113 | | |
| 114 | | /** |
| 115 | | * Changing the active definition editor fires the value changed event. This |
| 116 | | * allows refreshes to happen when external definitions are modified and need |
| 117 | | * to trigger the processing chain. |
| 118 | | */ |
| 119 | | private final ObjectProperty<TextDefinition> mActiveDefinitionEditor = |
| 120 | | createActiveDefinitionEditor( mActiveTextEditor ); |
| 121 | | |
| 122 | | /** |
| 123 | | * Responsible for creating a new scene when a tab is detached into |
| 124 | | * its own window frame. |
| 125 | | */ |
| 126 | | private final DefinitionTabSceneFactory mDefinitionTabSceneFactory = |
| 127 | | createDefinitionTabSceneFactory( mActiveDefinitionEditor ); |
| 128 | | |
| 129 | | /** |
| 130 | | * Tracks the number of detached tab panels opened into their own windows, |
| 131 | | * which allows unique identification of subordinate windows by their title. |
| 132 | | * It is doubtful more than 128 windows, much less 256, will be created. |
| 133 | | */ |
| 134 | | private byte mWindowCount; |
| 135 | | |
| 136 | | /** |
| 137 | | * Called when the definition data is changed. |
| 138 | | */ |
| 139 | | private final EventHandler<TreeModificationEvent<Event>> mTreeHandler = |
| 140 | | event -> { |
| 141 | | final var editor = mActiveDefinitionEditor.get(); |
| 142 | | |
| 143 | | resolve( editor ); |
| 144 | | process( getActiveTextEditor() ); |
| 145 | | save( editor ); |
| 146 | | }; |
| 147 | | |
| 148 | | /** |
| 149 | | * Adds all content panels to the main user interface. This will load the |
| 150 | | * configuration settings from the workspace to reproduce the settings from |
| 151 | | * a previous session. |
| 152 | | */ |
| 153 | | public MainPane( final Workspace workspace ) { |
| 154 | | mWorkspace = workspace; |
| 155 | | mHtmlPreview = new HtmlPreview( workspace ); |
| 156 | | |
| 157 | | open( bin( getRecentFiles() ) ); |
| 158 | | viewPreview(); |
| 159 | | setDividerPositions( calculateDividerPositions() ); |
| 160 | | |
| 161 | | // Once the main scene's window regains focus, update the active definition |
| 162 | | // editor to the currently selected tab. |
| 163 | | runLater( |
| 164 | | () -> getWindow().focusedProperty().addListener( ( c, o, n ) -> { |
| 165 | | if( n != null && n ) { |
| 166 | | final var pane = mTabPanes.get( TEXT_YAML ); |
| 167 | | final var model = pane.getSelectionModel(); |
| 168 | | final var tab = model.getSelectedItem(); |
| 169 | | |
| 170 | | if( tab != null ) { |
| 171 | | final var resource = tab.getContent(); |
| 172 | | |
| 173 | | if( resource instanceof TextDefinition ) { |
| 174 | | mActiveDefinitionEditor.set( (TextDefinition) tab.getContent() ); |
| 175 | | } |
| 176 | | } |
| 177 | | } |
| 178 | | } ) |
| 179 | | ); |
| 180 | | } |
| 181 | | |
| 182 | | /** |
| 183 | | * TODO: Load divider positions from exported settings, see bin() comment. |
| 184 | | */ |
| 185 | | private double[] calculateDividerPositions() { |
| 186 | | final var ratio = 100f / getItems().size() / 100; |
| 187 | | final var positions = getDividerPositions(); |
| 188 | | |
| 189 | | for( int i = 0; i < positions.length; i++ ) { |
| 190 | | positions[ i ] = ratio * i; |
| 191 | | } |
| 192 | | |
| 193 | | return positions; |
| 194 | | } |
| 195 | | |
| 196 | | /** |
| 197 | | * Opens all the files into the application, provided the paths are unique. |
| 198 | | * This may only be called for any type of files that a user can edit |
| 199 | | * (i.e., update and persist), such as definitions and text files. |
| 200 | | * |
| 201 | | * @param files The list of files to open. |
| 202 | | */ |
| 203 | | public void open( final List<File> files ) { |
| 204 | | files.forEach( this::open ); |
| 205 | | } |
| 206 | | |
| 207 | | /** |
| 208 | | * This opens the given file. Since the preview pane is not a file that |
| 209 | | * can be opened, it is safe to add a listener to the detachable pane. |
| 210 | | * |
| 211 | | * @param file The file to open. |
| 212 | | */ |
| 213 | | private void open( final File file ) { |
| 214 | | final var tab = createTab( file ); |
| 215 | | final var node = tab.getContent(); |
| 216 | | final var mediaType = MediaType.valueFrom( file ); |
| 217 | | final var tabPane = obtainDetachableTabPane( mediaType ); |
| 218 | | final var newTabPane = !getItems().contains( tabPane ); |
| 219 | | |
| 220 | | tab.setTooltip( createTooltip( file ) ); |
| 221 | | tabPane.setFocusTraversable( false ); |
| 222 | | tabPane.setTabClosingPolicy( ALL_TABS ); |
| 223 | | tabPane.getTabs().add( tab ); |
| 224 | | |
| 225 | | if( newTabPane ) { |
| 226 | | var index = getItems().size(); |
| 227 | | |
| 228 | | if( node instanceof TextDefinition ) { |
| 229 | | tabPane.setSceneFactory( mDefinitionTabSceneFactory::create ); |
| 230 | | index = 0; |
| 231 | | } |
| 232 | | |
| 233 | | addTabPane( index, tabPane ); |
| 234 | | } |
| 235 | | |
| 236 | | getRecentFiles().add( file.getAbsolutePath() ); |
| 237 | | } |
| 238 | | |
| 239 | | /** |
| 240 | | * Opens a new text editor document using the default document file name. |
| 241 | | */ |
| 242 | | public void newTextEditor() { |
| 243 | | open( DOCUMENT_DEFAULT ); |
| 244 | | } |
| 245 | | |
| 246 | | /** |
| 247 | | * Opens a new definition editor document using the default definition |
| 248 | | * file name. |
| 249 | | */ |
| 250 | | public void newDefinitionEditor() { |
| 251 | | open( DEFINITION_DEFAULT ); |
| 252 | | } |
| 253 | | |
| 254 | | /** |
| 255 | | * Iterates over all tab panes to find all {@link TextEditor}s and request |
| 256 | | * that they save themselves. |
| 257 | | */ |
| 258 | | public void saveAll() { |
| 259 | | mTabPanes.forEach( |
| 260 | | ( mt, tp ) -> tp.getTabs().forEach( ( tab ) -> { |
| 261 | | final var node = tab.getContent(); |
| 262 | | if( node instanceof TextEditor ) { |
| 263 | | save( ((TextEditor) node) ); |
| 264 | | } |
| 265 | | } ) |
| 266 | | ); |
| 267 | | } |
| 268 | | |
| 269 | | /** |
| 270 | | * Requests that the active {@link TextEditor} saves itself. Don't bother |
| 271 | | * checking if modified first because if the user swaps external media from |
| 272 | | * an external source (e.g., USB thumb drive), save should not second-guess |
| 273 | | * the user: save always re-saves. Also, it's less code. |
| 274 | | */ |
| 275 | | public void save() { |
| 276 | | save( getActiveTextEditor() ); |
| 277 | | } |
| 278 | | |
| 279 | | /** |
| 280 | | * Saves the active {@link TextEditor} under a new name. |
| 281 | | * |
| 282 | | * @param file The new active editor {@link File} reference. |
| 283 | | */ |
| 284 | | public void saveAs( final File file ) { |
| 285 | | assert file != null; |
| 286 | | final var editor = getActiveTextEditor(); |
| 287 | | final var tab = getTab( editor ); |
| 288 | | |
| 289 | | editor.rename( file ); |
| 290 | | tab.ifPresent( t -> { |
| 291 | | t.setText( editor.getFilename() ); |
| 292 | | t.setTooltip( createTooltip( file ) ); |
| 293 | | } ); |
| 294 | | |
| 295 | | save(); |
| 296 | | } |
| 297 | | |
| 298 | | /** |
| 299 | | * Saves the given {@link TextResource} to a file. This is typically used |
| 300 | | * to save either an instance of {@link TextEditor} or {@link TextDefinition}. |
| 301 | | * |
| 302 | | * @param resource The resource to export. |
| 303 | | */ |
| 304 | | private void save( final TextResource resource ) { |
| 305 | | try { |
| 306 | | resource.save(); |
| 307 | | } catch( final Exception ex ) { |
| 308 | | clue( ex ); |
| 309 | | sNotifier.alert( |
| 310 | | getWindow(), resource.getPath(), "TextResource.saveFailed", ex |
| 311 | | ); |
| 312 | | } |
| 313 | | } |
| 314 | | |
| 315 | | /** |
| 316 | | * Closes all open {@link TextEditor}s; all {@link TextDefinition}s stay open. |
| 317 | | * |
| 318 | | * @return {@code true} when all editors, modified or otherwise, were |
| 319 | | * permitted to close; {@code false} when one or more editors were modified |
| 320 | | * and the user requested no closing. |
| 321 | | */ |
| 322 | | public boolean closeAll() { |
| 323 | | var closable = true; |
| 324 | | |
| 325 | | for( final var entry : mTabPanes.entrySet() ) { |
| 326 | | final var tabPane = entry.getValue(); |
| 327 | | final var tabIterator = tabPane.getTabs().iterator(); |
| 328 | | |
| 329 | | while( tabIterator.hasNext() ) { |
| 330 | | final var tab = tabIterator.next(); |
| 331 | | final var node = tab.getContent(); |
| 332 | | |
| 333 | | if( node instanceof TextEditor && |
| 334 | | (closable &= canClose( (TextEditor) node )) ) { |
| 335 | | tabIterator.remove(); |
| 336 | | close( tab ); |
| 337 | | } |
| 338 | | } |
| 339 | | } |
| 340 | | |
| 341 | | return closable; |
| 342 | | } |
| 343 | | |
| 344 | | /** |
| 345 | | * Calls the tab's {@link Tab#getOnClosed()} handler to carry out a close |
| 346 | | * event. |
| 347 | | * |
| 348 | | * @param tab The {@link Tab} that was closed. |
| 349 | | */ |
| 350 | | private void close( final Tab tab ) { |
| 351 | | final var handler = tab.getOnClosed(); |
| 352 | | |
| 353 | | if( handler != null ) { |
| 354 | | handler.handle( new ActionEvent() ); |
| 355 | | } |
| 356 | | } |
| 357 | | |
| 358 | | /** |
| 359 | | * Closes the active tab; delegates to {@link #canClose(TextEditor)}. |
| 360 | | */ |
| 361 | | public void close() { |
| 362 | | final var editor = getActiveTextEditor(); |
| 363 | | if( canClose( editor ) ) { |
| 364 | | close( editor ); |
| 365 | | } |
| 366 | | } |
| 367 | | |
| 368 | | /** |
| 369 | | * Closes the given {@link TextEditor}. This must not be called from within |
| 370 | | * a loop that iterates over the tab panes using {@code forEach}, lest a |
| 371 | | * concurrent modification exception be thrown. |
| 372 | | * |
| 373 | | * @param editor The {@link TextEditor} to close, without confirming with |
| 374 | | * the user. |
| 375 | | */ |
| 376 | | private void close( final TextEditor editor ) { |
| 377 | | getTab( editor ).ifPresent( |
| 378 | | ( tab ) -> { |
| 379 | | tab.getTabPane().getTabs().remove( tab ); |
| 380 | | close( tab ); |
| 381 | | } |
| 382 | | ); |
| 383 | | } |
| 384 | | |
| 385 | | /** |
| 386 | | * Answers whether the given {@link TextEditor} may be closed. |
| 387 | | * |
| 388 | | * @param editor The {@link TextEditor} to try closing. |
| 389 | | * @return {@code true} when the editor may be closed; {@code false} when |
| 390 | | * the user has requested to keep the editor open. |
| 391 | | */ |
| 392 | | private boolean canClose( final TextEditor editor ) { |
| 393 | | final var editorTab = getTab( editor ); |
| 394 | | final var canClose = new AtomicBoolean( true ); |
| 395 | | |
| 396 | | if( editor.isModified() ) { |
| 397 | | final var filename = new StringBuilder(); |
| 398 | | editorTab.ifPresent( ( tab ) -> filename.append( tab.getText() ) ); |
| 399 | | |
| 400 | | final var message = sNotifier.createNotification( |
| 401 | | Messages.get( "Alert.file.close.title" ), |
| 402 | | Messages.get( "Alert.file.close.text" ), |
| 403 | | filename.toString() |
| 404 | | ); |
| 405 | | |
| 406 | | final var dialog = sNotifier.createConfirmation( getWindow(), message ); |
| 407 | | |
| 408 | | dialog.showAndWait().ifPresent( |
| 409 | | save -> canClose.set( save == YES ? editor.save() : save == NO ) |
| 410 | | ); |
| 411 | | } |
| 412 | | |
| 413 | | return canClose.get(); |
| 414 | | } |
| 415 | | |
| 416 | | private ObjectProperty<TextEditor> createActiveTextEditor() { |
| 417 | | final var editor = new SimpleObjectProperty<TextEditor>(); |
| 418 | | |
| 419 | | editor.addListener( ( c, o, n ) -> { |
| 420 | | if( n != null ) { |
| 421 | | mHtmlPreview.setBaseUri( n.getPath() ); |
| 422 | | process( n ); |
| 423 | | } |
| 424 | | } ); |
| 425 | | |
| 426 | | return editor; |
| 427 | | } |
| 428 | | |
| 429 | | /** |
| 430 | | * Adds the HTML preview tab to its own tab pane. This will only add the |
| 431 | | * preview once. |
| 432 | | */ |
| 433 | | public void viewPreview() { |
| 434 | | final var tabPane = obtainDetachableTabPane( TEXT_HTML ); |
| 435 | | |
| 436 | | // Prevent multiple HTML previews because in the end, there can be only one. |
| 437 | | for( final var tab : tabPane.getTabs() ) { |
| 438 | | if( tab.getContent() == mHtmlPreview ) { |
| 439 | | return; |
| 440 | | } |
| 441 | | } |
| 442 | | |
| 443 | | tabPane.addTab( "HTML", mHtmlPreview ); |
| 444 | | addTabPane( tabPane ); |
| 445 | | } |
| 446 | | |
| 447 | | public void viewRefresh() { |
| 448 | | mHtmlPreview.refresh(); |
| 449 | | } |
| 450 | | |
| 451 | | /** |
| 452 | | * Returns the tab that contains the given {@link TextEditor}. |
| 453 | | * |
| 454 | | * @param editor The {@link TextEditor} instance to find amongst the tabs. |
| 455 | | * @return The first tab having content that matches the given tab. |
| 456 | | */ |
| 457 | | private Optional<Tab> getTab( final TextEditor editor ) { |
| 28 | import javafx.application.Platform; |
| 29 | import javafx.beans.property.*; |
| 30 | import javafx.collections.ListChangeListener; |
| 31 | import javafx.event.ActionEvent; |
| 32 | import javafx.event.Event; |
| 33 | import javafx.event.EventHandler; |
| 34 | import javafx.scene.Scene; |
| 35 | import javafx.scene.control.SplitPane; |
| 36 | import javafx.scene.control.Tab; |
| 37 | import javafx.scene.control.Tooltip; |
| 38 | import javafx.scene.control.TreeItem.TreeModificationEvent; |
| 39 | import javafx.scene.input.KeyEvent; |
| 40 | import javafx.stage.Stage; |
| 41 | import javafx.stage.Window; |
| 42 | |
| 43 | import java.io.File; |
| 44 | import java.nio.file.Path; |
| 45 | import java.util.*; |
| 46 | import java.util.concurrent.atomic.AtomicBoolean; |
| 47 | import java.util.function.Function; |
| 48 | import java.util.stream.Collectors; |
| 49 | |
| 50 | import static com.keenwrite.Constants.*; |
| 51 | import static com.keenwrite.ExportFormat.NONE; |
| 52 | import static com.keenwrite.Messages.get; |
| 53 | import static com.keenwrite.StatusNotifier.clue; |
| 54 | import static com.keenwrite.io.MediaType.*; |
| 55 | import static com.keenwrite.preferences.Workspace.*; |
| 56 | import static com.keenwrite.processors.ProcessorFactory.createProcessors; |
| 57 | import static com.keenwrite.service.events.Notifier.NO; |
| 58 | import static com.keenwrite.service.events.Notifier.YES; |
| 59 | import static java.util.stream.Collectors.groupingBy; |
| 60 | import static javafx.application.Platform.runLater; |
| 61 | import static javafx.scene.control.TabPane.TabClosingPolicy.ALL_TABS; |
| 62 | import static javafx.scene.input.KeyCode.SPACE; |
| 63 | import static javafx.scene.input.KeyCombination.CONTROL_DOWN; |
| 64 | import static javafx.util.Duration.millis; |
| 65 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; |
| 66 | |
| 67 | /** |
| 68 | * Responsible for wiring together the main application components for a |
| 69 | * particular workspace (project). These include the definition views, |
| 70 | * text editors, and preview pane along with any corresponding controllers. |
| 71 | */ |
| 72 | public final class MainPane extends SplitPane { |
| 73 | private static final Notifier sNotifier = Services.load( Notifier.class ); |
| 74 | |
| 75 | /** |
| 76 | * Used when opening files to determine how each file should be binned and |
| 77 | * therefore what tab pane to be opened within. |
| 78 | */ |
| 79 | private static final Set<MediaType> PLAIN_TEXT_FORMAT = Set.of( |
| 80 | TEXT_MARKDOWN, TEXT_R_MARKDOWN, TEXT_R_XML, UNDEFINED |
| 81 | ); |
| 82 | |
| 83 | /** |
| 84 | * Prevents re-instantiation of processing classes. |
| 85 | */ |
| 86 | private final Map<TextResource, Processor<String>> mProcessors = |
| 87 | new HashMap<>(); |
| 88 | |
| 89 | private final Workspace mWorkspace; |
| 90 | |
| 91 | /** |
| 92 | * Groups similar file type tabs together. |
| 93 | */ |
| 94 | private final Map<MediaType, DetachableTabPane> mTabPanes = new HashMap<>(); |
| 95 | |
| 96 | /** |
| 97 | * Stores definition names and values. |
| 98 | */ |
| 99 | private final Map<String, String> mResolvedMap = |
| 100 | new HashMap<>( MAP_SIZE_DEFAULT ); |
| 101 | |
| 102 | /** |
| 103 | * Renders the actively selected plain text editor tab. |
| 104 | */ |
| 105 | private final HtmlPreview mHtmlPreview; |
| 106 | |
| 107 | /** |
| 108 | * Changing the active editor fires the value changed event. This allows |
| 109 | * refreshes to happen when external definitions are modified and need to |
| 110 | * trigger the processing chain. |
| 111 | */ |
| 112 | private final ObjectProperty<TextEditor> mActiveTextEditor = |
| 113 | createActiveTextEditor(); |
| 114 | |
| 115 | /** |
| 116 | * Changing the active definition editor fires the value changed event. This |
| 117 | * allows refreshes to happen when external definitions are modified and need |
| 118 | * to trigger the processing chain. |
| 119 | */ |
| 120 | private final ObjectProperty<TextDefinition> mActiveDefinitionEditor = |
| 121 | createActiveDefinitionEditor( mActiveTextEditor ); |
| 122 | |
| 123 | /** |
| 124 | * Responsible for creating a new scene when a tab is detached into |
| 125 | * its own window frame. |
| 126 | */ |
| 127 | private final DefinitionTabSceneFactory mDefinitionTabSceneFactory = |
| 128 | createDefinitionTabSceneFactory( mActiveDefinitionEditor ); |
| 129 | |
| 130 | /** |
| 131 | * Tracks the number of detached tab panels opened into their own windows, |
| 132 | * which allows unique identification of subordinate windows by their title. |
| 133 | * It is doubtful more than 128 windows, much less 256, will be created. |
| 134 | */ |
| 135 | private byte mWindowCount; |
| 136 | |
| 137 | /** |
| 138 | * Called when the definition data is changed. |
| 139 | */ |
| 140 | private final EventHandler<TreeModificationEvent<Event>> mTreeHandler = |
| 141 | event -> { |
| 142 | final var editor = mActiveDefinitionEditor.get(); |
| 143 | |
| 144 | resolve( editor ); |
| 145 | process( getActiveTextEditor() ); |
| 146 | save( editor ); |
| 147 | }; |
| 148 | |
| 149 | /** |
| 150 | * Adds all content panels to the main user interface. This will load the |
| 151 | * configuration settings from the workspace to reproduce the settings from |
| 152 | * a previous session. |
| 153 | */ |
| 154 | public MainPane( final Workspace workspace ) { |
| 155 | mWorkspace = workspace; |
| 156 | mHtmlPreview = new HtmlPreview( workspace ); |
| 157 | |
| 158 | open( bin( getRecentFiles() ) ); |
| 159 | viewPreview(); |
| 160 | setDividerPositions( calculateDividerPositions() ); |
| 161 | |
| 162 | // Once the main scene's window regains focus, update the active definition |
| 163 | // editor to the currently selected tab. |
| 164 | runLater( |
| 165 | () -> { |
| 166 | getWindow().focusedProperty().addListener( ( c, o, n ) -> { |
| 167 | if( n != null && n ) { |
| 168 | final var pane = mTabPanes.get( TEXT_YAML ); |
| 169 | final var model = pane.getSelectionModel(); |
| 170 | final var tab = model.getSelectedItem(); |
| 171 | |
| 172 | if( tab != null ) { |
| 173 | final var resource = tab.getContent(); |
| 174 | |
| 175 | if( resource instanceof TextDefinition ) { |
| 176 | mActiveDefinitionEditor.set( (TextDefinition) tab.getContent() ); |
| 177 | } |
| 178 | } |
| 179 | } |
| 180 | } ); |
| 181 | |
| 182 | getWindow().setOnCloseRequest( ( event ) -> { |
| 183 | // Order matters here. We want to close all the tabs to ensure each |
| 184 | // is saved, but after they are closed, the workspace should still |
| 185 | // retain the list of files that were open. If this line came after |
| 186 | // closing, then restarting the application would list no files. |
| 187 | mWorkspace.save(); |
| 188 | |
| 189 | if( closeAll() ) { |
| 190 | Platform.exit(); |
| 191 | System.exit( 0 ); |
| 192 | } |
| 193 | else { |
| 194 | event.consume(); |
| 195 | } |
| 196 | } ); |
| 197 | } |
| 198 | ); |
| 199 | } |
| 200 | |
| 201 | /** |
| 202 | * TODO: Load divider positions from exported settings, see bin() comment. |
| 203 | */ |
| 204 | private double[] calculateDividerPositions() { |
| 205 | final var ratio = 100f / getItems().size() / 100; |
| 206 | final var positions = getDividerPositions(); |
| 207 | |
| 208 | for( int i = 0; i < positions.length; i++ ) { |
| 209 | positions[ i ] = ratio * i; |
| 210 | } |
| 211 | |
| 212 | return positions; |
| 213 | } |
| 214 | |
| 215 | /** |
| 216 | * Opens all the files into the application, provided the paths are unique. |
| 217 | * This may only be called for any type of files that a user can edit |
| 218 | * (i.e., update and persist), such as definitions and text files. |
| 219 | * |
| 220 | * @param files The list of files to open. |
| 221 | */ |
| 222 | public void open( final List<File> files ) { |
| 223 | files.forEach( this::open ); |
| 224 | } |
| 225 | |
| 226 | /** |
| 227 | * This opens the given file. Since the preview pane is not a file that |
| 228 | * can be opened, it is safe to add a listener to the detachable pane. |
| 229 | * |
| 230 | * @param file The file to open. |
| 231 | */ |
| 232 | private void open( final File file ) { |
| 233 | final var tab = createTab( file ); |
| 234 | final var node = tab.getContent(); |
| 235 | final var mediaType = MediaType.valueFrom( file ); |
| 236 | final var tabPane = obtainDetachableTabPane( mediaType ); |
| 237 | final var newTabPane = !getItems().contains( tabPane ); |
| 238 | |
| 239 | tab.setTooltip( createTooltip( file ) ); |
| 240 | tabPane.setFocusTraversable( false ); |
| 241 | tabPane.setTabClosingPolicy( ALL_TABS ); |
| 242 | tabPane.getTabs().add( tab ); |
| 243 | |
| 244 | if( newTabPane ) { |
| 245 | var index = getItems().size(); |
| 246 | |
| 247 | if( node instanceof TextDefinition ) { |
| 248 | tabPane.setSceneFactory( mDefinitionTabSceneFactory::create ); |
| 249 | index = 0; |
| 250 | } |
| 251 | |
| 252 | addTabPane( index, tabPane ); |
| 253 | } |
| 254 | |
| 255 | getRecentFiles().add( file.getAbsolutePath() ); |
| 256 | } |
| 257 | |
| 258 | /** |
| 259 | * Opens a new text editor document using the default document file name. |
| 260 | */ |
| 261 | public void newTextEditor() { |
| 262 | open( DOCUMENT_DEFAULT ); |
| 263 | } |
| 264 | |
| 265 | /** |
| 266 | * Opens a new definition editor document using the default definition |
| 267 | * file name. |
| 268 | */ |
| 269 | public void newDefinitionEditor() { |
| 270 | open( DEFINITION_DEFAULT ); |
| 271 | } |
| 272 | |
| 273 | /** |
| 274 | * Iterates over all tab panes to find all {@link TextEditor}s and request |
| 275 | * that they save themselves. |
| 276 | */ |
| 277 | public void saveAll() { |
| 278 | mTabPanes.forEach( |
| 279 | ( mt, tp ) -> tp.getTabs().forEach( ( tab ) -> { |
| 280 | final var node = tab.getContent(); |
| 281 | if( node instanceof TextEditor ) { |
| 282 | save( ((TextEditor) node) ); |
| 283 | } |
| 284 | } ) |
| 285 | ); |
| 286 | } |
| 287 | |
| 288 | /** |
| 289 | * Requests that the active {@link TextEditor} saves itself. Don't bother |
| 290 | * checking if modified first because if the user swaps external media from |
| 291 | * an external source (e.g., USB thumb drive), save should not second-guess |
| 292 | * the user: save always re-saves. Also, it's less code. |
| 293 | */ |
| 294 | public void save() { |
| 295 | save( getActiveTextEditor() ); |
| 296 | } |
| 297 | |
| 298 | /** |
| 299 | * Saves the active {@link TextEditor} under a new name. |
| 300 | * |
| 301 | * @param file The new active editor {@link File} reference. |
| 302 | */ |
| 303 | public void saveAs( final File file ) { |
| 304 | assert file != null; |
| 305 | final var editor = getActiveTextEditor(); |
| 306 | final var tab = getTab( editor ); |
| 307 | |
| 308 | editor.rename( file ); |
| 309 | tab.ifPresent( t -> { |
| 310 | t.setText( editor.getFilename() ); |
| 311 | t.setTooltip( createTooltip( file ) ); |
| 312 | } ); |
| 313 | |
| 314 | save(); |
| 315 | } |
| 316 | |
| 317 | /** |
| 318 | * Saves the given {@link TextResource} to a file. This is typically used |
| 319 | * to save either an instance of {@link TextEditor} or {@link TextDefinition}. |
| 320 | * |
| 321 | * @param resource The resource to export. |
| 322 | */ |
| 323 | private void save( final TextResource resource ) { |
| 324 | try { |
| 325 | resource.save(); |
| 326 | } catch( final Exception ex ) { |
| 327 | clue( ex ); |
| 328 | sNotifier.alert( |
| 329 | getWindow(), resource.getPath(), "TextResource.saveFailed", ex |
| 330 | ); |
| 331 | } |
| 332 | } |
| 333 | |
| 334 | /** |
| 335 | * Closes all open {@link TextEditor}s; all {@link TextDefinition}s stay open. |
| 336 | * |
| 337 | * @return {@code true} when all editors, modified or otherwise, were |
| 338 | * permitted to close; {@code false} when one or more editors were modified |
| 339 | * and the user requested no closing. |
| 340 | */ |
| 341 | public boolean closeAll() { |
| 342 | var closable = true; |
| 343 | |
| 344 | for( final var entry : mTabPanes.entrySet() ) { |
| 345 | final var tabPane = entry.getValue(); |
| 346 | final var tabIterator = tabPane.getTabs().iterator(); |
| 347 | |
| 348 | while( tabIterator.hasNext() ) { |
| 349 | final var tab = tabIterator.next(); |
| 350 | final var resource = tab.getContent(); |
| 351 | |
| 352 | if( !(resource instanceof TextResource) ) { |
| 353 | continue; |
| 354 | } |
| 355 | |
| 356 | if( canClose( (TextResource) resource ) ) { |
| 357 | tabIterator.remove(); |
| 358 | close( tab ); |
| 359 | } |
| 360 | else { |
| 361 | closable = false; |
| 362 | } |
| 363 | } |
| 364 | } |
| 365 | |
| 366 | return closable; |
| 367 | } |
| 368 | |
| 369 | /** |
| 370 | * Calls the tab's {@link Tab#getOnClosed()} handler to carry out a close |
| 371 | * event. |
| 372 | * |
| 373 | * @param tab The {@link Tab} that was closed. |
| 374 | */ |
| 375 | private void close( final Tab tab ) { |
| 376 | final var handler = tab.getOnClosed(); |
| 377 | |
| 378 | if( handler != null ) { |
| 379 | handler.handle( new ActionEvent() ); |
| 380 | } |
| 381 | } |
| 382 | |
| 383 | /** |
| 384 | * Closes the active tab; delegates to {@link #canClose(TextResource)}. |
| 385 | */ |
| 386 | public void close() { |
| 387 | final var editor = getActiveTextEditor(); |
| 388 | if( canClose( editor ) ) { |
| 389 | close( editor ); |
| 390 | } |
| 391 | } |
| 392 | |
| 393 | /** |
| 394 | * Closes the given {@link TextResource}. This must not be called from within |
| 395 | * a loop that iterates over the tab panes using {@code forEach}, lest a |
| 396 | * concurrent modification exception be thrown. |
| 397 | * |
| 398 | * @param resource The {@link TextResource} to close, without confirming with |
| 399 | * the user. |
| 400 | */ |
| 401 | private void close( final TextResource resource ) { |
| 402 | getTab( resource ).ifPresent( |
| 403 | ( tab ) -> { |
| 404 | tab.getTabPane().getTabs().remove( tab ); |
| 405 | close( tab ); |
| 406 | } |
| 407 | ); |
| 408 | } |
| 409 | |
| 410 | /** |
| 411 | * Answers whether the given {@link TextResource} may be closed. |
| 412 | * |
| 413 | * @param editor The {@link TextResource} to try closing. |
| 414 | * @return {@code true} when the editor may be closed; {@code false} when |
| 415 | * the user has requested to keep the editor open. |
| 416 | */ |
| 417 | private boolean canClose( final TextResource editor ) { |
| 418 | final var editorTab = getTab( editor ); |
| 419 | final var canClose = new AtomicBoolean( true ); |
| 420 | |
| 421 | if( editor.isModified() ) { |
| 422 | final var filename = new StringBuilder(); |
| 423 | editorTab.ifPresent( ( tab ) -> filename.append( tab.getText() ) ); |
| 424 | |
| 425 | final var message = sNotifier.createNotification( |
| 426 | Messages.get( "Alert.file.close.title" ), |
| 427 | Messages.get( "Alert.file.close.text" ), |
| 428 | filename.toString() |
| 429 | ); |
| 430 | |
| 431 | final var dialog = sNotifier.createConfirmation( getWindow(), message ); |
| 432 | |
| 433 | dialog.showAndWait().ifPresent( |
| 434 | save -> canClose.set( save == YES ? editor.save() : save == NO ) |
| 435 | ); |
| 436 | } |
| 437 | |
| 438 | return canClose.get(); |
| 439 | } |
| 440 | |
| 441 | private ObjectProperty<TextEditor> createActiveTextEditor() { |
| 442 | final var editor = new SimpleObjectProperty<TextEditor>(); |
| 443 | |
| 444 | editor.addListener( ( c, o, n ) -> { |
| 445 | if( n != null ) { |
| 446 | mHtmlPreview.setBaseUri( n.getPath() ); |
| 447 | process( n ); |
| 448 | } |
| 449 | } ); |
| 450 | |
| 451 | return editor; |
| 452 | } |
| 453 | |
| 454 | /** |
| 455 | * Adds the HTML preview tab to its own tab pane. This will only add the |
| 456 | * preview once. |
| 457 | */ |
| 458 | public void viewPreview() { |
| 459 | final var tabPane = obtainDetachableTabPane( TEXT_HTML ); |
| 460 | |
| 461 | // Prevent multiple HTML previews because in the end, there can be only one. |
| 462 | for( final var tab : tabPane.getTabs() ) { |
| 463 | if( tab.getContent() == mHtmlPreview ) { |
| 464 | return; |
| 465 | } |
| 466 | } |
| 467 | |
| 468 | tabPane.addTab( "HTML", mHtmlPreview ); |
| 469 | addTabPane( tabPane ); |
| 470 | } |
| 471 | |
| 472 | public void viewRefresh() { |
| 473 | mHtmlPreview.refresh(); |
| 474 | } |
| 475 | |
| 476 | /** |
| 477 | * Returns the tab that contains the given {@link TextEditor}. |
| 478 | * |
| 479 | * @param editor The {@link TextEditor} instance to find amongst the tabs. |
| 480 | * @return The first tab having content that matches the given tab. |
| 481 | */ |
| 482 | private Optional<Tab> getTab( final TextResource editor ) { |
| 458 | 483 | return mTabPanes.values() |
| 459 | 484 | .stream() |