| 11 | 11 | .idea |
| 12 | 12 | themes |
| 13 | quotes | |
| 13 | 14 |
| 222 | 222 | "octopus" = "octopuses", |
| 223 | 223 | "ox" = "oxen", |
| 224 | "passerby" = "passersby", | |
| 224 | 225 | "soliloquy" = "soliloquies", |
| 225 | 226 | "trilby" = "trilbys" |
| 60 | 60 | } |
| 61 | 61 | |
| 62 | pro.sub <- function( s ) { | |
| 63 | if( s == 'm' ) { | |
| 64 | s <- 'he' | |
| 65 | } | |
| 66 | else if( s == 'f' ) { | |
| 67 | s <- 'she' | |
| 68 | } | |
| 69 | else { | |
| 70 | s <- 'their' | |
| 71 | } | |
| 72 | ||
| 73 | s | |
| 74 | } | |
| 75 | ||
| 76 | pro.obj <- function( s ) { | |
| 77 | if( s == 'm' ) { | |
| 78 | s <- 'him' | |
| 79 | } | |
| 80 | else if( s == 'f' ) { | |
| 81 | s <- 'her' | |
| 82 | } | |
| 83 | else { | |
| 84 | s <- 'their' | |
| 85 | } | |
| 86 | ||
| 87 | s | |
| 88 | } | |
| 89 | ||
| 90 | pro.ref <- function( s ) { | |
| 91 | if( s == 'm' ) { | |
| 92 | s <- 'himself' | |
| 93 | } | |
| 94 | else if( s == 'f' ) { | |
| 95 | s <- 'herself' | |
| 96 | } | |
| 97 | else { | |
| 98 | s <- 'themselves' | |
| 99 | } | |
| 100 | ||
| 101 | s | |
| 102 | } | |
| 103 | ||
| 104 | pro.pos <- function( s ) { | |
| 105 | if( s == 'm' ) { | |
| 106 | s = 'his' | |
| 107 | } | |
| 108 | else if( s == 'f' ) { | |
| 109 | s <- 'her' | |
| 110 | } | |
| 111 | else { | |
| 112 | s <- 'theirs' | |
| 113 | } | |
| 114 | ||
| 115 | s | |
| 116 | } | |
| 117 | ||
| 118 | pro.noun <- function( s ) { | |
| 119 | if( s == 'm' ) { | |
| 120 | s = 'man' | |
| 121 | } | |
| 122 | else if( s == 'f' ) { | |
| 123 | s <- 'woman' | |
| 124 | } | |
| 125 | ||
| 126 | s | |
| 127 | } | |
| 128 | ||
| 62 | 129 |
| 39 | 39 | "--add-exports=javafx.graphics/com.sun.javafx.geom=ALL-UNNAMED", |
| 40 | 40 | "--add-exports=javafx.graphics/com.sun.javafx.scene.traversal=ALL-UNNAMED", |
| 41 | "--add-exports=javafx.graphics/com.sun.javafx.scene.traversal=ALL-UNNAMED", | |
| 42 | 41 | "--add-exports=javafx.graphics/com.sun.javafx.text=ALL-UNNAMED", |
| 43 | 42 | ] |
| ... | ||
| 52 | 51 | def v_junit = '5.7.2' |
| 53 | 52 | def v_flexmark = '0.62.2' |
| 54 | def v_jackson = '2.12.3' | |
| 53 | def v_jackson = '2.12.5' | |
| 55 | 54 | def v_batik = '1.14' |
| 56 | 55 | def v_wheatsheaf = '2.0.1' |
| ... | ||
| 87 | 86 | |
| 88 | 87 | // HTML parsing and rendering |
| 89 | implementation 'org.jsoup:jsoup:1.13.1' | |
| 90 | implementation 'org.xhtmlrenderer:flying-saucer-core:9.1.20' | |
| 88 | implementation 'org.jsoup:jsoup:1.14.2' | |
| 89 | implementation 'org.xhtmlrenderer:flying-saucer-core:9.1.22' | |
| 91 | 90 | |
| 92 | 91 | // R |
| 1 | org.gradle.jvmargs=-Xmx1G -XX:MaxPermSize=512m | |
| 1 | org.gradle.jvmargs=-Xmx1G | |
| 2 | 2 | org.gradle.daemon=true |
| 3 | 3 | org.gradle.parallel=true |
| 51 | 51 | import java.util.*; |
| 52 | 52 | import java.util.concurrent.ExecutorService; |
| 53 | import java.util.concurrent.atomic.AtomicBoolean; | |
| 54 | import java.util.function.Function; | |
| 55 | import java.util.stream.Collectors; | |
| 56 | ||
| 57 | import static com.keenwrite.ExportFormat.NONE; | |
| 58 | import static com.keenwrite.Messages.get; | |
| 59 | import static com.keenwrite.constants.Constants.*; | |
| 60 | import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG_NODE; | |
| 61 | import static com.keenwrite.events.Bus.register; | |
| 62 | import static com.keenwrite.events.HyperlinkOpenEvent.fireHyperlinkOpenEvent; | |
| 63 | import static com.keenwrite.events.StatusEvent.clue; | |
| 64 | import static com.keenwrite.io.MediaType.*; | |
| 65 | import static com.keenwrite.preferences.WorkspaceKeys.*; | |
| 66 | import static com.keenwrite.processors.IdentityProcessor.IDENTITY; | |
| 67 | import static com.keenwrite.processors.ProcessorFactory.createProcessors; | |
| 68 | import static java.lang.String.format; | |
| 69 | import static java.lang.System.getProperty; | |
| 70 | import static java.util.concurrent.Executors.newFixedThreadPool; | |
| 71 | import static java.util.stream.Collectors.groupingBy; | |
| 72 | import static javafx.application.Platform.runLater; | |
| 73 | import static javafx.scene.control.Alert.AlertType.ERROR; | |
| 74 | import static javafx.scene.control.ButtonType.*; | |
| 75 | import static javafx.scene.control.TabPane.TabClosingPolicy.ALL_TABS; | |
| 76 | import static javafx.scene.input.KeyCode.SPACE; | |
| 77 | import static javafx.scene.input.KeyCombination.CONTROL_DOWN; | |
| 78 | import static javafx.util.Duration.millis; | |
| 79 | import static javax.swing.SwingUtilities.invokeLater; | |
| 80 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 81 | ||
| 82 | /** | |
| 83 | * Responsible for wiring together the main application components for a | |
| 84 | * particular workspace (project). These include the definition views, | |
| 85 | * text editors, and preview pane along with any corresponding controllers. | |
| 86 | */ | |
| 87 | public final class MainPane extends SplitPane { | |
| 88 | private static final ExecutorService sExecutor = newFixedThreadPool( 1 ); | |
| 89 | ||
| 90 | private static final Notifier sNotifier = Services.load( Notifier.class ); | |
| 91 | ||
| 92 | /** | |
| 93 | * Used when opening files to determine how each file should be binned and | |
| 94 | * therefore what tab pane to be opened within. | |
| 95 | */ | |
| 96 | private static final Set<MediaType> PLAIN_TEXT_FORMAT = Set.of( | |
| 97 | TEXT_MARKDOWN, TEXT_R_MARKDOWN, UNDEFINED | |
| 98 | ); | |
| 99 | ||
| 100 | /** | |
| 101 | * Prevents re-instantiation of processing classes. | |
| 102 | */ | |
| 103 | private final Map<TextResource, Processor<String>> mProcessors = | |
| 104 | new HashMap<>(); | |
| 105 | ||
| 106 | private final Workspace mWorkspace; | |
| 107 | ||
| 108 | /** | |
| 109 | * Groups similar file type tabs together. | |
| 110 | */ | |
| 111 | private final List<TabPane> mTabPanes = new ArrayList<>(); | |
| 112 | ||
| 113 | /** | |
| 114 | * Stores definition names and values. | |
| 115 | */ | |
| 116 | private final Map<String, String> mResolvedMap = | |
| 117 | new HashMap<>( MAP_SIZE_DEFAULT ); | |
| 118 | ||
| 119 | /** | |
| 120 | * Renders the actively selected plain text editor tab. | |
| 121 | */ | |
| 122 | private final HtmlPreview mPreview; | |
| 123 | ||
| 124 | /** | |
| 125 | * Provides an interactive document outline. | |
| 126 | */ | |
| 127 | private final DocumentOutline mOutline = new DocumentOutline(); | |
| 128 | ||
| 129 | /** | |
| 130 | * Changing the active editor fires the value changed event. This allows | |
| 131 | * refreshes to happen when external definitions are modified and need to | |
| 132 | * trigger the processing chain. | |
| 133 | */ | |
| 134 | private final ObjectProperty<TextEditor> mActiveTextEditor = | |
| 135 | createActiveTextEditor(); | |
| 136 | ||
| 137 | /** | |
| 138 | * Changing the active definition editor fires the value changed event. This | |
| 139 | * allows refreshes to happen when external definitions are modified and need | |
| 140 | * to trigger the processing chain. | |
| 141 | */ | |
| 142 | private final ObjectProperty<TextDefinition> mActiveDefinitionEditor = | |
| 143 | createActiveDefinitionEditor( mActiveTextEditor ); | |
| 144 | ||
| 145 | /** | |
| 146 | * Tracks the number of detached tab panels opened into their own windows, | |
| 147 | * which allows unique identification of subordinate windows by their title. | |
| 148 | * It is doubtful more than 128 windows, much less 256, will be created. | |
| 149 | */ | |
| 150 | private byte mWindowCount; | |
| 151 | ||
| 152 | /** | |
| 153 | * Called when the definition data is changed. | |
| 154 | */ | |
| 155 | private final EventHandler<TreeModificationEvent<Event>> mTreeHandler = | |
| 156 | event -> { | |
| 157 | final var editor = mActiveDefinitionEditor.get(); | |
| 158 | ||
| 159 | resolve( editor ); | |
| 160 | process( getActiveTextEditor() ); | |
| 161 | save( editor ); | |
| 162 | }; | |
| 163 | ||
| 164 | private final DocumentStatistics mStatistics; | |
| 165 | ||
| 166 | /** | |
| 167 | * Adds all content panels to the main user interface. This will load the | |
| 168 | * configuration settings from the workspace to reproduce the settings from | |
| 169 | * a previous session. | |
| 170 | */ | |
| 171 | public MainPane( final Workspace workspace ) { | |
| 172 | mWorkspace = workspace; | |
| 173 | mPreview = new HtmlPreview( workspace ); | |
| 174 | mStatistics = new DocumentStatistics( workspace ); | |
| 175 | mActiveTextEditor.set( new MarkdownEditor( workspace ) ); | |
| 176 | ||
| 177 | open( bin( getRecentFiles() ) ); | |
| 178 | viewPreview(); | |
| 179 | setDividerPositions( calculateDividerPositions() ); | |
| 180 | ||
| 181 | // Once the main scene's window regains focus, update the active definition | |
| 182 | // editor to the currently selected tab. | |
| 183 | runLater( () -> getWindow().setOnCloseRequest( ( event ) -> { | |
| 184 | // Order matters here. We want to close all the tabs to ensure each | |
| 185 | // is saved, but after they are closed, the workspace should still | |
| 186 | // retain the list of files that were open. If this line came after | |
| 187 | // closing, then restarting the application would list no files. | |
| 188 | mWorkspace.save(); | |
| 189 | ||
| 190 | if( closeAll() ) { | |
| 191 | Platform.exit(); | |
| 192 | System.exit( 0 ); | |
| 193 | } | |
| 194 | else { | |
| 195 | event.consume(); | |
| 196 | } | |
| 197 | } ) ); | |
| 198 | ||
| 199 | register( this ); | |
| 200 | } | |
| 201 | ||
| 202 | @Subscribe | |
| 203 | public void handle( final TextEditorFocusEvent event ) { | |
| 204 | mActiveTextEditor.set( event.get() ); | |
| 205 | } | |
| 206 | ||
| 207 | @Subscribe | |
| 208 | public void handle( final TextDefinitionFocusEvent event ) { | |
| 209 | mActiveDefinitionEditor.set( event.get() ); | |
| 210 | } | |
| 211 | ||
| 212 | /** | |
| 213 | * Typically called when a file name is clicked in the {@link HtmlPanel}. | |
| 214 | * | |
| 215 | * @param event The event to process, must contain a valid file reference. | |
| 216 | */ | |
| 217 | @Subscribe | |
| 218 | public void handle( final FileOpenEvent event ) { | |
| 219 | final File eventFile; | |
| 220 | final var eventUri = event.getUri(); | |
| 221 | ||
| 222 | if( eventUri.isAbsolute() ) { | |
| 223 | eventFile = new File( eventUri.getPath() ); | |
| 224 | } | |
| 225 | else { | |
| 226 | final var activeFile = getActiveTextEditor().getFile(); | |
| 227 | final var parent = activeFile.getParentFile(); | |
| 228 | ||
| 229 | if( parent == null ) { | |
| 230 | clue( new FileNotFoundException( eventUri.getPath() ) ); | |
| 231 | return; | |
| 232 | } | |
| 233 | else { | |
| 234 | final var parentPath = parent.getAbsolutePath(); | |
| 235 | eventFile = Path.of( parentPath, eventUri.getPath() ).toFile(); | |
| 236 | } | |
| 237 | } | |
| 238 | ||
| 239 | runLater( () -> open( eventFile ) ); | |
| 240 | } | |
| 241 | ||
| 242 | @Subscribe | |
| 243 | public void handle( final CaretNavigationEvent event ) { | |
| 244 | runLater( () -> { | |
| 245 | final var textArea = getActiveTextEditor().getTextArea(); | |
| 246 | textArea.moveTo( event.getOffset() ); | |
| 247 | textArea.requestFollowCaret(); | |
| 248 | textArea.requestFocus(); | |
| 249 | } ); | |
| 250 | } | |
| 251 | ||
| 252 | @Subscribe | |
| 253 | @SuppressWarnings( "unused" ) | |
| 254 | public void handle( final ExportFailedEvent event ) { | |
| 255 | final var os = getProperty( "os.name" ); | |
| 256 | final var arch = getProperty( "os.arch" ).toLowerCase(); | |
| 257 | final var bits = getProperty( "sun.arch.data.model" ); | |
| 258 | ||
| 259 | final var title = Messages.get( "Alert.typesetter.missing.title" ); | |
| 260 | final var header = Messages.get( "Alert.typesetter.missing.header" ); | |
| 261 | final var version = Messages.get( | |
| 262 | "Alert.typesetter.missing.version", | |
| 263 | os, | |
| 264 | arch | |
| 265 | .replaceAll( "amd.*|i.*|x86.*", "X86" ) | |
| 266 | .replaceAll( "mips.*", "MIPS" ) | |
| 267 | .replaceAll( "armv.*", "ARM" ), | |
| 268 | bits ); | |
| 269 | final var text = Messages.get( "Alert.typesetter.missing.installer.text" ); | |
| 270 | ||
| 271 | // Download and install ConTeXt for {0} {1} {2}-bit | |
| 272 | final var content = format( "%s %s", text, version ); | |
| 273 | final var flowPane = new FlowPane(); | |
| 274 | final var link = new Hyperlink( text ); | |
| 275 | final var label = new Label( version ); | |
| 276 | flowPane.getChildren().addAll( link, label ); | |
| 277 | ||
| 278 | final var alert = new Alert( ERROR, content, OK ); | |
| 279 | alert.setTitle( title ); | |
| 280 | alert.setHeaderText( header ); | |
| 281 | alert.getDialogPane().contentProperty().set( flowPane ); | |
| 282 | alert.setGraphic( ICON_DIALOG_NODE ); | |
| 283 | ||
| 284 | link.setOnAction( ( e ) -> { | |
| 285 | alert.close(); | |
| 286 | final var url = Messages.get( "Alert.typesetter.missing.installer.url" ); | |
| 287 | runLater( () -> fireHyperlinkOpenEvent( url ) ); | |
| 288 | } ); | |
| 289 | ||
| 290 | alert.showAndWait(); | |
| 291 | } | |
| 292 | ||
| 293 | /** | |
| 294 | * TODO: Load divider positions from exported settings, see bin() comment. | |
| 295 | */ | |
| 296 | private double[] calculateDividerPositions() { | |
| 297 | final var ratio = 100f / getItems().size() / 100; | |
| 298 | final var positions = getDividerPositions(); | |
| 299 | ||
| 300 | for( int i = 0; i < positions.length; i++ ) { | |
| 301 | positions[ i ] = ratio * i; | |
| 302 | } | |
| 303 | ||
| 304 | return positions; | |
| 305 | } | |
| 306 | ||
| 307 | /** | |
| 308 | * Opens all the files into the application, provided the paths are unique. | |
| 309 | * This may only be called for any type of files that a user can edit | |
| 310 | * (i.e., update and persist), such as definitions and text files. | |
| 311 | * | |
| 312 | * @param files The list of files to open. | |
| 313 | */ | |
| 314 | public void open( final List<File> files ) { | |
| 315 | files.forEach( this::open ); | |
| 316 | } | |
| 317 | ||
| 318 | /** | |
| 319 | * This opens the given file. Since the preview pane is not a file that | |
| 320 | * can be opened, it is safe to add a listener to the detachable pane. | |
| 321 | * | |
| 322 | * @param file The file to open. | |
| 323 | */ | |
| 324 | private void open( final File file ) { | |
| 325 | final var tab = createTab( file ); | |
| 326 | final var node = tab.getContent(); | |
| 327 | final var mediaType = MediaType.valueFrom( file ); | |
| 328 | final var tabPane = obtainTabPane( mediaType ); | |
| 329 | ||
| 330 | tab.setTooltip( createTooltip( file ) ); | |
| 331 | tabPane.setFocusTraversable( false ); | |
| 332 | tabPane.setTabClosingPolicy( ALL_TABS ); | |
| 333 | tabPane.getTabs().add( tab ); | |
| 334 | ||
| 335 | // Attach the tab scene factory for new tab panes. | |
| 336 | if( !getItems().contains( tabPane ) ) { | |
| 337 | addTabPane( | |
| 338 | node instanceof TextDefinition ? 0 : getItems().size(), tabPane | |
| 339 | ); | |
| 340 | } | |
| 341 | ||
| 342 | getRecentFiles().add( file.getAbsolutePath() ); | |
| 343 | } | |
| 344 | ||
| 345 | /** | |
| 346 | * Opens a new text editor document using the default document file name. | |
| 347 | */ | |
| 348 | public void newTextEditor() { | |
| 349 | open( DOCUMENT_DEFAULT ); | |
| 350 | } | |
| 351 | ||
| 352 | /** | |
| 353 | * Opens a new definition editor document using the default definition | |
| 354 | * file name. | |
| 355 | */ | |
| 356 | public void newDefinitionEditor() { | |
| 357 | open( DEFINITION_DEFAULT ); | |
| 358 | } | |
| 359 | ||
| 360 | /** | |
| 361 | * Iterates over all tab panes to find all {@link TextEditor}s and request | |
| 362 | * that they save themselves. | |
| 363 | */ | |
| 364 | public void saveAll() { | |
| 365 | mTabPanes.forEach( | |
| 366 | ( tp ) -> tp.getTabs().forEach( ( tab ) -> { | |
| 367 | final var node = tab.getContent(); | |
| 368 | if( node instanceof final TextEditor editor ) { | |
| 369 | save( editor ); | |
| 370 | } | |
| 371 | } ) | |
| 372 | ); | |
| 373 | } | |
| 374 | ||
| 375 | /** | |
| 376 | * Requests that the active {@link TextEditor} saves itself. Don't bother | |
| 377 | * checking if modified first because if the user swaps external media from | |
| 378 | * an external source (e.g., USB thumb drive), save should not second-guess | |
| 379 | * the user: save always re-saves. Also, it's less code. | |
| 380 | */ | |
| 381 | public void save() { | |
| 382 | save( getActiveTextEditor() ); | |
| 383 | } | |
| 384 | ||
| 385 | /** | |
| 386 | * Saves the active {@link TextEditor} under a new name. | |
| 387 | * | |
| 388 | * @param files The new active editor {@link File} reference, must contain | |
| 389 | * at least one element. | |
| 390 | */ | |
| 391 | public void saveAs( final List<File> files ) { | |
| 392 | assert files != null; | |
| 393 | assert !files.isEmpty(); | |
| 394 | final var editor = getActiveTextEditor(); | |
| 395 | final var tab = getTab( editor ); | |
| 396 | final var file = files.get( 0 ); | |
| 397 | ||
| 398 | editor.rename( file ); | |
| 399 | tab.ifPresent( t -> { | |
| 400 | t.setText( editor.getFilename() ); | |
| 401 | t.setTooltip( createTooltip( file ) ); | |
| 402 | } ); | |
| 403 | ||
| 404 | save(); | |
| 405 | } | |
| 406 | ||
| 407 | /** | |
| 408 | * Saves the given {@link TextResource} to a file. This is typically used | |
| 409 | * to save either an instance of {@link TextEditor} or {@link TextDefinition}. | |
| 410 | * | |
| 411 | * @param resource The resource to export. | |
| 412 | */ | |
| 413 | private void save( final TextResource resource ) { | |
| 414 | try { | |
| 415 | resource.save(); | |
| 416 | } catch( final Exception ex ) { | |
| 417 | clue( ex ); | |
| 418 | sNotifier.alert( | |
| 419 | getWindow(), resource.getPath(), "TextResource.saveFailed", ex | |
| 420 | ); | |
| 421 | } | |
| 422 | } | |
| 423 | ||
| 424 | /** | |
| 425 | * Closes all open {@link TextEditor}s; all {@link TextDefinition}s stay open. | |
| 426 | * | |
| 427 | * @return {@code true} when all editors, modified or otherwise, were | |
| 428 | * permitted to close; {@code false} when one or more editors were modified | |
| 429 | * and the user requested no closing. | |
| 430 | */ | |
| 431 | public boolean closeAll() { | |
| 432 | var closable = true; | |
| 433 | ||
| 434 | for( final var tabPane : mTabPanes ) { | |
| 435 | final var tabIterator = tabPane.getTabs().iterator(); | |
| 436 | ||
| 437 | while( tabIterator.hasNext() ) { | |
| 438 | final var tab = tabIterator.next(); | |
| 439 | final var resource = tab.getContent(); | |
| 440 | ||
| 441 | // The definition panes auto-save, so being specific here prevents | |
| 442 | // closing the definitions in the situation where the user wants to | |
| 443 | // continue editing (i.e., possibly save unsaved work). | |
| 444 | if( !(resource instanceof TextEditor) ) { | |
| 445 | continue; | |
| 446 | } | |
| 447 | ||
| 448 | if( canClose( (TextEditor) resource ) ) { | |
| 449 | tabIterator.remove(); | |
| 450 | close( tab ); | |
| 451 | } | |
| 452 | else { | |
| 453 | closable = false; | |
| 454 | } | |
| 455 | } | |
| 456 | } | |
| 457 | ||
| 458 | return closable; | |
| 459 | } | |
| 460 | ||
| 461 | /** | |
| 462 | * Calls the tab's {@link Tab#getOnClosed()} handler to carry out a close | |
| 463 | * event. | |
| 464 | * | |
| 465 | * @param tab The {@link Tab} that was closed. | |
| 466 | */ | |
| 467 | private void close( final Tab tab ) { | |
| 468 | final var handler = tab.getOnClosed(); | |
| 469 | ||
| 470 | if( handler != null ) { | |
| 471 | handler.handle( new ActionEvent() ); | |
| 472 | } | |
| 473 | } | |
| 474 | ||
| 475 | /** | |
| 476 | * Closes the active tab; delegates to {@link #canClose(TextResource)}. | |
| 477 | */ | |
| 478 | public void close() { | |
| 479 | final var editor = getActiveTextEditor(); | |
| 480 | ||
| 481 | if( canClose( editor ) ) { | |
| 482 | close( editor ); | |
| 483 | } | |
| 484 | } | |
| 485 | ||
| 486 | /** | |
| 487 | * Closes the given {@link TextResource}. This must not be called from within | |
| 488 | * a loop that iterates over the tab panes using {@code forEach}, lest a | |
| 489 | * concurrent modification exception be thrown. | |
| 490 | * | |
| 491 | * @param resource The {@link TextResource} to close, without confirming with | |
| 492 | * the user. | |
| 493 | */ | |
| 494 | private void close( final TextResource resource ) { | |
| 495 | getTab( resource ).ifPresent( | |
| 496 | ( tab ) -> { | |
| 497 | tab.getTabPane().getTabs().remove( tab ); | |
| 498 | close( tab ); | |
| 499 | } | |
| 500 | ); | |
| 501 | } | |
| 502 | ||
| 503 | /** | |
| 504 | * Answers whether the given {@link TextResource} may be closed. | |
| 505 | * | |
| 506 | * @param editor The {@link TextResource} to try closing. | |
| 507 | * @return {@code true} when the editor may be closed; {@code false} when | |
| 508 | * the user has requested to keep the editor open. | |
| 509 | */ | |
| 510 | private boolean canClose( final TextResource editor ) { | |
| 511 | final var editorTab = getTab( editor ); | |
| 512 | final var canClose = new AtomicBoolean( true ); | |
| 513 | ||
| 514 | if( editor.isModified() ) { | |
| 515 | final var filename = new StringBuilder(); | |
| 516 | editorTab.ifPresent( ( tab ) -> filename.append( tab.getText() ) ); | |
| 517 | ||
| 518 | final var message = sNotifier.createNotification( | |
| 519 | Messages.get( "Alert.file.close.title" ), | |
| 520 | Messages.get( "Alert.file.close.text" ), | |
| 521 | filename.toString() | |
| 522 | ); | |
| 523 | ||
| 524 | final var dialog = sNotifier.createConfirmation( getWindow(), message ); | |
| 525 | ||
| 526 | dialog.showAndWait().ifPresent( | |
| 527 | save -> canClose.set( save == YES ? editor.save() : save == NO ) | |
| 528 | ); | |
| 529 | } | |
| 530 | ||
| 531 | return canClose.get(); | |
| 532 | } | |
| 533 | ||
| 534 | private ObjectProperty<TextEditor> createActiveTextEditor() { | |
| 535 | final var editor = new SimpleObjectProperty<TextEditor>(); | |
| 536 | ||
| 537 | editor.addListener( ( c, o, n ) -> { | |
| 538 | if( n != null ) { | |
| 539 | mPreview.setBaseUri( n.getPath() ); | |
| 540 | process( n ); | |
| 541 | } | |
| 542 | } ); | |
| 543 | ||
| 544 | return editor; | |
| 545 | } | |
| 546 | ||
| 547 | /** | |
| 548 | * Adds the HTML preview tab to its own, singular tab pane. | |
| 549 | */ | |
| 550 | public void viewPreview() { | |
| 551 | viewTab( mPreview, TEXT_HTML, "Pane.preview.title" ); | |
| 552 | } | |
| 553 | ||
| 554 | /** | |
| 555 | * Adds the document outline tab to its own, singular tab pane. | |
| 556 | */ | |
| 557 | public void viewOutline() { | |
| 558 | viewTab( mOutline, APP_DOCUMENT_OUTLINE, "Pane.outline.title" ); | |
| 559 | } | |
| 560 | ||
| 561 | public void viewStatistics() { | |
| 562 | viewTab( mStatistics, APP_DOCUMENT_STATISTICS, "Pane.statistics.title" ); | |
| 563 | } | |
| 564 | ||
| 565 | public void viewFiles() { | |
| 566 | try { | |
| 567 | final var factory = new FilePickerFactory( mWorkspace ); | |
| 568 | final var fileManager = factory.createModeless(); | |
| 569 | viewTab( fileManager, APP_FILE_MANAGER, "Pane.files.title" ); | |
| 570 | } catch( final Exception ex ) { | |
| 571 | clue( ex ); | |
| 572 | } | |
| 573 | } | |
| 574 | ||
| 575 | private void viewTab( | |
| 576 | final Node node, final MediaType mediaType, final String key ) { | |
| 577 | final var tabPane = obtainTabPane( mediaType ); | |
| 578 | ||
| 579 | for( final var tab : tabPane.getTabs() ) { | |
| 580 | if( tab.getContent() == node ) { | |
| 581 | return; | |
| 582 | } | |
| 583 | } | |
| 584 | ||
| 585 | tabPane.getTabs().add( createTab( get( key ), node ) ); | |
| 586 | addTabPane( tabPane ); | |
| 587 | } | |
| 588 | ||
| 589 | public void viewRefresh() { | |
| 590 | mPreview.refresh(); | |
| 591 | } | |
| 592 | ||
| 593 | /** | |
| 594 | * Returns the tab that contains the given {@link TextEditor}. | |
| 595 | * | |
| 596 | * @param editor The {@link TextEditor} instance to find amongst the tabs. | |
| 597 | * @return The first tab having content that matches the given tab. | |
| 598 | */ | |
| 599 | private Optional<Tab> getTab( final TextResource editor ) { | |
| 600 | return mTabPanes.stream() | |
| 601 | .flatMap( pane -> pane.getTabs().stream() ) | |
| 602 | .filter( tab -> editor.equals( tab.getContent() ) ) | |
| 603 | .findFirst(); | |
| 604 | } | |
| 605 | ||
| 606 | /** | |
| 607 | * Creates a new {@link DefinitionEditor} wrapped in a listener that | |
| 608 | * is used to detect when the active {@link DefinitionEditor} has changed. | |
| 609 | * Upon changing, the {@link #mResolvedMap} is updated and the active | |
| 610 | * text editor is refreshed. | |
| 611 | * | |
| 612 | * @param editor Text editor to update with the revised resolved map. | |
| 613 | * @return A newly configured property that represents the active | |
| 614 | * {@link DefinitionEditor}, never null. | |
| 615 | */ | |
| 616 | private ObjectProperty<TextDefinition> createActiveDefinitionEditor( | |
| 617 | final ObjectProperty<TextEditor> editor ) { | |
| 618 | final var definitions = new SimpleObjectProperty<TextDefinition>(); | |
| 619 | definitions.addListener( ( c, o, n ) -> { | |
| 620 | resolve( n == null ? createDefinitionEditor() : n ); | |
| 621 | process( editor.get() ); | |
| 622 | } ); | |
| 623 | ||
| 624 | return definitions; | |
| 625 | } | |
| 626 | ||
| 627 | private Tab createTab( final String filename, final Node node ) { | |
| 628 | return new DetachableTab( filename, node ); | |
| 629 | } | |
| 630 | ||
| 631 | private Tab createTab( final File file ) { | |
| 632 | final var r = createTextResource( file ); | |
| 633 | final var tab = createTab( r.getFilename(), r.getNode() ); | |
| 634 | ||
| 635 | r.modifiedProperty().addListener( | |
| 636 | ( c, o, n ) -> tab.setText( r.getFilename() + (n ? "*" : "") ) | |
| 637 | ); | |
| 638 | ||
| 639 | // This is called when either the tab is closed by the user clicking on | |
| 640 | // the tab's close icon or when closing (all) from the file menu. | |
| 641 | tab.setOnClosed( | |
| 642 | ( __ ) -> getRecentFiles().remove( file.getAbsolutePath() ) | |
| 643 | ); | |
| 644 | ||
| 645 | tab.tabPaneProperty().addListener( ( cPane, oPane, nPane ) -> { | |
| 646 | if( nPane != null ) { | |
| 647 | nPane.focusedProperty().addListener( ( c, o, n ) -> { | |
| 648 | if( n != null && n ) { | |
| 649 | final var selected = nPane.getSelectionModel().getSelectedItem(); | |
| 650 | final var node = selected.getContent(); | |
| 651 | node.requestFocus(); | |
| 652 | } | |
| 653 | } ); | |
| 654 | } | |
| 655 | } ); | |
| 656 | ||
| 657 | return tab; | |
| 658 | } | |
| 659 | ||
| 660 | /** | |
| 661 | * Creates bins for the different {@link MediaType}s, which eventually are | |
| 662 | * added to the UI as separate tab panes. If ever a general-purpose scene | |
| 663 | * exporter is developed to serialize a scene to an FXML file, this could | |
| 664 | * be replaced by such a class. | |
| 665 | * <p> | |
| 666 | * When binning the files, this makes sure that at least one file exists | |
| 667 | * for every type. If the user has opted to close a particular type (such | |
| 668 | * as the definition pane), the view will suppressed elsewhere. | |
| 669 | * </p> | |
| 670 | * <p> | |
| 671 | * The order that the binned files are returned will be reflected in the | |
| 672 | * order that the corresponding panes are rendered in the UI. | |
| 673 | * </p> | |
| 674 | * | |
| 675 | * @param paths The file paths to bin according to their type. | |
| 676 | * @return An in-order list of files, first by structured definition files, | |
| 677 | * then by plain text documents. | |
| 678 | */ | |
| 679 | private List<File> bin( final SetProperty<String> paths ) { | |
| 680 | // Treat all files destined for the text editor as plain text documents | |
| 681 | // so that they are added to the same pane. Grouping by TEXT_PLAIN is a | |
| 682 | // bit arbitrary, but means explicitly capturing TEXT_PLAIN isn't needed. | |
| 683 | final Function<MediaType, MediaType> bin = | |
| 684 | m -> PLAIN_TEXT_FORMAT.contains( m ) ? TEXT_PLAIN : m; | |
| 685 | ||
| 686 | // Create two groups: YAML files and plain text files. | |
| 687 | final var bins = paths | |
| 688 | .stream() | |
| 689 | .collect( | |
| 690 | groupingBy( path -> bin.apply( MediaType.fromFilename( path ) ) ) | |
| 691 | ); | |
| 692 | ||
| 693 | bins.putIfAbsent( TEXT_YAML, List.of( DEFINITION_DEFAULT.toString() ) ); | |
| 694 | bins.putIfAbsent( TEXT_PLAIN, List.of( DOCUMENT_DEFAULT.toString() ) ); | |
| 695 | ||
| 696 | final var result = new ArrayList<File>( paths.size() ); | |
| 697 | ||
| 698 | // Ensure that the same types are listed together (keep insertion order). | |
| 699 | bins.forEach( ( mediaType, files ) -> result.addAll( | |
| 700 | files.stream().map( File::new ).collect( Collectors.toList() ) ) | |
| 701 | ); | |
| 702 | ||
| 703 | return result; | |
| 704 | } | |
| 705 | ||
| 706 | /** | |
| 707 | * Uses the given {@link TextDefinition} instance to update the | |
| 708 | * {@link #mResolvedMap}. | |
| 709 | * | |
| 710 | * @param editor A non-null, possibly empty definition editor. | |
| 711 | */ | |
| 712 | private void resolve( final TextDefinition editor ) { | |
| 713 | assert editor != null; | |
| 714 | ||
| 715 | final var tokens = createDefinitionTokens(); | |
| 716 | final var operator = new YamlSigilOperator( tokens ); | |
| 717 | final var map = new HashMap<String, String>(); | |
| 718 | ||
| 719 | editor.toMap().forEach( ( k, v ) -> map.put( operator.entoken( k ), v ) ); | |
| 720 | ||
| 721 | mResolvedMap.clear(); | |
| 722 | mResolvedMap.putAll( editor.interpolate( map, tokens ) ); | |
| 723 | } | |
| 724 | ||
| 725 | /** | |
| 726 | * Force the active editor to update, which will cause the processor | |
| 727 | * to re-evaluate the interpolated definition map thereby updating the | |
| 728 | * preview pane. | |
| 729 | * | |
| 730 | * @param editor Contains the source document to update in the preview pane. | |
| 731 | */ | |
| 732 | private void process( final TextEditor editor ) { | |
| 733 | // Ensure processing does not run on the JavaFX thread, which frees the | |
| 734 | // text editor immediately for caret movement. The preview will have a | |
| 735 | // slight delay when catching up to the caret position. | |
| 736 | final var task = new Task<Void>() { | |
| 737 | @Override | |
| 738 | public Void call() { | |
| 739 | try { | |
| 740 | final var p = mProcessors.getOrDefault( editor, IDENTITY ); | |
| 741 | p.apply( editor == null ? "" : editor.getText() ); | |
| 742 | } catch( final Exception ex ) { | |
| 743 | clue( ex ); | |
| 744 | } | |
| 745 | ||
| 746 | return null; | |
| 747 | } | |
| 748 | }; | |
| 749 | ||
| 750 | task.setOnSucceeded( | |
| 751 | e -> invokeLater( () -> mPreview.scrollTo( CARET_ID ) ) | |
| 752 | ); | |
| 753 | ||
| 754 | // Prevents multiple process requests from executing simultaneously (due | |
| 755 | // to having a restricted queue size). | |
| 756 | sExecutor.execute( task ); | |
| 757 | } | |
| 758 | ||
| 759 | /** | |
| 760 | * Lazily creates a {@link TabPane} configured to listen for tab select | |
| 761 | * events. The tab pane is associated with a given media type so that | |
| 762 | * similar files can be grouped together. | |
| 763 | * | |
| 764 | * @param mediaType The media type to associate with the tab pane. | |
| 765 | * @return An instance of {@link TabPane} that will handle tab docking. | |
| 766 | */ | |
| 767 | private TabPane obtainTabPane( final MediaType mediaType ) { | |
| 768 | for( final var pane : mTabPanes ) { | |
| 769 | for( final var tab : pane.getTabs() ) { | |
| 770 | final var node = tab.getContent(); | |
| 771 | ||
| 772 | if( node instanceof TextResource r && r.supports( mediaType ) ) { | |
| 773 | return pane; | |
| 774 | } | |
| 775 | } | |
| 776 | } | |
| 777 | ||
| 778 | final var pane = createTabPane(); | |
| 779 | mTabPanes.add( pane ); | |
| 780 | return pane; | |
| 781 | } | |
| 782 | ||
| 783 | /** | |
| 784 | * Creates an initialized {@link TabPane} instance. | |
| 785 | * | |
| 786 | * @return A new {@link TabPane} with all listeners configured. | |
| 787 | */ | |
| 788 | private TabPane createTabPane() { | |
| 789 | final var tabPane = new DetachableTabPane(); | |
| 790 | ||
| 791 | initStageOwnerFactory( tabPane ); | |
| 792 | initTabListener( tabPane ); | |
| 793 | ||
| 794 | return tabPane; | |
| 795 | } | |
| 796 | ||
| 797 | /** | |
| 798 | * When any {@link DetachableTabPane} is detached from the main window, | |
| 799 | * the stage owner factory must be given its parent window, which will | |
| 800 | * own the child window. The parent window is the {@link MainPane}'s | |
| 801 | * {@link Scene}'s {@link Window} instance. | |
| 802 | * | |
| 803 | * <p> | |
| 804 | * This will derives the new title from the main window title, incrementing | |
| 805 | * the window count to help uniquely identify the child windows. | |
| 806 | * </p> | |
| 807 | * | |
| 808 | * @param tabPane A new {@link DetachableTabPane} to configure. | |
| 809 | */ | |
| 810 | private void initStageOwnerFactory( final DetachableTabPane tabPane ) { | |
| 811 | tabPane.setStageOwnerFactory( ( stage ) -> { | |
| 812 | final var title = get( | |
| 813 | "Detach.tab.title", | |
| 814 | ((Stage) getWindow()).getTitle(), ++mWindowCount | |
| 815 | ); | |
| 816 | stage.setTitle( title ); | |
| 817 | ||
| 818 | return getScene().getWindow(); | |
| 819 | } ); | |
| 820 | } | |
| 821 | ||
| 822 | /** | |
| 823 | * Responsible for configuring the content of each {@link DetachableTab} when | |
| 824 | * it is added to the given {@link DetachableTabPane} instance. | |
| 825 | * <p> | |
| 826 | * For {@link TextEditor} contents, an instance of {@link ScrollEventHandler} | |
| 827 | * is initialized to perform synchronized scrolling between the editor and | |
| 828 | * its preview window. Additionally, the last tab in the tab pane's list of | |
| 829 | * tabs is given focus. | |
| 830 | * </p> | |
| 831 | * <p> | |
| 832 | * Note that multiple tabs can be added simultaneously. | |
| 833 | * </p> | |
| 834 | * | |
| 835 | * @param tabPane A new {@link TabPane} to configure. | |
| 836 | */ | |
| 837 | private void initTabListener( final TabPane tabPane ) { | |
| 838 | tabPane.getTabs().addListener( | |
| 839 | ( final ListChangeListener.Change<? extends Tab> listener ) -> { | |
| 840 | while( listener.next() ) { | |
| 841 | if( listener.wasAdded() ) { | |
| 842 | final var tabs = listener.getAddedSubList(); | |
| 843 | ||
| 844 | tabs.forEach( ( tab ) -> { | |
| 845 | final var node = tab.getContent(); | |
| 846 | ||
| 847 | if( node instanceof TextEditor ) { | |
| 848 | initScrollEventListener( tab ); | |
| 849 | } | |
| 850 | } ); | |
| 851 | ||
| 852 | // Select and give focus to the last tab opened. | |
| 853 | final var index = tabs.size() - 1; | |
| 854 | if( index >= 0 ) { | |
| 855 | final var tab = tabs.get( index ); | |
| 856 | tabPane.getSelectionModel().select( tab ); | |
| 857 | tab.getContent().requestFocus(); | |
| 858 | } | |
| 859 | } | |
| 860 | } | |
| 861 | } | |
| 862 | ); | |
| 863 | } | |
| 864 | ||
| 865 | /** | |
| 866 | * Synchronizes scrollbar positions between the given {@link Tab} that | |
| 867 | * contains an instance of {@link TextEditor} and {@link HtmlPreview} pane. | |
| 868 | * | |
| 869 | * @param tab The container for an instance of {@link TextEditor}. | |
| 870 | */ | |
| 871 | private void initScrollEventListener( final Tab tab ) { | |
| 872 | final var editor = (TextEditor) tab.getContent(); | |
| 873 | final var scrollPane = editor.getScrollPane(); | |
| 874 | final var scrollBar = mPreview.getVerticalScrollBar(); | |
| 875 | final var handler = new ScrollEventHandler( scrollPane, scrollBar ); | |
| 876 | handler.enabledProperty().bind( tab.selectedProperty() ); | |
| 877 | } | |
| 878 | ||
| 879 | private void addTabPane( final int index, final TabPane tabPane ) { | |
| 880 | final var items = getItems(); | |
| 881 | if( !items.contains( tabPane ) ) { | |
| 882 | items.add( index, tabPane ); | |
| 883 | } | |
| 884 | } | |
| 885 | ||
| 886 | private void addTabPane( final TabPane tabPane ) { | |
| 887 | addTabPane( getItems().size(), tabPane ); | |
| 888 | } | |
| 889 | ||
| 890 | public ProcessorContext createProcessorContext() { | |
| 891 | return createProcessorContext( null, NONE ); | |
| 892 | } | |
| 893 | ||
| 894 | public ProcessorContext createProcessorContext( | |
| 895 | final Path exportPath, final ExportFormat format ) { | |
| 896 | final var editor = getActiveTextEditor(); | |
| 897 | return createProcessorContext( | |
| 898 | editor.getPath(), exportPath, format, editor.getCaret() ); | |
| 899 | } | |
| 900 | ||
| 901 | private ProcessorContext createProcessorContext( | |
| 902 | final Path path, final Caret caret ) { | |
| 903 | return createProcessorContext( path, null, ExportFormat.NONE, caret ); | |
| 904 | } | |
| 905 | ||
| 906 | /** | |
| 907 | * @param path Used by {@link ProcessorFactory} to determine | |
| 908 | * {@link Processor} type to create based on file type. | |
| 909 | * @param exportPath Used when exporting to a PDF file (binary). | |
| 910 | * @param format Used when processors export to a new text format. | |
| 911 | * @param caret Used by {@link CaretExtension} to add ID attribute into | |
| 912 | * preview document for scrollbar synchronization. | |
| 913 | * @return A new {@link ProcessorContext} to use when creating an instance of | |
| 914 | * {@link Processor}. | |
| 915 | */ | |
| 916 | private ProcessorContext createProcessorContext( | |
| 917 | final Path path, final Path exportPath, final ExportFormat format, | |
| 918 | final Caret caret ) { | |
| 919 | return new ProcessorContext( | |
| 920 | mPreview, mResolvedMap, path, exportPath, format, mWorkspace, caret | |
| 921 | ); | |
| 922 | } | |
| 923 | ||
| 924 | private TextResource createTextResource( final File file ) { | |
| 925 | // TODO: Create PlainTextEditor that's returned by default. | |
| 926 | return MediaType.valueFrom( file ) == TEXT_YAML | |
| 927 | ? createDefinitionEditor( file ) | |
| 928 | : createMarkdownEditor( file ); | |
| 929 | } | |
| 930 | ||
| 931 | /** | |
| 932 | * Creates an instance of {@link MarkdownEditor} that listens for both | |
| 933 | * caret change events and text change events. Text change events must | |
| 934 | * take priority over caret change events because it's possible to change | |
| 935 | * the text without moving the caret (e.g., delete selected text). | |
| 936 | * | |
| 937 | * @param file The file containing contents for the text editor. | |
| 938 | * @return A non-null text editor. | |
| 939 | */ | |
| 940 | private TextResource createMarkdownEditor( final File file ) { | |
| 941 | final var path = file.toPath(); | |
| 942 | final var editor = new MarkdownEditor( file, getWorkspace() ); | |
| 943 | final var caret = editor.getCaret(); | |
| 944 | final var context = createProcessorContext( path, caret ); | |
| 945 | ||
| 946 | mProcessors.computeIfAbsent( editor, p -> createProcessors( context ) ); | |
| 947 | ||
| 948 | editor.addDirtyListener( ( c, o, n ) -> { | |
| 949 | if( n ) { | |
| 950 | // Reset the status to OK after changing the text. | |
| 951 | clue(); | |
| 952 | ||
| 953 | // Processing the text may update the status bar. | |
| 954 | process( getActiveTextEditor() ); | |
| 955 | } | |
| 956 | } ); | |
| 957 | ||
| 958 | editor.addEventListener( | |
| 959 | keyPressed( SPACE, CONTROL_DOWN ), this::autoinsert | |
| 960 | ); | |
| 961 | ||
| 962 | // Set the active editor, which refreshes the preview panel. | |
| 963 | mActiveTextEditor.set( editor ); | |
| 964 | ||
| 965 | return editor; | |
| 966 | } | |
| 967 | ||
| 968 | /** | |
| 969 | * Delegates to {@link #autoinsert()}. | |
| 970 | * | |
| 971 | * @param event Ignored. | |
| 972 | */ | |
| 53 | import java.util.concurrent.ScheduledExecutorService; | |
| 54 | import java.util.concurrent.ScheduledFuture; | |
| 55 | import java.util.concurrent.atomic.AtomicBoolean; | |
| 56 | import java.util.concurrent.atomic.AtomicReference; | |
| 57 | import java.util.function.Function; | |
| 58 | import java.util.stream.Collectors; | |
| 59 | ||
| 60 | import static com.keenwrite.ExportFormat.NONE; | |
| 61 | import static com.keenwrite.Messages.get; | |
| 62 | import static com.keenwrite.constants.Constants.*; | |
| 63 | import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG_NODE; | |
| 64 | import static com.keenwrite.events.Bus.register; | |
| 65 | import static com.keenwrite.events.HyperlinkOpenEvent.fireHyperlinkOpenEvent; | |
| 66 | import static com.keenwrite.events.StatusEvent.clue; | |
| 67 | import static com.keenwrite.io.MediaType.*; | |
| 68 | import static com.keenwrite.preferences.WorkspaceKeys.*; | |
| 69 | import static com.keenwrite.processors.IdentityProcessor.IDENTITY; | |
| 70 | import static com.keenwrite.processors.ProcessorFactory.createProcessors; | |
| 71 | import static java.lang.String.format; | |
| 72 | import static java.lang.System.getProperty; | |
| 73 | import static java.util.concurrent.Executors.newFixedThreadPool; | |
| 74 | import static java.util.concurrent.Executors.newScheduledThreadPool; | |
| 75 | import static java.util.concurrent.TimeUnit.SECONDS; | |
| 76 | import static java.util.stream.Collectors.groupingBy; | |
| 77 | import static javafx.application.Platform.runLater; | |
| 78 | import static javafx.scene.control.Alert.AlertType.ERROR; | |
| 79 | import static javafx.scene.control.ButtonType.*; | |
| 80 | import static javafx.scene.control.TabPane.TabClosingPolicy.ALL_TABS; | |
| 81 | import static javafx.scene.input.KeyCode.SPACE; | |
| 82 | import static javafx.scene.input.KeyCombination.CONTROL_DOWN; | |
| 83 | import static javafx.util.Duration.millis; | |
| 84 | import static javax.swing.SwingUtilities.invokeLater; | |
| 85 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 86 | ||
| 87 | /** | |
| 88 | * Responsible for wiring together the main application components for a | |
| 89 | * particular workspace (project). These include the definition views, | |
| 90 | * text editors, and preview pane along with any corresponding controllers. | |
| 91 | */ | |
| 92 | public final class MainPane extends SplitPane { | |
| 93 | private static final ExecutorService sExecutor = newFixedThreadPool( 1 ); | |
| 94 | ||
| 95 | private final ScheduledExecutorService mSaver = newScheduledThreadPool( 1 ); | |
| 96 | private final AtomicReference<ScheduledFuture<?>> mSaveTask = | |
| 97 | new AtomicReference<>(); | |
| 98 | ||
| 99 | private static final Notifier sNotifier = Services.load( Notifier.class ); | |
| 100 | ||
| 101 | /** | |
| 102 | * Used when opening files to determine how each file should be binned and | |
| 103 | * therefore what tab pane to be opened within. | |
| 104 | */ | |
| 105 | private static final Set<MediaType> PLAIN_TEXT_FORMAT = Set.of( | |
| 106 | TEXT_MARKDOWN, TEXT_R_MARKDOWN, UNDEFINED | |
| 107 | ); | |
| 108 | ||
| 109 | /** | |
| 110 | * Prevents re-instantiation of processing classes. | |
| 111 | */ | |
| 112 | private final Map<TextResource, Processor<String>> mProcessors = | |
| 113 | new HashMap<>(); | |
| 114 | ||
| 115 | private final Workspace mWorkspace; | |
| 116 | ||
| 117 | /** | |
| 118 | * Groups similar file type tabs together. | |
| 119 | */ | |
| 120 | private final List<TabPane> mTabPanes = new ArrayList<>(); | |
| 121 | ||
| 122 | /** | |
| 123 | * Stores definition names and values. | |
| 124 | */ | |
| 125 | private final Map<String, String> mResolvedMap = | |
| 126 | new HashMap<>( MAP_SIZE_DEFAULT ); | |
| 127 | ||
| 128 | /** | |
| 129 | * Renders the actively selected plain text editor tab. | |
| 130 | */ | |
| 131 | private final HtmlPreview mPreview; | |
| 132 | ||
| 133 | /** | |
| 134 | * Provides an interactive document outline. | |
| 135 | */ | |
| 136 | private final DocumentOutline mOutline = new DocumentOutline(); | |
| 137 | ||
| 138 | /** | |
| 139 | * Changing the active editor fires the value changed event. This allows | |
| 140 | * refreshes to happen when external definitions are modified and need to | |
| 141 | * trigger the processing chain. | |
| 142 | */ | |
| 143 | private final ObjectProperty<TextEditor> mActiveTextEditor = | |
| 144 | createActiveTextEditor(); | |
| 145 | ||
| 146 | /** | |
| 147 | * Changing the active definition editor fires the value changed event. This | |
| 148 | * allows refreshes to happen when external definitions are modified and need | |
| 149 | * to trigger the processing chain. | |
| 150 | */ | |
| 151 | private final ObjectProperty<TextDefinition> mActiveDefinitionEditor = | |
| 152 | createActiveDefinitionEditor( mActiveTextEditor ); | |
| 153 | ||
| 154 | /** | |
| 155 | * Tracks the number of detached tab panels opened into their own windows, | |
| 156 | * which allows unique identification of subordinate windows by their title. | |
| 157 | * It is doubtful more than 128 windows, much less 256, will be created. | |
| 158 | */ | |
| 159 | private byte mWindowCount; | |
| 160 | ||
| 161 | /** | |
| 162 | * Called when the definition data is changed. | |
| 163 | */ | |
| 164 | private final EventHandler<TreeModificationEvent<Event>> mTreeHandler = | |
| 165 | event -> { | |
| 166 | final var editor = mActiveDefinitionEditor.get(); | |
| 167 | ||
| 168 | resolve( editor ); | |
| 169 | process( getActiveTextEditor() ); | |
| 170 | save( editor ); | |
| 171 | }; | |
| 172 | ||
| 173 | private final DocumentStatistics mStatistics; | |
| 174 | ||
| 175 | /** | |
| 176 | * Adds all content panels to the main user interface. This will load the | |
| 177 | * configuration settings from the workspace to reproduce the settings from | |
| 178 | * a previous session. | |
| 179 | */ | |
| 180 | public MainPane( final Workspace workspace ) { | |
| 181 | mWorkspace = workspace; | |
| 182 | mPreview = new HtmlPreview( workspace ); | |
| 183 | mStatistics = new DocumentStatistics( workspace ); | |
| 184 | mActiveTextEditor.set( new MarkdownEditor( workspace ) ); | |
| 185 | ||
| 186 | open( bin( getRecentFiles() ) ); | |
| 187 | viewPreview(); | |
| 188 | setDividerPositions( calculateDividerPositions() ); | |
| 189 | ||
| 190 | // Once the main scene's window regains focus, update the active definition | |
| 191 | // editor to the currently selected tab. | |
| 192 | runLater( () -> getWindow().setOnCloseRequest( ( event ) -> { | |
| 193 | // Order matters here. We want to close all the tabs to ensure each | |
| 194 | // is saved, but after they are closed, the workspace should still | |
| 195 | // retain the list of files that were open. If this line came after | |
| 196 | // closing, then restarting the application would list no files. | |
| 197 | mWorkspace.save(); | |
| 198 | ||
| 199 | if( closeAll() ) { | |
| 200 | Platform.exit(); | |
| 201 | System.exit( 0 ); | |
| 202 | } | |
| 203 | else { | |
| 204 | event.consume(); | |
| 205 | } | |
| 206 | } ) ); | |
| 207 | ||
| 208 | register( this ); | |
| 209 | initAutosave( workspace ); | |
| 210 | } | |
| 211 | ||
| 212 | @Subscribe | |
| 213 | public void handle( final TextEditorFocusEvent event ) { | |
| 214 | mActiveTextEditor.set( event.get() ); | |
| 215 | } | |
| 216 | ||
| 217 | @Subscribe | |
| 218 | public void handle( final TextDefinitionFocusEvent event ) { | |
| 219 | mActiveDefinitionEditor.set( event.get() ); | |
| 220 | } | |
| 221 | ||
| 222 | /** | |
| 223 | * Typically called when a file name is clicked in the {@link HtmlPanel}. | |
| 224 | * | |
| 225 | * @param event The event to process, must contain a valid file reference. | |
| 226 | */ | |
| 227 | @Subscribe | |
| 228 | public void handle( final FileOpenEvent event ) { | |
| 229 | final File eventFile; | |
| 230 | final var eventUri = event.getUri(); | |
| 231 | ||
| 232 | if( eventUri.isAbsolute() ) { | |
| 233 | eventFile = new File( eventUri.getPath() ); | |
| 234 | } | |
| 235 | else { | |
| 236 | final var activeFile = getActiveTextEditor().getFile(); | |
| 237 | final var parent = activeFile.getParentFile(); | |
| 238 | ||
| 239 | if( parent == null ) { | |
| 240 | clue( new FileNotFoundException( eventUri.getPath() ) ); | |
| 241 | return; | |
| 242 | } | |
| 243 | else { | |
| 244 | final var parentPath = parent.getAbsolutePath(); | |
| 245 | eventFile = Path.of( parentPath, eventUri.getPath() ).toFile(); | |
| 246 | } | |
| 247 | } | |
| 248 | ||
| 249 | runLater( () -> open( eventFile ) ); | |
| 250 | } | |
| 251 | ||
| 252 | @Subscribe | |
| 253 | public void handle( final CaretNavigationEvent event ) { | |
| 254 | runLater( () -> { | |
| 255 | final var textArea = getActiveTextEditor().getTextArea(); | |
| 256 | textArea.moveTo( event.getOffset() ); | |
| 257 | textArea.requestFollowCaret(); | |
| 258 | textArea.requestFocus(); | |
| 259 | } ); | |
| 260 | } | |
| 261 | ||
| 262 | @Subscribe | |
| 263 | @SuppressWarnings( "unused" ) | |
| 264 | public void handle( final ExportFailedEvent event ) { | |
| 265 | final var os = getProperty( "os.name" ); | |
| 266 | final var arch = getProperty( "os.arch" ).toLowerCase(); | |
| 267 | final var bits = getProperty( "sun.arch.data.model" ); | |
| 268 | ||
| 269 | final var title = Messages.get( "Alert.typesetter.missing.title" ); | |
| 270 | final var header = Messages.get( "Alert.typesetter.missing.header" ); | |
| 271 | final var version = Messages.get( | |
| 272 | "Alert.typesetter.missing.version", | |
| 273 | os, | |
| 274 | arch | |
| 275 | .replaceAll( "amd.*|i.*|x86.*", "X86" ) | |
| 276 | .replaceAll( "mips.*", "MIPS" ) | |
| 277 | .replaceAll( "armv.*", "ARM" ), | |
| 278 | bits ); | |
| 279 | final var text = Messages.get( "Alert.typesetter.missing.installer.text" ); | |
| 280 | ||
| 281 | // Download and install ConTeXt for {0} {1} {2}-bit | |
| 282 | final var content = format( "%s %s", text, version ); | |
| 283 | final var flowPane = new FlowPane(); | |
| 284 | final var link = new Hyperlink( text ); | |
| 285 | final var label = new Label( version ); | |
| 286 | flowPane.getChildren().addAll( link, label ); | |
| 287 | ||
| 288 | final var alert = new Alert( ERROR, content, OK ); | |
| 289 | alert.setTitle( title ); | |
| 290 | alert.setHeaderText( header ); | |
| 291 | alert.getDialogPane().contentProperty().set( flowPane ); | |
| 292 | alert.setGraphic( ICON_DIALOG_NODE ); | |
| 293 | ||
| 294 | link.setOnAction( ( e ) -> { | |
| 295 | alert.close(); | |
| 296 | final var url = Messages.get( "Alert.typesetter.missing.installer.url" ); | |
| 297 | runLater( () -> fireHyperlinkOpenEvent( url ) ); | |
| 298 | } ); | |
| 299 | ||
| 300 | alert.showAndWait(); | |
| 301 | } | |
| 302 | ||
| 303 | private void initAutosave( final Workspace workspace ) { | |
| 304 | final var rate = workspace.integerProperty( KEY_EDITOR_AUTOSAVE ); | |
| 305 | ||
| 306 | rate.addListener( | |
| 307 | ( c, o, n ) -> { | |
| 308 | final var taskRef = mSaveTask.get(); | |
| 309 | ||
| 310 | // Prevent multiple autosaves from running. | |
| 311 | if( taskRef != null ) { | |
| 312 | taskRef.cancel( false ); | |
| 313 | } | |
| 314 | ||
| 315 | initAutosave( rate ); | |
| 316 | } | |
| 317 | ); | |
| 318 | ||
| 319 | // Start the save listener (avoids duplicating some code). | |
| 320 | initAutosave( rate ); | |
| 321 | } | |
| 322 | ||
| 323 | private void initAutosave( final IntegerProperty rate ) { | |
| 324 | mSaveTask.set( | |
| 325 | mSaver.scheduleAtFixedRate( | |
| 326 | () -> { | |
| 327 | if( getActiveTextEditor().isModified() ) { | |
| 328 | // Ensure the modified indicator is cleared by running on EDT. | |
| 329 | runLater( this::save ); | |
| 330 | } | |
| 331 | }, 0, rate.intValue(), SECONDS | |
| 332 | ) | |
| 333 | ); | |
| 334 | } | |
| 335 | ||
| 336 | /** | |
| 337 | * TODO: Load divider positions from exported settings, see bin() comment. | |
| 338 | */ | |
| 339 | private double[] calculateDividerPositions() { | |
| 340 | final var ratio = 100f / getItems().size() / 100; | |
| 341 | final var positions = getDividerPositions(); | |
| 342 | ||
| 343 | for( int i = 0; i < positions.length; i++ ) { | |
| 344 | positions[ i ] = ratio * i; | |
| 345 | } | |
| 346 | ||
| 347 | return positions; | |
| 348 | } | |
| 349 | ||
| 350 | /** | |
| 351 | * Opens all the files into the application, provided the paths are unique. | |
| 352 | * This may only be called for any type of files that a user can edit | |
| 353 | * (i.e., update and persist), such as definitions and text files. | |
| 354 | * | |
| 355 | * @param files The list of files to open. | |
| 356 | */ | |
| 357 | public void open( final List<File> files ) { | |
| 358 | files.forEach( this::open ); | |
| 359 | } | |
| 360 | ||
| 361 | /** | |
| 362 | * This opens the given file. Since the preview pane is not a file that | |
| 363 | * can be opened, it is safe to add a listener to the detachable pane. | |
| 364 | * | |
| 365 | * @param file The file to open. | |
| 366 | */ | |
| 367 | private void open( final File file ) { | |
| 368 | final var tab = createTab( file ); | |
| 369 | final var node = tab.getContent(); | |
| 370 | final var mediaType = MediaType.valueFrom( file ); | |
| 371 | final var tabPane = obtainTabPane( mediaType ); | |
| 372 | ||
| 373 | tab.setTooltip( createTooltip( file ) ); | |
| 374 | tabPane.setFocusTraversable( false ); | |
| 375 | tabPane.setTabClosingPolicy( ALL_TABS ); | |
| 376 | tabPane.getTabs().add( tab ); | |
| 377 | ||
| 378 | // Attach the tab scene factory for new tab panes. | |
| 379 | if( !getItems().contains( tabPane ) ) { | |
| 380 | addTabPane( | |
| 381 | node instanceof TextDefinition ? 0 : getItems().size(), tabPane | |
| 382 | ); | |
| 383 | } | |
| 384 | ||
| 385 | getRecentFiles().add( file.getAbsolutePath() ); | |
| 386 | } | |
| 387 | ||
| 388 | /** | |
| 389 | * Opens a new text editor document using the default document file name. | |
| 390 | */ | |
| 391 | public void newTextEditor() { | |
| 392 | open( DOCUMENT_DEFAULT ); | |
| 393 | } | |
| 394 | ||
| 395 | /** | |
| 396 | * Opens a new definition editor document using the default definition | |
| 397 | * file name. | |
| 398 | */ | |
| 399 | public void newDefinitionEditor() { | |
| 400 | open( DEFINITION_DEFAULT ); | |
| 401 | } | |
| 402 | ||
| 403 | /** | |
| 404 | * Iterates over all tab panes to find all {@link TextEditor}s and request | |
| 405 | * that they save themselves. | |
| 406 | */ | |
| 407 | public void saveAll() { | |
| 408 | mTabPanes.forEach( | |
| 409 | ( tp ) -> tp.getTabs().forEach( ( tab ) -> { | |
| 410 | final var node = tab.getContent(); | |
| 411 | if( node instanceof final TextEditor editor ) { | |
| 412 | save( editor ); | |
| 413 | } | |
| 414 | } ) | |
| 415 | ); | |
| 416 | } | |
| 417 | ||
| 418 | /** | |
| 419 | * Requests that the active {@link TextEditor} saves itself. Don't bother | |
| 420 | * checking if modified first because if the user swaps external media from | |
| 421 | * an external source (e.g., USB thumb drive), save should not second-guess | |
| 422 | * the user: save always re-saves. Also, it's less code. | |
| 423 | */ | |
| 424 | public void save() { | |
| 425 | save( getActiveTextEditor() ); | |
| 426 | } | |
| 427 | ||
| 428 | /** | |
| 429 | * Saves the active {@link TextEditor} under a new name. | |
| 430 | * | |
| 431 | * @param files The new active editor {@link File} reference, must contain | |
| 432 | * at least one element. | |
| 433 | */ | |
| 434 | public void saveAs( final List<File> files ) { | |
| 435 | assert files != null; | |
| 436 | assert !files.isEmpty(); | |
| 437 | final var editor = getActiveTextEditor(); | |
| 438 | final var tab = getTab( editor ); | |
| 439 | final var file = files.get( 0 ); | |
| 440 | ||
| 441 | editor.rename( file ); | |
| 442 | tab.ifPresent( t -> { | |
| 443 | t.setText( editor.getFilename() ); | |
| 444 | t.setTooltip( createTooltip( file ) ); | |
| 445 | } ); | |
| 446 | ||
| 447 | save(); | |
| 448 | } | |
| 449 | ||
| 450 | /** | |
| 451 | * Saves the given {@link TextResource} to a file. This is typically used | |
| 452 | * to save either an instance of {@link TextEditor} or {@link TextDefinition}. | |
| 453 | * | |
| 454 | * @param resource The resource to export. | |
| 455 | */ | |
| 456 | private void save( final TextResource resource ) { | |
| 457 | try { | |
| 458 | resource.save(); | |
| 459 | } catch( final Exception ex ) { | |
| 460 | clue( ex ); | |
| 461 | sNotifier.alert( | |
| 462 | getWindow(), resource.getPath(), "TextResource.saveFailed", ex | |
| 463 | ); | |
| 464 | } | |
| 465 | } | |
| 466 | ||
| 467 | /** | |
| 468 | * Closes all open {@link TextEditor}s; all {@link TextDefinition}s stay open. | |
| 469 | * | |
| 470 | * @return {@code true} when all editors, modified or otherwise, were | |
| 471 | * permitted to close; {@code false} when one or more editors were modified | |
| 472 | * and the user requested no closing. | |
| 473 | */ | |
| 474 | public boolean closeAll() { | |
| 475 | var closable = true; | |
| 476 | ||
| 477 | for( final var tabPane : mTabPanes ) { | |
| 478 | final var tabIterator = tabPane.getTabs().iterator(); | |
| 479 | ||
| 480 | while( tabIterator.hasNext() ) { | |
| 481 | final var tab = tabIterator.next(); | |
| 482 | final var resource = tab.getContent(); | |
| 483 | ||
| 484 | // The definition panes auto-save, so being specific here prevents | |
| 485 | // closing the definitions in the situation where the user wants to | |
| 486 | // continue editing (i.e., possibly save unsaved work). | |
| 487 | if( !(resource instanceof TextEditor) ) { | |
| 488 | continue; | |
| 489 | } | |
| 490 | ||
| 491 | if( canClose( (TextEditor) resource ) ) { | |
| 492 | tabIterator.remove(); | |
| 493 | close( tab ); | |
| 494 | } | |
| 495 | else { | |
| 496 | closable = false; | |
| 497 | } | |
| 498 | } | |
| 499 | } | |
| 500 | ||
| 501 | return closable; | |
| 502 | } | |
| 503 | ||
| 504 | /** | |
| 505 | * Calls the tab's {@link Tab#getOnClosed()} handler to carry out a close | |
| 506 | * event. | |
| 507 | * | |
| 508 | * @param tab The {@link Tab} that was closed. | |
| 509 | */ | |
| 510 | private void close( final Tab tab ) { | |
| 511 | assert tab != null; | |
| 512 | ||
| 513 | final var handler = tab.getOnClosed(); | |
| 514 | ||
| 515 | if( handler != null ) { | |
| 516 | handler.handle( new ActionEvent() ); | |
| 517 | } | |
| 518 | } | |
| 519 | ||
| 520 | /** | |
| 521 | * Closes the active tab; delegates to {@link #canClose(TextResource)}. | |
| 522 | */ | |
| 523 | public void close() { | |
| 524 | final var editor = getActiveTextEditor(); | |
| 525 | ||
| 526 | if( canClose( editor ) ) { | |
| 527 | close( editor ); | |
| 528 | } | |
| 529 | } | |
| 530 | ||
| 531 | /** | |
| 532 | * Closes the given {@link TextResource}. This must not be called from within | |
| 533 | * a loop that iterates over the tab panes using {@code forEach}, lest a | |
| 534 | * concurrent modification exception be thrown. | |
| 535 | * | |
| 536 | * @param resource The {@link TextResource} to close, without confirming with | |
| 537 | * the user. | |
| 538 | */ | |
| 539 | private void close( final TextResource resource ) { | |
| 540 | getTab( resource ).ifPresent( | |
| 541 | ( tab ) -> { | |
| 542 | close( tab ); | |
| 543 | tab.getTabPane().getTabs().remove( tab ); | |
| 544 | } | |
| 545 | ); | |
| 546 | } | |
| 547 | ||
| 548 | /** | |
| 549 | * Answers whether the given {@link TextResource} may be closed. | |
| 550 | * | |
| 551 | * @param editor The {@link TextResource} to try closing. | |
| 552 | * @return {@code true} when the editor may be closed; {@code false} when | |
| 553 | * the user has requested to keep the editor open. | |
| 554 | */ | |
| 555 | private boolean canClose( final TextResource editor ) { | |
| 556 | final var editorTab = getTab( editor ); | |
| 557 | final var canClose = new AtomicBoolean( true ); | |
| 558 | ||
| 559 | if( editor.isModified() ) { | |
| 560 | final var filename = new StringBuilder(); | |
| 561 | editorTab.ifPresent( ( tab ) -> filename.append( tab.getText() ) ); | |
| 562 | ||
| 563 | final var message = sNotifier.createNotification( | |
| 564 | Messages.get( "Alert.file.close.title" ), | |
| 565 | Messages.get( "Alert.file.close.text" ), | |
| 566 | filename.toString() | |
| 567 | ); | |
| 568 | ||
| 569 | final var dialog = sNotifier.createConfirmation( getWindow(), message ); | |
| 570 | ||
| 571 | dialog.showAndWait().ifPresent( | |
| 572 | save -> canClose.set( save == YES ? editor.save() : save == NO ) | |
| 573 | ); | |
| 574 | } | |
| 575 | ||
| 576 | return canClose.get(); | |
| 577 | } | |
| 578 | ||
| 579 | private ObjectProperty<TextEditor> createActiveTextEditor() { | |
| 580 | final var editor = new SimpleObjectProperty<TextEditor>(); | |
| 581 | ||
| 582 | editor.addListener( ( c, o, n ) -> { | |
| 583 | if( n != null ) { | |
| 584 | mPreview.setBaseUri( n.getPath() ); | |
| 585 | process( n ); | |
| 586 | } | |
| 587 | } ); | |
| 588 | ||
| 589 | return editor; | |
| 590 | } | |
| 591 | ||
| 592 | /** | |
| 593 | * Adds the HTML preview tab to its own, singular tab pane. | |
| 594 | */ | |
| 595 | public void viewPreview() { | |
| 596 | viewTab( mPreview, TEXT_HTML, "Pane.preview.title" ); | |
| 597 | } | |
| 598 | ||
| 599 | /** | |
| 600 | * Adds the document outline tab to its own, singular tab pane. | |
| 601 | */ | |
| 602 | public void viewOutline() { | |
| 603 | viewTab( mOutline, APP_DOCUMENT_OUTLINE, "Pane.outline.title" ); | |
| 604 | } | |
| 605 | ||
| 606 | public void viewStatistics() { | |
| 607 | viewTab( mStatistics, APP_DOCUMENT_STATISTICS, "Pane.statistics.title" ); | |
| 608 | } | |
| 609 | ||
| 610 | public void viewFiles() { | |
| 611 | try { | |
| 612 | final var factory = new FilePickerFactory( mWorkspace ); | |
| 613 | final var fileManager = factory.createModeless(); | |
| 614 | viewTab( fileManager, APP_FILE_MANAGER, "Pane.files.title" ); | |
| 615 | } catch( final Exception ex ) { | |
| 616 | clue( ex ); | |
| 617 | } | |
| 618 | } | |
| 619 | ||
| 620 | private void viewTab( | |
| 621 | final Node node, final MediaType mediaType, final String key ) { | |
| 622 | final var tabPane = obtainTabPane( mediaType ); | |
| 623 | ||
| 624 | for( final var tab : tabPane.getTabs() ) { | |
| 625 | if( tab.getContent() == node ) { | |
| 626 | return; | |
| 627 | } | |
| 628 | } | |
| 629 | ||
| 630 | tabPane.getTabs().add( createTab( get( key ), node ) ); | |
| 631 | addTabPane( tabPane ); | |
| 632 | } | |
| 633 | ||
| 634 | public void viewRefresh() { | |
| 635 | mPreview.refresh(); | |
| 636 | } | |
| 637 | ||
| 638 | /** | |
| 639 | * Returns the tab that contains the given {@link TextEditor}. | |
| 640 | * | |
| 641 | * @param editor The {@link TextEditor} instance to find amongst the tabs. | |
| 642 | * @return The first tab having content that matches the given tab. | |
| 643 | */ | |
| 644 | private Optional<Tab> getTab( final TextResource editor ) { | |
| 645 | return mTabPanes.stream() | |
| 646 | .flatMap( pane -> pane.getTabs().stream() ) | |
| 647 | .filter( tab -> editor.equals( tab.getContent() ) ) | |
| 648 | .findFirst(); | |
| 649 | } | |
| 650 | ||
| 651 | /** | |
| 652 | * Creates a new {@link DefinitionEditor} wrapped in a listener that | |
| 653 | * is used to detect when the active {@link DefinitionEditor} has changed. | |
| 654 | * Upon changing, the {@link #mResolvedMap} is updated and the active | |
| 655 | * text editor is refreshed. | |
| 656 | * | |
| 657 | * @param editor Text editor to update with the revised resolved map. | |
| 658 | * @return A newly configured property that represents the active | |
| 659 | * {@link DefinitionEditor}, never null. | |
| 660 | */ | |
| 661 | private ObjectProperty<TextDefinition> createActiveDefinitionEditor( | |
| 662 | final ObjectProperty<TextEditor> editor ) { | |
| 663 | final var definitions = new SimpleObjectProperty<TextDefinition>(); | |
| 664 | definitions.addListener( ( c, o, n ) -> { | |
| 665 | resolve( n == null ? createDefinitionEditor() : n ); | |
| 666 | process( editor.get() ); | |
| 667 | } ); | |
| 668 | ||
| 669 | return definitions; | |
| 670 | } | |
| 671 | ||
| 672 | private Tab createTab( final String filename, final Node node ) { | |
| 673 | return new DetachableTab( filename, node ); | |
| 674 | } | |
| 675 | ||
| 676 | private Tab createTab( final File file ) { | |
| 677 | final var r = createTextResource( file ); | |
| 678 | final var tab = createTab( r.getFilename(), r.getNode() ); | |
| 679 | ||
| 680 | r.modifiedProperty().addListener( | |
| 681 | ( c, o, n ) -> tab.setText( r.getFilename() + (n ? "*" : "") ) | |
| 682 | ); | |
| 683 | ||
| 684 | // This is called when either the tab is closed by the user clicking on | |
| 685 | // the tab's close icon or when closing (all) from the file menu. | |
| 686 | tab.setOnClosed( | |
| 687 | ( __ ) -> getRecentFiles().remove( file.getAbsolutePath() ) | |
| 688 | ); | |
| 689 | ||
| 690 | tab.tabPaneProperty().addListener( ( cPane, oPane, nPane ) -> { | |
| 691 | if( nPane != null ) { | |
| 692 | nPane.focusedProperty().addListener( ( c, o, n ) -> { | |
| 693 | if( n != null && n ) { | |
| 694 | final var selected = nPane.getSelectionModel().getSelectedItem(); | |
| 695 | final var node = selected.getContent(); | |
| 696 | node.requestFocus(); | |
| 697 | } | |
| 698 | } ); | |
| 699 | } | |
| 700 | } ); | |
| 701 | ||
| 702 | return tab; | |
| 703 | } | |
| 704 | ||
| 705 | /** | |
| 706 | * Creates bins for the different {@link MediaType}s, which eventually are | |
| 707 | * added to the UI as separate tab panes. If ever a general-purpose scene | |
| 708 | * exporter is developed to serialize a scene to an FXML file, this could | |
| 709 | * be replaced by such a class. | |
| 710 | * <p> | |
| 711 | * When binning the files, this makes sure that at least one file exists | |
| 712 | * for every type. If the user has opted to close a particular type (such | |
| 713 | * as the definition pane), the view will suppressed elsewhere. | |
| 714 | * </p> | |
| 715 | * <p> | |
| 716 | * The order that the binned files are returned will be reflected in the | |
| 717 | * order that the corresponding panes are rendered in the UI. | |
| 718 | * </p> | |
| 719 | * | |
| 720 | * @param paths The file paths to bin according to their type. | |
| 721 | * @return An in-order list of files, first by structured definition files, | |
| 722 | * then by plain text documents. | |
| 723 | */ | |
| 724 | private List<File> bin( final SetProperty<String> paths ) { | |
| 725 | // Treat all files destined for the text editor as plain text documents | |
| 726 | // so that they are added to the same pane. Grouping by TEXT_PLAIN is a | |
| 727 | // bit arbitrary, but means explicitly capturing TEXT_PLAIN isn't needed. | |
| 728 | final Function<MediaType, MediaType> bin = | |
| 729 | m -> PLAIN_TEXT_FORMAT.contains( m ) ? TEXT_PLAIN : m; | |
| 730 | ||
| 731 | // Create two groups: YAML files and plain text files. | |
| 732 | final var bins = paths | |
| 733 | .stream() | |
| 734 | .collect( | |
| 735 | groupingBy( path -> bin.apply( MediaType.fromFilename( path ) ) ) | |
| 736 | ); | |
| 737 | ||
| 738 | bins.putIfAbsent( TEXT_YAML, List.of( DEFINITION_DEFAULT.toString() ) ); | |
| 739 | bins.putIfAbsent( TEXT_PLAIN, List.of( DOCUMENT_DEFAULT.toString() ) ); | |
| 740 | ||
| 741 | final var result = new ArrayList<File>( paths.size() ); | |
| 742 | ||
| 743 | // Ensure that the same types are listed together (keep insertion order). | |
| 744 | bins.forEach( ( mediaType, files ) -> result.addAll( | |
| 745 | files.stream().map( File::new ).collect( Collectors.toList() ) ) | |
| 746 | ); | |
| 747 | ||
| 748 | return result; | |
| 749 | } | |
| 750 | ||
| 751 | /** | |
| 752 | * Uses the given {@link TextDefinition} instance to update the | |
| 753 | * {@link #mResolvedMap}. | |
| 754 | * | |
| 755 | * @param editor A non-null, possibly empty definition editor. | |
| 756 | */ | |
| 757 | private void resolve( final TextDefinition editor ) { | |
| 758 | assert editor != null; | |
| 759 | ||
| 760 | final var tokens = createDefinitionTokens(); | |
| 761 | final var operator = new YamlSigilOperator( tokens ); | |
| 762 | final var map = new HashMap<String, String>(); | |
| 763 | ||
| 764 | editor.toMap().forEach( ( k, v ) -> map.put( operator.entoken( k ), v ) ); | |
| 765 | ||
| 766 | mResolvedMap.clear(); | |
| 767 | mResolvedMap.putAll( editor.interpolate( map, tokens ) ); | |
| 768 | } | |
| 769 | ||
| 770 | /** | |
| 771 | * Force the active editor to update, which will cause the processor | |
| 772 | * to re-evaluate the interpolated definition map thereby updating the | |
| 773 | * preview pane. | |
| 774 | * | |
| 775 | * @param editor Contains the source document to update in the preview pane. | |
| 776 | */ | |
| 777 | private void process( final TextEditor editor ) { | |
| 778 | // Ensure processing does not run on the JavaFX thread, which frees the | |
| 779 | // text editor immediately for caret movement. The preview will have a | |
| 780 | // slight delay when catching up to the caret position. | |
| 781 | final var task = new Task<Void>() { | |
| 782 | @Override | |
| 783 | public Void call() { | |
| 784 | try { | |
| 785 | final var p = mProcessors.getOrDefault( editor, IDENTITY ); | |
| 786 | p.apply( editor == null ? "" : editor.getText() ); | |
| 787 | } catch( final Exception ex ) { | |
| 788 | clue( ex ); | |
| 789 | } | |
| 790 | ||
| 791 | return null; | |
| 792 | } | |
| 793 | }; | |
| 794 | ||
| 795 | task.setOnSucceeded( | |
| 796 | e -> invokeLater( () -> mPreview.scrollTo( CARET_ID ) ) | |
| 797 | ); | |
| 798 | ||
| 799 | // Prevents multiple process requests from executing simultaneously (due | |
| 800 | // to having a restricted queue size). | |
| 801 | sExecutor.execute( task ); | |
| 802 | } | |
| 803 | ||
| 804 | /** | |
| 805 | * Lazily creates a {@link TabPane} configured to listen for tab select | |
| 806 | * events. The tab pane is associated with a given media type so that | |
| 807 | * similar files can be grouped together. | |
| 808 | * | |
| 809 | * @param mediaType The media type to associate with the tab pane. | |
| 810 | * @return An instance of {@link TabPane} that will handle tab docking. | |
| 811 | */ | |
| 812 | private TabPane obtainTabPane( final MediaType mediaType ) { | |
| 813 | for( final var pane : mTabPanes ) { | |
| 814 | for( final var tab : pane.getTabs() ) { | |
| 815 | final var node = tab.getContent(); | |
| 816 | ||
| 817 | if( node instanceof TextResource r && r.supports( mediaType ) ) { | |
| 818 | return pane; | |
| 819 | } | |
| 820 | } | |
| 821 | } | |
| 822 | ||
| 823 | final var pane = createTabPane(); | |
| 824 | mTabPanes.add( pane ); | |
| 825 | return pane; | |
| 826 | } | |
| 827 | ||
| 828 | /** | |
| 829 | * Creates an initialized {@link TabPane} instance. | |
| 830 | * | |
| 831 | * @return A new {@link TabPane} with all listeners configured. | |
| 832 | */ | |
| 833 | private TabPane createTabPane() { | |
| 834 | final var tabPane = new DetachableTabPane(); | |
| 835 | ||
| 836 | initStageOwnerFactory( tabPane ); | |
| 837 | initTabListener( tabPane ); | |
| 838 | ||
| 839 | return tabPane; | |
| 840 | } | |
| 841 | ||
| 842 | /** | |
| 843 | * When any {@link DetachableTabPane} is detached from the main window, | |
| 844 | * the stage owner factory must be given its parent window, which will | |
| 845 | * own the child window. The parent window is the {@link MainPane}'s | |
| 846 | * {@link Scene}'s {@link Window} instance. | |
| 847 | * | |
| 848 | * <p> | |
| 849 | * This will derives the new title from the main window title, incrementing | |
| 850 | * the window count to help uniquely identify the child windows. | |
| 851 | * </p> | |
| 852 | * | |
| 853 | * @param tabPane A new {@link DetachableTabPane} to configure. | |
| 854 | */ | |
| 855 | private void initStageOwnerFactory( final DetachableTabPane tabPane ) { | |
| 856 | tabPane.setStageOwnerFactory( ( stage ) -> { | |
| 857 | final var title = get( | |
| 858 | "Detach.tab.title", | |
| 859 | ((Stage) getWindow()).getTitle(), ++mWindowCount | |
| 860 | ); | |
| 861 | stage.setTitle( title ); | |
| 862 | ||
| 863 | return getScene().getWindow(); | |
| 864 | } ); | |
| 865 | } | |
| 866 | ||
| 867 | /** | |
| 868 | * Responsible for configuring the content of each {@link DetachableTab} when | |
| 869 | * it is added to the given {@link DetachableTabPane} instance. | |
| 870 | * <p> | |
| 871 | * For {@link TextEditor} contents, an instance of {@link ScrollEventHandler} | |
| 872 | * is initialized to perform synchronized scrolling between the editor and | |
| 873 | * its preview window. Additionally, the last tab in the tab pane's list of | |
| 874 | * tabs is given focus. | |
| 875 | * </p> | |
| 876 | * <p> | |
| 877 | * Note that multiple tabs can be added simultaneously. | |
| 878 | * </p> | |
| 879 | * | |
| 880 | * @param tabPane A new {@link TabPane} to configure. | |
| 881 | */ | |
| 882 | private void initTabListener( final TabPane tabPane ) { | |
| 883 | tabPane.getTabs().addListener( | |
| 884 | ( final ListChangeListener.Change<? extends Tab> listener ) -> { | |
| 885 | while( listener.next() ) { | |
| 886 | if( listener.wasAdded() ) { | |
| 887 | final var tabs = listener.getAddedSubList(); | |
| 888 | ||
| 889 | tabs.forEach( ( tab ) -> { | |
| 890 | final var node = tab.getContent(); | |
| 891 | ||
| 892 | if( node instanceof TextEditor ) { | |
| 893 | initScrollEventListener( tab ); | |
| 894 | } | |
| 895 | } ); | |
| 896 | ||
| 897 | // Select and give focus to the last tab opened. | |
| 898 | final var index = tabs.size() - 1; | |
| 899 | if( index >= 0 ) { | |
| 900 | final var tab = tabs.get( index ); | |
| 901 | tabPane.getSelectionModel().select( tab ); | |
| 902 | tab.getContent().requestFocus(); | |
| 903 | } | |
| 904 | } | |
| 905 | } | |
| 906 | } | |
| 907 | ); | |
| 908 | } | |
| 909 | ||
| 910 | /** | |
| 911 | * Synchronizes scrollbar positions between the given {@link Tab} that | |
| 912 | * contains an instance of {@link TextEditor} and {@link HtmlPreview} pane. | |
| 913 | * | |
| 914 | * @param tab The container for an instance of {@link TextEditor}. | |
| 915 | */ | |
| 916 | private void initScrollEventListener( final Tab tab ) { | |
| 917 | final var editor = (TextEditor) tab.getContent(); | |
| 918 | final var scrollPane = editor.getScrollPane(); | |
| 919 | final var scrollBar = mPreview.getVerticalScrollBar(); | |
| 920 | final var handler = new ScrollEventHandler( scrollPane, scrollBar ); | |
| 921 | handler.enabledProperty().bind( tab.selectedProperty() ); | |
| 922 | } | |
| 923 | ||
| 924 | private void addTabPane( final int index, final TabPane tabPane ) { | |
| 925 | final var items = getItems(); | |
| 926 | if( !items.contains( tabPane ) ) { | |
| 927 | items.add( index, tabPane ); | |
| 928 | } | |
| 929 | } | |
| 930 | ||
| 931 | private void addTabPane( final TabPane tabPane ) { | |
| 932 | addTabPane( getItems().size(), tabPane ); | |
| 933 | } | |
| 934 | ||
| 935 | public ProcessorContext createProcessorContext() { | |
| 936 | return createProcessorContext( null, NONE ); | |
| 937 | } | |
| 938 | ||
| 939 | public ProcessorContext createProcessorContext( | |
| 940 | final Path exportPath, final ExportFormat format ) { | |
| 941 | final var editor = getActiveTextEditor(); | |
| 942 | return createProcessorContext( | |
| 943 | editor.getPath(), exportPath, format, editor.getCaret() ); | |
| 944 | } | |
| 945 | ||
| 946 | private ProcessorContext createProcessorContext( | |
| 947 | final Path path, final Caret caret ) { | |
| 948 | return createProcessorContext( path, null, ExportFormat.NONE, caret ); | |
| 949 | } | |
| 950 | ||
| 951 | /** | |
| 952 | * @param path Used by {@link ProcessorFactory} to determine | |
| 953 | * {@link Processor} type to create based on file type. | |
| 954 | * @param exportPath Used when exporting to a PDF file (binary). | |
| 955 | * @param format Used when processors export to a new text format. | |
| 956 | * @param caret Used by {@link CaretExtension} to add ID attribute into | |
| 957 | * preview document for scrollbar synchronization. | |
| 958 | * @return A new {@link ProcessorContext} to use when creating an instance of | |
| 959 | * {@link Processor}. | |
| 960 | */ | |
| 961 | private ProcessorContext createProcessorContext( | |
| 962 | final Path path, final Path exportPath, final ExportFormat format, | |
| 963 | final Caret caret ) { | |
| 964 | return new ProcessorContext( | |
| 965 | mPreview, mResolvedMap, path, exportPath, format, mWorkspace, caret | |
| 966 | ); | |
| 967 | } | |
| 968 | ||
| 969 | private TextResource createTextResource( final File file ) { | |
| 970 | // TODO: Create PlainTextEditor that's returned by default. | |
| 971 | return MediaType.valueFrom( file ) == TEXT_YAML | |
| 972 | ? createDefinitionEditor( file ) | |
| 973 | : createMarkdownEditor( file ); | |
| 974 | } | |
| 975 | ||
| 976 | /** | |
| 977 | * Creates an instance of {@link MarkdownEditor} that listens for both | |
| 978 | * caret change events and text change events. Text change events must | |
| 979 | * take priority over caret change events because it's possible to change | |
| 980 | * the text without moving the caret (e.g., delete selected text). | |
| 981 | * | |
| 982 | * @param file The file containing contents for the text editor. | |
| 983 | * @return A non-null text editor. | |
| 984 | */ | |
| 985 | private TextResource createMarkdownEditor( final File file ) { | |
| 986 | final var path = file.toPath(); | |
| 987 | final var editor = new MarkdownEditor( file, getWorkspace() ); | |
| 988 | final var caret = editor.getCaret(); | |
| 989 | final var context = createProcessorContext( path, caret ); | |
| 990 | ||
| 991 | mProcessors.computeIfAbsent( editor, p -> createProcessors( context ) ); | |
| 992 | ||
| 993 | editor.addDirtyListener( ( c, o, n ) -> { | |
| 994 | if( n ) { | |
| 995 | // Reset the status to OK after changing the text. | |
| 996 | clue(); | |
| 997 | ||
| 998 | // Processing the text may update the status bar. | |
| 999 | process( getActiveTextEditor() ); | |
| 1000 | } | |
| 1001 | } ); | |
| 1002 | ||
| 1003 | editor.addEventListener( | |
| 1004 | keyPressed( SPACE, CONTROL_DOWN ), this::autoinsert | |
| 1005 | ); | |
| 1006 | ||
| 1007 | // Set the active editor, which refreshes the preview panel. | |
| 1008 | mActiveTextEditor.set( editor ); | |
| 1009 | ||
| 1010 | return editor; | |
| 1011 | } | |
| 1012 | ||
| 1013 | /** | |
| 1014 | * Delegates to {@link #autoinsert()}. | |
| 1015 | * | |
| 1016 | * @param event Ignored. | |
| 1017 | */ | |
| 1018 | @SuppressWarnings( "unused" ) | |
| 973 | 1019 | private void autoinsert( final KeyEvent event ) { |
| 974 | 1020 | autoinsert(); |
| 56 | 56 | appPane.setBottom( mStatusBar ); |
| 57 | 57 | |
| 58 | final var watchThread = new Thread( mFileWatchService ); | |
| 59 | watchThread.setDaemon( true ); | |
| 60 | watchThread.start(); | |
| 58 | final var fileWatcher = new Thread( mFileWatchService ); | |
| 59 | fileWatcher.setDaemon( true ); | |
| 60 | fileWatcher.start(); | |
| 61 | 61 | |
| 62 | 62 | mScene = createScene( appPane ); |
| 194 | 194 | |
| 195 | 195 | /** |
| 196 | * Custom CSS to apply. | |
| 196 | * Custom JavaFX CSS to apply to user interface. | |
| 197 | 197 | */ |
| 198 | 198 | public static final File SKIN_CUSTOM_DEFAULT = null; |
| 199 | ||
| 200 | /** | |
| 201 | * Custom HTML CSS to apply to HTML preview panel. | |
| 202 | */ | |
| 203 | public static final File PREVIEW_CUSTOM_DEFAULT = null; | |
| 199 | 204 | |
| 200 | 205 | /** |
| 95 | 95 | input.setEncoding( UTF_8.toString() ); |
| 96 | 96 | input.setCharacterStream( reader ); |
| 97 | ||
| 97 | 98 | return sDocumentBuilder.parse( input ); |
| 98 | 99 | } catch( final Exception ex ) { |
| 99 | 100 | clue( ex ); |
| 101 | ||
| 100 | 102 | return sDocumentBuilder.newDocument(); |
| 101 | 103 | } |
| ... | ||
| 116 | 118 | */ |
| 117 | 119 | public static void walk( |
| 118 | final Document document, final String xpath, | |
| 120 | final Document document, | |
| 121 | final String xpath, | |
| 119 | 122 | final Consumer<Node> consumer ) { |
| 120 | 123 | assert document != null; |
| ... | ||
| 138 | 141 | final Document document, final Map.Entry<String, String> entry ) { |
| 139 | 142 | final var node = document.createElement( "meta" ); |
| 143 | ||
| 140 | 144 | node.setAttribute( "name", entry.getKey() ); |
| 141 | 145 | node.setAttribute( "content", entry.getValue() ); |
| ... | ||
| 150 | 154 | |
| 151 | 155 | sTransformer.transform( domSource, result ); |
| 156 | ||
| 152 | 157 | return writer.toString(); |
| 153 | 158 | } catch( final Exception ex ) { |
| ... | ||
| 161 | 166 | try( final var writer = new StringWriter() ) { |
| 162 | 167 | sTransformer.transform( |
| 163 | new DOMSource( root ), new StreamResult( writer ) ); | |
| 168 | new DOMSource( root ), new StreamResult( writer ) | |
| 169 | ); | |
| 170 | ||
| 164 | 171 | return writer.toString(); |
| 165 | 172 | } |
| ... | ||
| 191 | 198 | public static String decorate( final String html ) { |
| 192 | 199 | return |
| 193 | "<html><head><title> </title></head><body>" + html + "</body></html>"; | |
| 200 | "<html><head><title> </title><meta charset='utf8'/></head><body>" | |
| 201 | + html | |
| 202 | + "</body></html>"; | |
| 194 | 203 | } |
| 195 | 204 | |
| 10 | 10 | import com.dlsc.preferencesfx.util.StorageHandler; |
| 11 | 11 | import com.dlsc.preferencesfx.view.NavigationView; |
| 12 | import javafx.beans.property.BooleanProperty; | |
| 13 | import javafx.beans.property.DoubleProperty; | |
| 14 | import javafx.beans.property.ObjectProperty; | |
| 15 | import javafx.beans.property.StringProperty; | |
| 16 | import javafx.event.EventHandler; | |
| 17 | import javafx.scene.Node; | |
| 18 | import javafx.scene.control.Button; | |
| 19 | import javafx.scene.control.DialogPane; | |
| 20 | import javafx.scene.control.Label; | |
| 21 | import org.controlsfx.control.MasterDetailPane; | |
| 22 | ||
| 23 | import java.io.File; | |
| 24 | ||
| 25 | import static com.dlsc.formsfx.model.structure.Field.ofStringType; | |
| 26 | import static com.dlsc.preferencesfx.PreferencesFxEvent.EVENT_PREFERENCES_SAVED; | |
| 27 | import static com.keenwrite.Messages.get; | |
| 28 | import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG; | |
| 29 | import static com.keenwrite.preferences.LocaleProperty.localeListProperty; | |
| 30 | import static com.keenwrite.preferences.SkinProperty.skinListProperty; | |
| 31 | import static com.keenwrite.preferences.WorkspaceKeys.*; | |
| 32 | import static javafx.scene.control.ButtonType.CANCEL; | |
| 33 | import static javafx.scene.control.ButtonType.OK; | |
| 34 | ||
| 35 | /** | |
| 36 | * Provides the ability for users to configure their preferences. This links | |
| 37 | * the {@link Workspace} model with the {@link PreferencesFx} view, in MVC. | |
| 38 | */ | |
| 39 | @SuppressWarnings( "SameParameterValue" ) | |
| 40 | public final class PreferencesController { | |
| 41 | ||
| 42 | private final Workspace mWorkspace; | |
| 43 | private final PreferencesFx mPreferencesFx; | |
| 44 | ||
| 45 | public PreferencesController( final Workspace workspace ) { | |
| 46 | mWorkspace = workspace; | |
| 47 | ||
| 48 | // All properties must be initialized before creating the dialog. | |
| 49 | mPreferencesFx = createPreferencesFx(); | |
| 50 | ||
| 51 | initKeyEventHandler( mPreferencesFx ); | |
| 52 | } | |
| 53 | ||
| 54 | /** | |
| 55 | * Display the user preferences settings dialog (non-modal). | |
| 56 | */ | |
| 57 | public void show() { | |
| 58 | getPreferencesFx().show( false ); | |
| 59 | } | |
| 60 | ||
| 61 | /** | |
| 62 | * Call to persist the settings. Strictly speaking, this could watch on | |
| 63 | * all values for external changes then save automatically. | |
| 64 | */ | |
| 65 | public void save() { | |
| 66 | getPreferencesFx().saveSettings(); | |
| 67 | } | |
| 68 | ||
| 69 | /** | |
| 70 | * Delegates to the {@link PreferencesFx} event handler for monitoring | |
| 71 | * save events. | |
| 72 | * | |
| 73 | * @param eventHandler The handler to call when the preferences are saved. | |
| 74 | */ | |
| 75 | public void addSaveEventHandler( | |
| 76 | final EventHandler<? super PreferencesFxEvent> eventHandler ) { | |
| 77 | getPreferencesFx().addEventHandler( EVENT_PREFERENCES_SAVED, eventHandler ); | |
| 78 | } | |
| 79 | ||
| 80 | private StringField createFontNameField( | |
| 81 | final StringProperty fontName, final DoubleProperty fontSize ) { | |
| 82 | final var control = new SimpleFontControl( "Change" ); | |
| 83 | control.fontSizeProperty().addListener( ( c, o, n ) -> { | |
| 84 | if( n != null ) { | |
| 85 | fontSize.set( n.doubleValue() ); | |
| 86 | } | |
| 87 | } ); | |
| 88 | return ofStringType( fontName ).render( control ); | |
| 89 | } | |
| 90 | ||
| 91 | /** | |
| 92 | * Creates the preferences dialog based using {@link XmlStorageHandler} and | |
| 93 | * numerous {@link Category} objects. | |
| 94 | * | |
| 95 | * @return A component for editing preferences. | |
| 96 | * @throws RuntimeException Could not construct the {@link PreferencesFx} | |
| 97 | * object (e.g., illegal access permissions, | |
| 98 | * unmapped XML resource). | |
| 99 | */ | |
| 100 | private PreferencesFx createPreferencesFx() { | |
| 101 | return PreferencesFx.of( createStorageHandler(), createCategories() ) | |
| 102 | .instantPersistent( false ) | |
| 103 | .dialogIcon( ICON_DIALOG ); | |
| 104 | } | |
| 105 | ||
| 106 | private StorageHandler createStorageHandler() { | |
| 107 | return new XmlStorageHandler(); | |
| 108 | } | |
| 109 | ||
| 110 | private Category[] createCategories() { | |
| 111 | return new Category[]{ | |
| 112 | Category.of( | |
| 113 | get( KEY_DOC ), | |
| 114 | Group.of( | |
| 115 | get( KEY_DOC_TITLE ), | |
| 116 | Setting.of( label( KEY_DOC_TITLE ) ), | |
| 117 | Setting.of( title( KEY_DOC_TITLE ), | |
| 118 | stringProperty( KEY_DOC_TITLE ) ) | |
| 119 | ), | |
| 120 | Group.of( | |
| 121 | get( KEY_DOC_AUTHOR ), | |
| 122 | Setting.of( label( KEY_DOC_AUTHOR ) ), | |
| 123 | Setting.of( title( KEY_DOC_AUTHOR ), | |
| 124 | stringProperty( KEY_DOC_AUTHOR ) ) | |
| 125 | ), | |
| 126 | Group.of( | |
| 127 | get( KEY_DOC_BYLINE ), | |
| 128 | Setting.of( label( KEY_DOC_BYLINE ) ), | |
| 129 | Setting.of( title( KEY_DOC_BYLINE ), | |
| 130 | stringProperty( KEY_DOC_BYLINE ) ) | |
| 131 | ), | |
| 132 | Group.of( | |
| 133 | get( KEY_DOC_ADDRESS ), | |
| 134 | Setting.of( label( KEY_DOC_ADDRESS ) ), | |
| 135 | createMultilineSetting( "Address", KEY_DOC_ADDRESS ) | |
| 136 | ), | |
| 137 | Group.of( | |
| 138 | get( KEY_DOC_PHONE ), | |
| 139 | Setting.of( label( KEY_DOC_PHONE ) ), | |
| 140 | Setting.of( title( KEY_DOC_PHONE ), | |
| 141 | stringProperty( KEY_DOC_PHONE ) ) | |
| 142 | ), | |
| 143 | Group.of( | |
| 144 | get( KEY_DOC_EMAIL ), | |
| 145 | Setting.of( label( KEY_DOC_EMAIL ) ), | |
| 146 | Setting.of( title( KEY_DOC_EMAIL ), | |
| 147 | stringProperty( KEY_DOC_EMAIL ) ) | |
| 148 | ), | |
| 149 | Group.of( | |
| 150 | get( KEY_DOC_KEYWORDS ), | |
| 151 | Setting.of( label( KEY_DOC_KEYWORDS ) ), | |
| 152 | Setting.of( title( KEY_DOC_KEYWORDS ), | |
| 153 | stringProperty( KEY_DOC_KEYWORDS ) ) | |
| 154 | ), | |
| 155 | Group.of( | |
| 156 | get( KEY_DOC_COPYRIGHT ), | |
| 157 | Setting.of( label( KEY_DOC_COPYRIGHT ) ), | |
| 158 | Setting.of( title( KEY_DOC_COPYRIGHT ), | |
| 159 | stringProperty( KEY_DOC_COPYRIGHT ) ) | |
| 160 | ), | |
| 161 | Group.of( | |
| 162 | get( KEY_DOC_DATE ), | |
| 163 | Setting.of( label( KEY_DOC_DATE ) ), | |
| 164 | Setting.of( title( KEY_DOC_DATE ), | |
| 165 | stringProperty( KEY_DOC_DATE ) ) | |
| 166 | ) | |
| 167 | ), | |
| 168 | Category.of( | |
| 169 | get( KEY_TYPESET ), | |
| 170 | Group.of( | |
| 171 | get( KEY_TYPESET_CONTEXT ), | |
| 172 | Setting.of( label( KEY_TYPESET_CONTEXT_THEMES_PATH ) ), | |
| 173 | Setting.of( title( KEY_TYPESET_CONTEXT_THEMES_PATH ), | |
| 174 | fileProperty( KEY_TYPESET_CONTEXT_THEMES_PATH ), true ), | |
| 175 | Setting.of( label( KEY_TYPESET_CONTEXT_CLEAN ) ), | |
| 176 | Setting.of( title( KEY_TYPESET_CONTEXT_CLEAN ), | |
| 177 | booleanProperty( KEY_TYPESET_CONTEXT_CLEAN ) ) | |
| 178 | ), | |
| 179 | Group.of( | |
| 180 | get( KEY_TYPESET_TYPOGRAPHY ), | |
| 181 | Setting.of( label( KEY_TYPESET_TYPOGRAPHY_QUOTES ) ), | |
| 182 | Setting.of( title( KEY_TYPESET_TYPOGRAPHY_QUOTES ), | |
| 183 | booleanProperty( KEY_TYPESET_TYPOGRAPHY_QUOTES ) ) | |
| 184 | ) | |
| 185 | ), | |
| 186 | Category.of( | |
| 187 | get( KEY_R ), | |
| 188 | Group.of( | |
| 189 | get( KEY_R_DIR ), | |
| 190 | Setting.of( label( KEY_R_DIR, | |
| 191 | stringProperty( KEY_DEF_DELIM_BEGAN ).get(), | |
| 192 | stringProperty( KEY_DEF_DELIM_ENDED ).get() ) ), | |
| 193 | Setting.of( title( KEY_R_DIR ), | |
| 194 | fileProperty( KEY_R_DIR ), true ) | |
| 195 | ), | |
| 196 | Group.of( | |
| 197 | get( KEY_R_SCRIPT ), | |
| 198 | Setting.of( label( KEY_R_SCRIPT ) ), | |
| 199 | createMultilineSetting( "Script", KEY_R_SCRIPT ) | |
| 200 | ), | |
| 201 | Group.of( | |
| 202 | get( KEY_R_DELIM_BEGAN ), | |
| 203 | Setting.of( label( KEY_R_DELIM_BEGAN ) ), | |
| 204 | Setting.of( title( KEY_R_DELIM_BEGAN ), | |
| 205 | stringProperty( KEY_R_DELIM_BEGAN ) ) | |
| 206 | ), | |
| 207 | Group.of( | |
| 208 | get( KEY_R_DELIM_ENDED ), | |
| 209 | Setting.of( label( KEY_R_DELIM_ENDED ) ), | |
| 210 | Setting.of( title( KEY_R_DELIM_ENDED ), | |
| 211 | stringProperty( KEY_R_DELIM_ENDED ) ) | |
| 212 | ) | |
| 213 | ), | |
| 214 | Category.of( | |
| 215 | get( KEY_IMAGES ), | |
| 216 | Group.of( | |
| 217 | get( KEY_IMAGES_DIR ), | |
| 218 | Setting.of( label( KEY_IMAGES_DIR ) ), | |
| 219 | Setting.of( title( KEY_IMAGES_DIR ), | |
| 220 | fileProperty( KEY_IMAGES_DIR ), true ) | |
| 221 | ), | |
| 222 | Group.of( | |
| 223 | get( KEY_IMAGES_ORDER ), | |
| 224 | Setting.of( label( KEY_IMAGES_ORDER ) ), | |
| 225 | Setting.of( title( KEY_IMAGES_ORDER ), | |
| 226 | stringProperty( KEY_IMAGES_ORDER ) ) | |
| 227 | ), | |
| 228 | Group.of( | |
| 229 | get( KEY_IMAGES_RESIZE ), | |
| 230 | Setting.of( label( KEY_IMAGES_RESIZE ) ), | |
| 231 | Setting.of( title( KEY_IMAGES_RESIZE ), | |
| 232 | booleanProperty( KEY_IMAGES_RESIZE ) ) | |
| 233 | ), | |
| 234 | Group.of( | |
| 235 | get( KEY_IMAGES_SERVER ), | |
| 236 | Setting.of( label( KEY_IMAGES_SERVER ) ), | |
| 237 | Setting.of( title( KEY_IMAGES_SERVER ), | |
| 238 | stringProperty( KEY_IMAGES_SERVER ) ) | |
| 239 | ) | |
| 240 | ), | |
| 241 | Category.of( | |
| 242 | get( KEY_DEF ), | |
| 243 | Group.of( | |
| 244 | get( KEY_DEF_PATH ), | |
| 245 | Setting.of( label( KEY_DEF_PATH ) ), | |
| 246 | Setting.of( title( KEY_DEF_PATH ), | |
| 247 | fileProperty( KEY_DEF_PATH ), false ) | |
| 248 | ), | |
| 249 | Group.of( | |
| 250 | get( KEY_DEF_DELIM_BEGAN ), | |
| 251 | Setting.of( label( KEY_DEF_DELIM_BEGAN ) ), | |
| 252 | Setting.of( title( KEY_DEF_DELIM_BEGAN ), | |
| 253 | stringProperty( KEY_DEF_DELIM_BEGAN ) ) | |
| 254 | ), | |
| 255 | Group.of( | |
| 256 | get( KEY_DEF_DELIM_ENDED ), | |
| 257 | Setting.of( label( KEY_DEF_DELIM_ENDED ) ), | |
| 258 | Setting.of( title( KEY_DEF_DELIM_ENDED ), | |
| 259 | stringProperty( KEY_DEF_DELIM_ENDED ) ) | |
| 260 | ) | |
| 261 | ), | |
| 262 | Category.of( | |
| 263 | get( KEY_UI_FONT ), | |
| 264 | Group.of( | |
| 265 | get( KEY_UI_FONT_EDITOR ), | |
| 266 | Setting.of( label( KEY_UI_FONT_EDITOR_NAME ) ), | |
| 267 | Setting.of( title( KEY_UI_FONT_EDITOR_NAME ), | |
| 268 | createFontNameField( | |
| 269 | stringProperty( KEY_UI_FONT_EDITOR_NAME ), | |
| 270 | doubleProperty( KEY_UI_FONT_EDITOR_SIZE ) ), | |
| 271 | stringProperty( KEY_UI_FONT_EDITOR_NAME ) ), | |
| 272 | Setting.of( label( KEY_UI_FONT_EDITOR_SIZE ) ), | |
| 273 | Setting.of( title( KEY_UI_FONT_EDITOR_SIZE ), | |
| 274 | doubleProperty( KEY_UI_FONT_EDITOR_SIZE ) ) | |
| 275 | ), | |
| 276 | Group.of( | |
| 277 | get( KEY_UI_FONT_PREVIEW ), | |
| 278 | Setting.of( label( KEY_UI_FONT_PREVIEW_NAME ) ), | |
| 279 | Setting.of( title( KEY_UI_FONT_PREVIEW_NAME ), | |
| 280 | createFontNameField( | |
| 281 | stringProperty( KEY_UI_FONT_PREVIEW_NAME ), | |
| 282 | doubleProperty( KEY_UI_FONT_PREVIEW_SIZE ) ), | |
| 283 | stringProperty( KEY_UI_FONT_PREVIEW_NAME ) ), | |
| 284 | Setting.of( label( KEY_UI_FONT_PREVIEW_SIZE ) ), | |
| 285 | Setting.of( title( KEY_UI_FONT_PREVIEW_SIZE ), | |
| 286 | doubleProperty( KEY_UI_FONT_PREVIEW_SIZE ) ), | |
| 287 | Setting.of( label( KEY_UI_FONT_PREVIEW_MONO_NAME ) ), | |
| 288 | Setting.of( title( KEY_UI_FONT_PREVIEW_MONO_NAME ), | |
| 289 | createFontNameField( | |
| 290 | stringProperty( KEY_UI_FONT_PREVIEW_MONO_NAME ), | |
| 291 | doubleProperty( KEY_UI_FONT_PREVIEW_MONO_SIZE ) ), | |
| 292 | stringProperty( KEY_UI_FONT_PREVIEW_MONO_NAME ) ), | |
| 293 | Setting.of( label( KEY_UI_FONT_PREVIEW_MONO_SIZE ) ), | |
| 294 | Setting.of( title( KEY_UI_FONT_PREVIEW_MONO_SIZE ), | |
| 295 | doubleProperty( KEY_UI_FONT_PREVIEW_MONO_SIZE ) ) | |
| 296 | ) | |
| 297 | ), | |
| 298 | Category.of( | |
| 299 | get( KEY_UI_SKIN ), | |
| 300 | Group.of( | |
| 301 | get( KEY_UI_SKIN_SELECTION ), | |
| 302 | Setting.of( label( KEY_UI_SKIN_SELECTION ) ), | |
| 303 | Setting.of( title( KEY_UI_SKIN_SELECTION ), | |
| 304 | skinListProperty(), | |
| 305 | skinProperty( KEY_UI_SKIN_SELECTION ) ) | |
| 306 | ), | |
| 307 | Group.of( | |
| 308 | get( KEY_UI_SKIN_CUSTOM ), | |
| 309 | Setting.of( label( KEY_UI_SKIN_CUSTOM ) ), | |
| 310 | Setting.of( title( KEY_UI_SKIN_CUSTOM ), | |
| 311 | fileProperty( KEY_UI_SKIN_CUSTOM ), false ) | |
| 312 | ) | |
| 313 | ), | |
| 314 | Category.of( | |
| 315 | get( KEY_LANGUAGE ), | |
| 316 | Group.of( | |
| 317 | get( KEY_LANGUAGE_LOCALE ), | |
| 318 | Setting.of( label( KEY_LANGUAGE_LOCALE ) ), | |
| 319 | Setting.of( title( KEY_LANGUAGE_LOCALE ), | |
| 320 | localeListProperty(), | |
| 321 | localeProperty( KEY_LANGUAGE_LOCALE ) ) | |
| 322 | ) | |
| 323 | )}; | |
| 324 | } | |
| 325 | ||
| 326 | @SuppressWarnings( "unchecked" ) | |
| 327 | private Setting<StringField, StringProperty> createMultilineSetting( | |
| 328 | final String description, final Key property ) { | |
| 329 | final Setting<StringField, StringProperty> setting = | |
| 330 | Setting.of( description, stringProperty( property ) ); | |
| 331 | final var field = setting.getElement(); | |
| 332 | field.multiline( true ); | |
| 333 | ||
| 334 | return setting; | |
| 335 | } | |
| 336 | ||
| 337 | private void initKeyEventHandler( final PreferencesFx preferences ) { | |
| 338 | final var view = preferences.getView(); | |
| 339 | final var nodes = view.getChildrenUnmodifiable(); | |
| 340 | final var master = (MasterDetailPane) nodes.get( 0 ); | |
| 341 | final var detail = (NavigationView) master.getDetailNode(); | |
| 342 | final var pane = (DialogPane) view.getParent(); | |
| 343 | ||
| 344 | detail.setOnKeyReleased( ( key ) -> { | |
| 345 | switch( key.getCode() ) { | |
| 346 | case ENTER -> ((Button) pane.lookupButton( OK )).fire(); | |
| 347 | case ESCAPE -> ((Button) pane.lookupButton( CANCEL )).fire(); | |
| 348 | } | |
| 349 | } ); | |
| 350 | } | |
| 351 | ||
| 352 | /** | |
| 353 | * Creates a label for the given key after interpolating its value. | |
| 354 | * | |
| 355 | * @param key The key to find in the resource bundle. | |
| 356 | * @return The value of the key as a label. | |
| 357 | */ | |
| 358 | private Node label( final Key key ) { | |
| 359 | return label( key, (String[]) null ); | |
| 360 | } | |
| 361 | ||
| 362 | private Node label( final Key key, final String... values ) { | |
| 363 | return new Label( get( key.toString() + ".desc", (Object[]) values ) ); | |
| 364 | } | |
| 365 | ||
| 366 | private String title( final Key key ) { | |
| 367 | return get( key.toString() + ".title" ); | |
| 368 | } | |
| 369 | ||
| 370 | private ObjectProperty<File> fileProperty( final Key key ) { | |
| 371 | return mWorkspace.fileProperty( key ); | |
| 372 | } | |
| 373 | ||
| 374 | private StringProperty stringProperty( final Key key ) { | |
| 375 | return mWorkspace.stringProperty( key ); | |
| 376 | } | |
| 377 | ||
| 378 | private BooleanProperty booleanProperty( final Key key ) { | |
| 379 | return mWorkspace.booleanProperty( key ); | |
| 12 | import javafx.beans.property.*; | |
| 13 | import javafx.event.EventHandler; | |
| 14 | import javafx.scene.Node; | |
| 15 | import javafx.scene.control.Button; | |
| 16 | import javafx.scene.control.DialogPane; | |
| 17 | import javafx.scene.control.Label; | |
| 18 | import org.controlsfx.control.MasterDetailPane; | |
| 19 | ||
| 20 | import java.io.File; | |
| 21 | ||
| 22 | import static com.dlsc.formsfx.model.structure.Field.ofStringType; | |
| 23 | import static com.dlsc.preferencesfx.PreferencesFxEvent.EVENT_PREFERENCES_SAVED; | |
| 24 | import static com.keenwrite.Messages.get; | |
| 25 | import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG; | |
| 26 | import static com.keenwrite.preferences.LocaleProperty.localeListProperty; | |
| 27 | import static com.keenwrite.preferences.SkinProperty.skinListProperty; | |
| 28 | import static com.keenwrite.preferences.WorkspaceKeys.*; | |
| 29 | import static javafx.scene.control.ButtonType.CANCEL; | |
| 30 | import static javafx.scene.control.ButtonType.OK; | |
| 31 | ||
| 32 | /** | |
| 33 | * Provides the ability for users to configure their preferences. This links | |
| 34 | * the {@link Workspace} model with the {@link PreferencesFx} view, in MVC. | |
| 35 | */ | |
| 36 | @SuppressWarnings( "SameParameterValue" ) | |
| 37 | public final class PreferencesController { | |
| 38 | ||
| 39 | private final Workspace mWorkspace; | |
| 40 | private final PreferencesFx mPreferencesFx; | |
| 41 | ||
| 42 | public PreferencesController( final Workspace workspace ) { | |
| 43 | mWorkspace = workspace; | |
| 44 | ||
| 45 | // All properties must be initialized before creating the dialog. | |
| 46 | mPreferencesFx = createPreferencesFx(); | |
| 47 | ||
| 48 | initKeyEventHandler( mPreferencesFx ); | |
| 49 | } | |
| 50 | ||
| 51 | /** | |
| 52 | * Display the user preferences settings dialog (non-modal). | |
| 53 | */ | |
| 54 | public void show() { | |
| 55 | getPreferencesFx().show( false ); | |
| 56 | } | |
| 57 | ||
| 58 | /** | |
| 59 | * Call to persist the settings. Strictly speaking, this could watch on | |
| 60 | * all values for external changes then save automatically. | |
| 61 | */ | |
| 62 | public void save() { | |
| 63 | getPreferencesFx().saveSettings(); | |
| 64 | } | |
| 65 | ||
| 66 | /** | |
| 67 | * Delegates to the {@link PreferencesFx} event handler for monitoring | |
| 68 | * save events. | |
| 69 | * | |
| 70 | * @param eventHandler The handler to call when the preferences are saved. | |
| 71 | */ | |
| 72 | public void addSaveEventHandler( | |
| 73 | final EventHandler<? super PreferencesFxEvent> eventHandler ) { | |
| 74 | getPreferencesFx().addEventHandler( EVENT_PREFERENCES_SAVED, eventHandler ); | |
| 75 | } | |
| 76 | ||
| 77 | private StringField createFontNameField( | |
| 78 | final StringProperty fontName, final DoubleProperty fontSize ) { | |
| 79 | final var control = new SimpleFontControl( "Change" ); | |
| 80 | control.fontSizeProperty().addListener( ( c, o, n ) -> { | |
| 81 | if( n != null ) { | |
| 82 | fontSize.set( n.doubleValue() ); | |
| 83 | } | |
| 84 | } ); | |
| 85 | return ofStringType( fontName ).render( control ); | |
| 86 | } | |
| 87 | ||
| 88 | /** | |
| 89 | * Creates the preferences dialog based using {@link XmlStorageHandler} and | |
| 90 | * numerous {@link Category} objects. | |
| 91 | * | |
| 92 | * @return A component for editing preferences. | |
| 93 | * @throws RuntimeException Could not construct the {@link PreferencesFx} | |
| 94 | * object (e.g., illegal access permissions, | |
| 95 | * unmapped XML resource). | |
| 96 | */ | |
| 97 | private PreferencesFx createPreferencesFx() { | |
| 98 | return PreferencesFx.of( createStorageHandler(), createCategories() ) | |
| 99 | .instantPersistent( false ) | |
| 100 | .dialogIcon( ICON_DIALOG ); | |
| 101 | } | |
| 102 | ||
| 103 | private StorageHandler createStorageHandler() { | |
| 104 | return new XmlStorageHandler(); | |
| 105 | } | |
| 106 | ||
| 107 | private Category[] createCategories() { | |
| 108 | return new Category[]{ | |
| 109 | Category.of( | |
| 110 | get( KEY_DOC ), | |
| 111 | Group.of( | |
| 112 | get( KEY_DOC_TITLE ), | |
| 113 | Setting.of( label( KEY_DOC_TITLE ) ), | |
| 114 | Setting.of( title( KEY_DOC_TITLE ), | |
| 115 | stringProperty( KEY_DOC_TITLE ) ) | |
| 116 | ), | |
| 117 | Group.of( | |
| 118 | get( KEY_DOC_AUTHOR ), | |
| 119 | Setting.of( label( KEY_DOC_AUTHOR ) ), | |
| 120 | Setting.of( title( KEY_DOC_AUTHOR ), | |
| 121 | stringProperty( KEY_DOC_AUTHOR ) ) | |
| 122 | ), | |
| 123 | Group.of( | |
| 124 | get( KEY_DOC_BYLINE ), | |
| 125 | Setting.of( label( KEY_DOC_BYLINE ) ), | |
| 126 | Setting.of( title( KEY_DOC_BYLINE ), | |
| 127 | stringProperty( KEY_DOC_BYLINE ) ) | |
| 128 | ), | |
| 129 | Group.of( | |
| 130 | get( KEY_DOC_ADDRESS ), | |
| 131 | Setting.of( label( KEY_DOC_ADDRESS ) ), | |
| 132 | createMultilineSetting( "Address", KEY_DOC_ADDRESS ) | |
| 133 | ), | |
| 134 | Group.of( | |
| 135 | get( KEY_DOC_PHONE ), | |
| 136 | Setting.of( label( KEY_DOC_PHONE ) ), | |
| 137 | Setting.of( title( KEY_DOC_PHONE ), | |
| 138 | stringProperty( KEY_DOC_PHONE ) ) | |
| 139 | ), | |
| 140 | Group.of( | |
| 141 | get( KEY_DOC_EMAIL ), | |
| 142 | Setting.of( label( KEY_DOC_EMAIL ) ), | |
| 143 | Setting.of( title( KEY_DOC_EMAIL ), | |
| 144 | stringProperty( KEY_DOC_EMAIL ) ) | |
| 145 | ), | |
| 146 | Group.of( | |
| 147 | get( KEY_DOC_KEYWORDS ), | |
| 148 | Setting.of( label( KEY_DOC_KEYWORDS ) ), | |
| 149 | Setting.of( title( KEY_DOC_KEYWORDS ), | |
| 150 | stringProperty( KEY_DOC_KEYWORDS ) ) | |
| 151 | ), | |
| 152 | Group.of( | |
| 153 | get( KEY_DOC_COPYRIGHT ), | |
| 154 | Setting.of( label( KEY_DOC_COPYRIGHT ) ), | |
| 155 | Setting.of( title( KEY_DOC_COPYRIGHT ), | |
| 156 | stringProperty( KEY_DOC_COPYRIGHT ) ) | |
| 157 | ), | |
| 158 | Group.of( | |
| 159 | get( KEY_DOC_DATE ), | |
| 160 | Setting.of( label( KEY_DOC_DATE ) ), | |
| 161 | Setting.of( title( KEY_DOC_DATE ), | |
| 162 | stringProperty( KEY_DOC_DATE ) ) | |
| 163 | ) | |
| 164 | ), | |
| 165 | Category.of( | |
| 166 | get( KEY_TYPESET ), | |
| 167 | Group.of( | |
| 168 | get( KEY_TYPESET_CONTEXT ), | |
| 169 | Setting.of( label( KEY_TYPESET_CONTEXT_THEMES_PATH ) ), | |
| 170 | Setting.of( title( KEY_TYPESET_CONTEXT_THEMES_PATH ), | |
| 171 | fileProperty( KEY_TYPESET_CONTEXT_THEMES_PATH ), true ), | |
| 172 | Setting.of( label( KEY_TYPESET_CONTEXT_CLEAN ) ), | |
| 173 | Setting.of( title( KEY_TYPESET_CONTEXT_CLEAN ), | |
| 174 | booleanProperty( KEY_TYPESET_CONTEXT_CLEAN ) ) | |
| 175 | ), | |
| 176 | Group.of( | |
| 177 | get( KEY_TYPESET_TYPOGRAPHY ), | |
| 178 | Setting.of( label( KEY_TYPESET_TYPOGRAPHY_QUOTES ) ), | |
| 179 | Setting.of( title( KEY_TYPESET_TYPOGRAPHY_QUOTES ), | |
| 180 | booleanProperty( KEY_TYPESET_TYPOGRAPHY_QUOTES ) ) | |
| 181 | ) | |
| 182 | ), | |
| 183 | Category.of( | |
| 184 | get( KEY_EDITOR ), | |
| 185 | Group.of( | |
| 186 | get( KEY_EDITOR_AUTOSAVE ), | |
| 187 | Setting.of( label( KEY_EDITOR_AUTOSAVE ) ), | |
| 188 | Setting.of( title( KEY_EDITOR_AUTOSAVE ), | |
| 189 | integerProperty( KEY_EDITOR_AUTOSAVE ) ) | |
| 190 | ) | |
| 191 | ), | |
| 192 | Category.of( | |
| 193 | get( KEY_R ), | |
| 194 | Group.of( | |
| 195 | get( KEY_R_DIR ), | |
| 196 | Setting.of( label( KEY_R_DIR, | |
| 197 | stringProperty( KEY_DEF_DELIM_BEGAN ).get(), | |
| 198 | stringProperty( KEY_DEF_DELIM_ENDED ).get() ) ), | |
| 199 | Setting.of( title( KEY_R_DIR ), | |
| 200 | fileProperty( KEY_R_DIR ), true ) | |
| 201 | ), | |
| 202 | Group.of( | |
| 203 | get( KEY_R_SCRIPT ), | |
| 204 | Setting.of( label( KEY_R_SCRIPT ) ), | |
| 205 | createMultilineSetting( "Script", KEY_R_SCRIPT ) | |
| 206 | ), | |
| 207 | Group.of( | |
| 208 | get( KEY_R_DELIM_BEGAN ), | |
| 209 | Setting.of( label( KEY_R_DELIM_BEGAN ) ), | |
| 210 | Setting.of( title( KEY_R_DELIM_BEGAN ), | |
| 211 | stringProperty( KEY_R_DELIM_BEGAN ) ) | |
| 212 | ), | |
| 213 | Group.of( | |
| 214 | get( KEY_R_DELIM_ENDED ), | |
| 215 | Setting.of( label( KEY_R_DELIM_ENDED ) ), | |
| 216 | Setting.of( title( KEY_R_DELIM_ENDED ), | |
| 217 | stringProperty( KEY_R_DELIM_ENDED ) ) | |
| 218 | ) | |
| 219 | ), | |
| 220 | Category.of( | |
| 221 | get( KEY_IMAGES ), | |
| 222 | Group.of( | |
| 223 | get( KEY_IMAGES_DIR ), | |
| 224 | Setting.of( label( KEY_IMAGES_DIR ) ), | |
| 225 | Setting.of( title( KEY_IMAGES_DIR ), | |
| 226 | fileProperty( KEY_IMAGES_DIR ), true ) | |
| 227 | ), | |
| 228 | Group.of( | |
| 229 | get( KEY_IMAGES_ORDER ), | |
| 230 | Setting.of( label( KEY_IMAGES_ORDER ) ), | |
| 231 | Setting.of( title( KEY_IMAGES_ORDER ), | |
| 232 | stringProperty( KEY_IMAGES_ORDER ) ) | |
| 233 | ), | |
| 234 | Group.of( | |
| 235 | get( KEY_IMAGES_RESIZE ), | |
| 236 | Setting.of( label( KEY_IMAGES_RESIZE ) ), | |
| 237 | Setting.of( title( KEY_IMAGES_RESIZE ), | |
| 238 | booleanProperty( KEY_IMAGES_RESIZE ) ) | |
| 239 | ), | |
| 240 | Group.of( | |
| 241 | get( KEY_IMAGES_SERVER ), | |
| 242 | Setting.of( label( KEY_IMAGES_SERVER ) ), | |
| 243 | Setting.of( title( KEY_IMAGES_SERVER ), | |
| 244 | stringProperty( KEY_IMAGES_SERVER ) ) | |
| 245 | ) | |
| 246 | ), | |
| 247 | Category.of( | |
| 248 | get( KEY_DEF ), | |
| 249 | Group.of( | |
| 250 | get( KEY_DEF_PATH ), | |
| 251 | Setting.of( label( KEY_DEF_PATH ) ), | |
| 252 | Setting.of( title( KEY_DEF_PATH ), | |
| 253 | fileProperty( KEY_DEF_PATH ), false ) | |
| 254 | ), | |
| 255 | Group.of( | |
| 256 | get( KEY_DEF_DELIM_BEGAN ), | |
| 257 | Setting.of( label( KEY_DEF_DELIM_BEGAN ) ), | |
| 258 | Setting.of( title( KEY_DEF_DELIM_BEGAN ), | |
| 259 | stringProperty( KEY_DEF_DELIM_BEGAN ) ) | |
| 260 | ), | |
| 261 | Group.of( | |
| 262 | get( KEY_DEF_DELIM_ENDED ), | |
| 263 | Setting.of( label( KEY_DEF_DELIM_ENDED ) ), | |
| 264 | Setting.of( title( KEY_DEF_DELIM_ENDED ), | |
| 265 | stringProperty( KEY_DEF_DELIM_ENDED ) ) | |
| 266 | ) | |
| 267 | ), | |
| 268 | Category.of( | |
| 269 | get( KEY_UI_FONT ), | |
| 270 | Group.of( | |
| 271 | get( KEY_UI_FONT_EDITOR ), | |
| 272 | Setting.of( label( KEY_UI_FONT_EDITOR_NAME ) ), | |
| 273 | Setting.of( title( KEY_UI_FONT_EDITOR_NAME ), | |
| 274 | createFontNameField( | |
| 275 | stringProperty( KEY_UI_FONT_EDITOR_NAME ), | |
| 276 | doubleProperty( KEY_UI_FONT_EDITOR_SIZE ) ), | |
| 277 | stringProperty( KEY_UI_FONT_EDITOR_NAME ) ), | |
| 278 | Setting.of( label( KEY_UI_FONT_EDITOR_SIZE ) ), | |
| 279 | Setting.of( title( KEY_UI_FONT_EDITOR_SIZE ), | |
| 280 | doubleProperty( KEY_UI_FONT_EDITOR_SIZE ) ) | |
| 281 | ), | |
| 282 | Group.of( | |
| 283 | get( KEY_UI_FONT_PREVIEW ), | |
| 284 | Setting.of( label( KEY_UI_FONT_PREVIEW_NAME ) ), | |
| 285 | Setting.of( title( KEY_UI_FONT_PREVIEW_NAME ), | |
| 286 | createFontNameField( | |
| 287 | stringProperty( KEY_UI_FONT_PREVIEW_NAME ), | |
| 288 | doubleProperty( KEY_UI_FONT_PREVIEW_SIZE ) ), | |
| 289 | stringProperty( KEY_UI_FONT_PREVIEW_NAME ) ), | |
| 290 | Setting.of( label( KEY_UI_FONT_PREVIEW_SIZE ) ), | |
| 291 | Setting.of( title( KEY_UI_FONT_PREVIEW_SIZE ), | |
| 292 | doubleProperty( KEY_UI_FONT_PREVIEW_SIZE ) ), | |
| 293 | Setting.of( label( KEY_UI_FONT_PREVIEW_MONO_NAME ) ), | |
| 294 | Setting.of( title( KEY_UI_FONT_PREVIEW_MONO_NAME ), | |
| 295 | createFontNameField( | |
| 296 | stringProperty( KEY_UI_FONT_PREVIEW_MONO_NAME ), | |
| 297 | doubleProperty( KEY_UI_FONT_PREVIEW_MONO_SIZE ) ), | |
| 298 | stringProperty( KEY_UI_FONT_PREVIEW_MONO_NAME ) ), | |
| 299 | Setting.of( label( KEY_UI_FONT_PREVIEW_MONO_SIZE ) ), | |
| 300 | Setting.of( title( KEY_UI_FONT_PREVIEW_MONO_SIZE ), | |
| 301 | doubleProperty( KEY_UI_FONT_PREVIEW_MONO_SIZE ) ) | |
| 302 | ) | |
| 303 | ), | |
| 304 | Category.of( | |
| 305 | get( KEY_UI_SKIN ), | |
| 306 | Group.of( | |
| 307 | get( KEY_UI_SKIN_SELECTION ), | |
| 308 | Setting.of( label( KEY_UI_SKIN_SELECTION ) ), | |
| 309 | Setting.of( title( KEY_UI_SKIN_SELECTION ), | |
| 310 | skinListProperty(), | |
| 311 | skinProperty( KEY_UI_SKIN_SELECTION ) ) | |
| 312 | ), | |
| 313 | Group.of( | |
| 314 | get( KEY_UI_SKIN_CUSTOM ), | |
| 315 | Setting.of( label( KEY_UI_SKIN_CUSTOM ) ), | |
| 316 | Setting.of( title( KEY_UI_SKIN_CUSTOM ), | |
| 317 | fileProperty( KEY_UI_SKIN_CUSTOM ), false ) | |
| 318 | ) | |
| 319 | ), | |
| 320 | Category.of( | |
| 321 | get( KEY_UI_PREVIEW ), | |
| 322 | Group.of( | |
| 323 | get( KEY_UI_PREVIEW_STYLESHEET ), | |
| 324 | Setting.of( label( KEY_UI_PREVIEW_STYLESHEET ) ), | |
| 325 | Setting.of( title( KEY_UI_PREVIEW_STYLESHEET ), | |
| 326 | fileProperty( KEY_UI_PREVIEW_STYLESHEET ), false ) | |
| 327 | ) | |
| 328 | ), | |
| 329 | Category.of( | |
| 330 | get( KEY_LANGUAGE ), | |
| 331 | Group.of( | |
| 332 | get( KEY_LANGUAGE_LOCALE ), | |
| 333 | Setting.of( label( KEY_LANGUAGE_LOCALE ) ), | |
| 334 | Setting.of( title( KEY_LANGUAGE_LOCALE ), | |
| 335 | localeListProperty(), | |
| 336 | localeProperty( KEY_LANGUAGE_LOCALE ) ) | |
| 337 | ) | |
| 338 | )}; | |
| 339 | } | |
| 340 | ||
| 341 | @SuppressWarnings( "unchecked" ) | |
| 342 | private Setting<StringField, StringProperty> createMultilineSetting( | |
| 343 | final String description, final Key property ) { | |
| 344 | final Setting<StringField, StringProperty> setting = | |
| 345 | Setting.of( description, stringProperty( property ) ); | |
| 346 | final var field = setting.getElement(); | |
| 347 | field.multiline( true ); | |
| 348 | ||
| 349 | return setting; | |
| 350 | } | |
| 351 | ||
| 352 | private void initKeyEventHandler( final PreferencesFx preferences ) { | |
| 353 | final var view = preferences.getView(); | |
| 354 | final var nodes = view.getChildrenUnmodifiable(); | |
| 355 | final var master = (MasterDetailPane) nodes.get( 0 ); | |
| 356 | final var detail = (NavigationView) master.getDetailNode(); | |
| 357 | final var pane = (DialogPane) view.getParent(); | |
| 358 | ||
| 359 | detail.setOnKeyReleased( ( key ) -> { | |
| 360 | switch( key.getCode() ) { | |
| 361 | case ENTER -> ((Button) pane.lookupButton( OK )).fire(); | |
| 362 | case ESCAPE -> ((Button) pane.lookupButton( CANCEL )).fire(); | |
| 363 | } | |
| 364 | } ); | |
| 365 | } | |
| 366 | ||
| 367 | /** | |
| 368 | * Creates a label for the given key after interpolating its value. | |
| 369 | * | |
| 370 | * @param key The key to find in the resource bundle. | |
| 371 | * @return The value of the key as a label. | |
| 372 | */ | |
| 373 | private Node label( final Key key ) { | |
| 374 | return label( key, (String[]) null ); | |
| 375 | } | |
| 376 | ||
| 377 | private Node label( final Key key, final String... values ) { | |
| 378 | return new Label( get( key.toString() + ".desc", (Object[]) values ) ); | |
| 379 | } | |
| 380 | ||
| 381 | private String title( final Key key ) { | |
| 382 | return get( key.toString() + ".title" ); | |
| 383 | } | |
| 384 | ||
| 385 | private ObjectProperty<File> fileProperty( final Key key ) { | |
| 386 | return mWorkspace.fileProperty( key ); | |
| 387 | } | |
| 388 | ||
| 389 | private StringProperty stringProperty( final Key key ) { | |
| 390 | return mWorkspace.stringProperty( key ); | |
| 391 | } | |
| 392 | ||
| 393 | private BooleanProperty booleanProperty( final Key key ) { | |
| 394 | return mWorkspace.booleanProperty( key ); | |
| 395 | } | |
| 396 | ||
| 397 | @SuppressWarnings( "SameParameterValue" ) | |
| 398 | private IntegerProperty integerProperty( final Key key ) { | |
| 399 | return mWorkspace.integerProperty( key ); | |
| 380 | 400 | } |
| 381 | 401 |
| 80 | 80 | entry( KEY_DOC_DATE, asStringProperty( getDate() ) ), |
| 81 | 81 | |
| 82 | entry( KEY_EDITOR_AUTOSAVE, asIntegerProperty( 30 ) ), | |
| 83 | ||
| 82 | 84 | entry( KEY_R_SCRIPT, asStringProperty( "" ) ), |
| 83 | 85 | entry( KEY_R_DIR, asFileProperty( USER_DIRECTORY ) ), |
| ... | ||
| 115 | 117 | entry( KEY_UI_SKIN_SELECTION, asSkinProperty( SKIN_DEFAULT ) ), |
| 116 | 118 | entry( KEY_UI_SKIN_CUSTOM, asFileProperty( SKIN_CUSTOM_DEFAULT ) ), |
| 119 | ||
| 120 | entry( KEY_UI_PREVIEW_STYLESHEET, asFileProperty( PREVIEW_CUSTOM_DEFAULT ) ), | |
| 117 | 121 | |
| 118 | 122 | entry( KEY_LANGUAGE_LOCALE, asLocaleProperty( LOCALE_DEFAULT ) ), |
| ... | ||
| 127 | 131 | private StringProperty asStringProperty( final String defaultValue ) { |
| 128 | 132 | return new SimpleStringProperty( defaultValue ); |
| 133 | } | |
| 134 | ||
| 135 | @SuppressWarnings( "SameParameterValue" ) | |
| 136 | private IntegerProperty asIntegerProperty( final int defaultValue ) { | |
| 137 | return new SimpleIntegerProperty( defaultValue ); | |
| 129 | 138 | } |
| 130 | 139 | |
| ... | ||
| 163 | 172 | LocaleProperty.class, LocaleProperty::parseLocale, |
| 164 | 173 | SimpleBooleanProperty.class, Boolean::parseBoolean, |
| 174 | SimpleIntegerProperty.class, Integer::parseInt, | |
| 165 | 175 | SimpleDoubleProperty.class, Double::parseDouble, |
| 166 | 176 | SimpleFloatProperty.class, Float::parseFloat, |
| ... | ||
| 253 | 263 | assert key != null; |
| 254 | 264 | return (Boolean) valuesProperty( key ).getValue(); |
| 265 | } | |
| 266 | ||
| 267 | /** | |
| 268 | * Returns the {@link Integer} preference value associated with the given | |
| 269 | * {@link Key}. The caller must be sure that the given {@link Key} is | |
| 270 | * associated with a value that matches the return type. | |
| 271 | * | |
| 272 | * @param key The {@link Key} associated with a preference value. | |
| 273 | * @return The value associated with the given {@link Key}. | |
| 274 | */ | |
| 275 | public int toInteger( final Key key ) { | |
| 276 | assert key != null; | |
| 277 | return (Integer) valuesProperty( key ).getValue(); | |
| 255 | 278 | } |
| 256 | 279 | |
| ... | ||
| 282 | 305 | assert ended != null; |
| 283 | 306 | return new Tokens( stringProperty( began ), stringProperty( ended ) ); |
| 307 | } | |
| 308 | ||
| 309 | @SuppressWarnings( "SameParameterValue" ) | |
| 310 | public IntegerProperty integerProperty( final Key key ) { | |
| 311 | assert key != null; | |
| 312 | return valuesProperty( key ); | |
| 284 | 313 | } |
| 285 | 314 | |
| 27 | 27 | public static final Key KEY_DOC_COPYRIGHT = key( KEY_DOC, "copyright" ); |
| 28 | 28 | |
| 29 | public static final Key KEY_EDITOR = key( KEY_ROOT, "editor" ); | |
| 30 | public static final Key KEY_EDITOR_AUTOSAVE = key( KEY_EDITOR, "autosave" ); | |
| 31 | ||
| 29 | 32 | public static final Key KEY_R = key( KEY_ROOT, "r" ); |
| 30 | 33 | public static final Key KEY_R_SCRIPT = key( KEY_R, "script" ); |
| ... | ||
| 78 | 81 | public static final Key KEY_UI_SKIN_SELECTION = key( KEY_UI_SKIN, "selection" ); |
| 79 | 82 | public static final Key KEY_UI_SKIN_CUSTOM = key( KEY_UI_SKIN, "custom" ); |
| 83 | ||
| 84 | public static final Key KEY_UI_PREVIEW = key( KEY_UI, "preview" ); | |
| 85 | public static final Key KEY_UI_PREVIEW_STYLESHEET = key( KEY_UI_PREVIEW, "stylesheet" ); | |
| 80 | 86 | |
| 81 | 87 | public static final Key KEY_LANGUAGE = key( KEY_ROOT, "language" ); |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.preview; | |
| 3 | ||
| 4 | import java.util.zip.Deflater; | |
| 5 | ||
| 6 | import static java.lang.String.format; | |
| 7 | import static java.util.Base64.getUrlEncoder; | |
| 8 | ||
| 9 | /** | |
| 10 | * Responsible for transforming text-based diagram descriptions into URLs | |
| 11 | * that the HTML renderer can embed as SVG images. | |
| 12 | */ | |
| 13 | public class DiagramUrlGenerator { | |
| 14 | private DiagramUrlGenerator() { | |
| 15 | } | |
| 16 | ||
| 17 | /** | |
| 18 | * Returns a URL that can be embedded as the {@code src} attribute to an HTML | |
| 19 | * {@code img} tag. | |
| 20 | * | |
| 21 | * @param server Name of server to use for diagram conversion. | |
| 22 | * @param diagram Diagram type (e.g., Graphviz, Block, PlantUML). | |
| 23 | * @param text Diagram text that conforms to the diagram type. | |
| 24 | * @return A secure URL string to use as an image {@code src} attribute. | |
| 25 | */ | |
| 26 | public static String toUrl( | |
| 27 | final String server, final String diagram, final String text ) { | |
| 28 | return format( | |
| 29 | "https://%s/%s/svg/%s", server, diagram, encode( text ) | |
| 30 | ); | |
| 31 | } | |
| 32 | ||
| 33 | /** | |
| 34 | * Convert the plain-text version of the diagram into a URL-encoded value | |
| 35 | * suitable for passing to a web server using an HTTP GET request. | |
| 36 | * | |
| 37 | * @param text The diagram text to encode. | |
| 38 | * @return The URL-encoded (and compressed) version of the text. | |
| 39 | */ | |
| 40 | private static String encode( final String text ) { | |
| 41 | return getUrlEncoder().encodeToString( compress( text.getBytes() ) ); | |
| 42 | } | |
| 43 | ||
| 44 | /** | |
| 45 | * Compresses a sequence of bytes using ZLIB format. | |
| 46 | * | |
| 47 | * @param source The data to compress. | |
| 48 | * @return A lossless, compressed sequence of bytes. | |
| 49 | */ | |
| 50 | private static byte[] compress( byte[] source ) { | |
| 51 | final var deflater = new Deflater(); | |
| 52 | deflater.setInput( source ); | |
| 53 | deflater.finish(); | |
| 54 | ||
| 55 | final var compressed = new byte[ Short.MAX_VALUE ]; | |
| 56 | final var size = deflater.deflate( compressed ); | |
| 57 | final var result = new byte[ size ]; | |
| 58 | ||
| 59 | System.arraycopy( compressed, 0, result, 0, size ); | |
| 60 | ||
| 61 | return result; | |
| 62 | } | |
| 63 | } | |
| 1 | 64 |
| 61 | 61 | * <li>%s --- default stylesheet</li> |
| 62 | 62 | * <li>%s --- language-specific stylesheet</li> |
| 63 | * <li>%s --- font family</li> | |
| 64 | * <li>%d --- font size (must be pixels, not points due to bug)</li> | |
| 65 | * <li>%s --- base href</li> | |
| 66 | * </p> | |
| 67 | */ | |
| 68 | private static final String HTML_HEAD = | |
| 69 | """ | |
| 70 | <!doctype html> | |
| 71 | <html lang='%s'><head><title> </title><meta charset='utf-8'/> | |
| 72 | %s%s<style>body{font-family:'%s';font-size: %dpx;}</style>%s</head><body> | |
| 73 | """; | |
| 74 | ||
| 75 | private static final String HTML_TAIL = "</body></html>"; | |
| 76 | ||
| 77 | private static final URL HTML_STYLE_PREVIEW = toUrl( STYLESHEET_PREVIEW ); | |
| 78 | ||
| 79 | private final ChainedReplacedElementFactory mFactory; | |
| 80 | ||
| 81 | /** | |
| 82 | * Reusing this buffer prevents repetitious memory re-allocations. | |
| 83 | */ | |
| 84 | private final StringBuilder mDocument = new StringBuilder( 65536 ); | |
| 85 | ||
| 86 | private HtmlPanel mView; | |
| 87 | private JScrollPane mScrollPane; | |
| 88 | private String mBaseUriPath = ""; | |
| 89 | private String mHead = ""; | |
| 90 | ||
| 91 | private volatile boolean mLocked; | |
| 92 | private final JButton mScrollLockButton = new JButton(); | |
| 93 | private final Workspace mWorkspace; | |
| 94 | ||
| 95 | /** | |
| 96 | * Creates a new preview pane that can scroll to the caret position within the | |
| 97 | * document. | |
| 98 | * | |
| 99 | * @param workspace Contains locale and font size information. | |
| 100 | */ | |
| 101 | public HtmlPreview( final Workspace workspace ) { | |
| 102 | mWorkspace = workspace; | |
| 103 | ||
| 104 | // The order is important: SwingReplacedElementFactory replaces SVG images | |
| 105 | // with a blank image, which will cause the chained factory to cache the | |
| 106 | // image and exit. Instead, the SVG must execute first to rasterize the | |
| 107 | // content. Consequently, the chained factory must maintain insertion order. | |
| 108 | mFactory = new ChainedReplacedElementFactory( | |
| 109 | new SvgReplacedElementFactory(), | |
| 110 | new SwingReplacedElementFactory() | |
| 111 | ); | |
| 112 | ||
| 113 | // Attempts to prevent a flash of black un-styled content upon load. | |
| 114 | setStyle( "-fx-background-color: white;" ); | |
| 115 | ||
| 116 | invokeLater( () -> { | |
| 117 | mHead = generateHead(); | |
| 118 | mView = new HtmlPanel(); | |
| 119 | mScrollPane = new JScrollPane( mView ); | |
| 120 | final var verticalBar = mScrollPane.getVerticalScrollBar(); | |
| 121 | final var verticalPanel = new JPanel( new BorderLayout() ); | |
| 122 | ||
| 123 | final var map = verticalBar.getInputMap( WHEN_IN_FOCUSED_WINDOW ); | |
| 124 | addKeyboardEvents( map ); | |
| 125 | ||
| 126 | mScrollLockButton.setFont( getIconFont( 14 ) ); | |
| 127 | mScrollLockButton.setText( getLockText( mLocked ) ); | |
| 128 | mScrollLockButton.setMargin( new Insets( 1, 0, 0, 0 ) ); | |
| 129 | mScrollLockButton.addActionListener( e -> fireScrollLockEvent( !mLocked ) ); | |
| 130 | ||
| 131 | verticalPanel.add( verticalBar, CENTER ); | |
| 132 | verticalPanel.add( mScrollLockButton, PAGE_END ); | |
| 133 | ||
| 134 | final var wrapper = new JPanel( new BorderLayout() ); | |
| 135 | wrapper.add( mScrollPane, CENTER ); | |
| 136 | wrapper.add( verticalPanel, LINE_END ); | |
| 137 | ||
| 138 | // Enabling the cache attempts to prevent black flashes when resizing. | |
| 139 | setCache( true ); | |
| 140 | setCacheHint( SPEED ); | |
| 141 | setContent( wrapper ); | |
| 142 | wrapper.addComponentListener( this ); | |
| 143 | ||
| 144 | final var context = mView.getSharedContext(); | |
| 145 | final var textRenderer = context.getTextRenderer(); | |
| 146 | context.setReplacedElementFactory( mFactory ); | |
| 147 | textRenderer.setSmoothingThreshold( 0 ); | |
| 148 | ||
| 149 | localeProperty().addListener( ( c, o, n ) -> rerender() ); | |
| 150 | fontFamilyProperty().addListener( ( c, o, n ) -> rerender() ); | |
| 151 | fontSizeProperty().addListener( ( c, o, n ) -> rerender() ); | |
| 152 | } ); | |
| 153 | ||
| 154 | register( this ); | |
| 155 | } | |
| 156 | ||
| 157 | @Subscribe | |
| 158 | public void handle( final ScrollLockEvent event ) { | |
| 159 | mLocked = event.isLocked(); | |
| 160 | invokeLater( () -> mScrollLockButton.setText( getLockText( mLocked ) ) ); | |
| 161 | } | |
| 162 | ||
| 163 | /** | |
| 164 | * Updates the internal HTML source shown in the preview pane. | |
| 165 | * | |
| 166 | * @param html The new HTML document to display. | |
| 167 | */ | |
| 168 | public void render( final String html ) { | |
| 169 | mView.render( decorate( html ), getBaseUri() ); | |
| 170 | } | |
| 171 | ||
| 172 | /** | |
| 173 | * Clears the caches then re-renders the content. | |
| 174 | */ | |
| 175 | public void refresh() { | |
| 176 | mFactory.clearCache(); | |
| 177 | rerender(); | |
| 178 | } | |
| 179 | ||
| 180 | /** | |
| 181 | * Recomputes the HTML head then renders the document. | |
| 182 | */ | |
| 183 | private void rerender() { | |
| 184 | mHead = generateHead(); | |
| 185 | render( mDocument.toString() ); | |
| 186 | } | |
| 187 | ||
| 188 | /** | |
| 189 | * Attaches the HTML head prefix and HTML tail suffix to the given HTML | |
| 190 | * string. | |
| 191 | * | |
| 192 | * @param html The HTML to adorn with opening and closing tags. | |
| 193 | * @return A complete HTML document, ready for rendering. | |
| 194 | */ | |
| 195 | private String decorate( final String html ) { | |
| 196 | mDocument.setLength( 0 ); | |
| 197 | mDocument.append( html ); | |
| 198 | ||
| 199 | // Head and tail must be separate from document due to re-rendering. | |
| 200 | return mHead + mDocument + HTML_TAIL; | |
| 201 | } | |
| 202 | ||
| 203 | /** | |
| 204 | * Called when settings are changed that affect the HTML document preamble. | |
| 205 | * This is a minor performance optimization to avoid generating the head | |
| 206 | * each time that the document itself changes. | |
| 207 | * | |
| 208 | * @return A new doctype and HTML {@code head} element. | |
| 209 | */ | |
| 210 | private String generateHead() { | |
| 211 | final var locale = getLocale(); | |
| 212 | final var url = toUrl( locale ); | |
| 213 | final var base = getBaseUri(); | |
| 214 | ||
| 215 | // Point sizes are converted to pixels because of a rendering bug. | |
| 216 | return format( | |
| 217 | HTML_HEAD, | |
| 218 | locale.getLanguage(), | |
| 219 | format( HTML_STYLESHEET, HTML_STYLE_PREVIEW ), | |
| 220 | url == null ? "" : format( HTML_STYLESHEET, url ), | |
| 221 | getFontFamily(), | |
| 222 | toPixels( getFontSize() ), | |
| 223 | base.isBlank() ? "" : format( HTML_BASE, base ) | |
| 224 | ); | |
| 225 | } | |
| 226 | ||
| 227 | /** | |
| 228 | * Clears the preview pane by rendering an empty string. | |
| 229 | */ | |
| 230 | public void clear() { | |
| 231 | render( "" ); | |
| 232 | } | |
| 233 | ||
| 234 | /** | |
| 235 | * Sets the base URI to the containing directory the file being edited. | |
| 236 | * | |
| 237 | * @param path The path to the file being edited. | |
| 238 | */ | |
| 239 | public void setBaseUri( final Path path ) { | |
| 240 | final var parent = path.getParent(); | |
| 241 | mBaseUriPath = parent == null ? "" : parent.toUri().toString(); | |
| 242 | } | |
| 243 | ||
| 244 | /** | |
| 245 | * Scrolls to the closest element matching the given identifier without | |
| 246 | * waiting for the document to be ready. | |
| 247 | * | |
| 248 | * @param id Scroll the preview pane to this unique paragraph identifier. | |
| 249 | */ | |
| 250 | public void scrollTo( final String id ) { | |
| 251 | if( mLocked ) { | |
| 252 | return; | |
| 253 | } | |
| 254 | ||
| 255 | invokeLater( () -> { | |
| 256 | int iter = 0; | |
| 257 | Box box = null; | |
| 258 | ||
| 259 | while( iter++ < 3 && ((box = mView.getBoxById( id )) == null) ) { | |
| 260 | try { | |
| 261 | sleep( 10 ); | |
| 262 | } catch( final Exception ex ) { | |
| 263 | clue( ex ); | |
| 264 | } | |
| 265 | } | |
| 266 | ||
| 267 | scrollTo( box ); | |
| 268 | } ); | |
| 269 | } | |
| 270 | ||
| 271 | /** | |
| 272 | * Scrolls to the location specified by the {@link Box} that corresponds | |
| 273 | * to a point somewhere in the preview pane. If there is no caret, then | |
| 274 | * this will not change the scroll position. Changing the scroll position | |
| 275 | * to the top if the {@link Box} instance is {@code null} will result in | |
| 276 | * jumping around a lot and inconsistent synchronization issues. | |
| 277 | * | |
| 278 | * @param box The rectangular region containing the caret, or {@code null} | |
| 279 | * if the HTML does not have a caret. | |
| 280 | */ | |
| 281 | private void scrollTo( final Box box ) { | |
| 282 | if( box != null ) { | |
| 283 | invokeLater( () -> { | |
| 284 | mView.scrollTo( createPoint( box ) ); | |
| 285 | getScrollPane().repaint(); | |
| 286 | } ); | |
| 287 | } | |
| 288 | } | |
| 289 | ||
| 290 | /** | |
| 291 | * Creates a {@link Point} to use as a reference for scrolling to the area | |
| 292 | * described by the given {@link Box}. The {@link Box} coordinates are used | |
| 293 | * to populate the {@link Point}'s location, with minor adjustments for | |
| 294 | * vertical centering. | |
| 295 | * | |
| 296 | * @param box The {@link Box} that represents a scrolling anchor reference. | |
| 297 | * @return A coordinate suitable for scrolling to. | |
| 298 | */ | |
| 299 | private Point createPoint( final Box box ) { | |
| 300 | assert box != null; | |
| 301 | ||
| 302 | // Scroll back up by half the height of the scroll bar to keep the typing | |
| 303 | // area within the view port. Otherwise the view port will have jumped too | |
| 304 | // high up and the most recently typed letters won't be visible. | |
| 305 | int y = max( box.getAbsY() - getVerticalScrollBarHeight() / 2, 0 ); | |
| 306 | int x = box.getAbsX(); | |
| 307 | ||
| 308 | if( !box.getStyle().isInline() ) { | |
| 309 | final var margin = box.getMargin( mView.getLayoutContext() ); | |
| 310 | y += margin.top(); | |
| 311 | x += margin.left(); | |
| 312 | } | |
| 313 | ||
| 314 | return new Point( x, y ); | |
| 315 | } | |
| 316 | ||
| 317 | private String getBaseUri() { | |
| 318 | return mBaseUriPath; | |
| 319 | } | |
| 320 | ||
| 321 | private JScrollPane getScrollPane() { | |
| 322 | return mScrollPane; | |
| 323 | } | |
| 324 | ||
| 325 | public JScrollBar getVerticalScrollBar() { | |
| 326 | return getScrollPane().getVerticalScrollBar(); | |
| 327 | } | |
| 328 | ||
| 329 | private int getVerticalScrollBarHeight() { | |
| 330 | return getVerticalScrollBar().getHeight(); | |
| 331 | } | |
| 332 | ||
| 333 | /** | |
| 334 | * Returns the ISO 639 alpha-2 or alpha-3 language code followed by a hyphen | |
| 335 | * followed by the ISO 15924 alpha-4 script code, followed by an ISO 3166 | |
| 336 | * alpha-2 country code or UN M.49 numeric-3 area code. For example, this | |
| 337 | * could return "en-Latn-CA" for Canadian English written in the Latin | |
| 338 | * character set. | |
| 339 | * | |
| 340 | * @return Unique identifier for language and country. | |
| 341 | */ | |
| 342 | private static URL toUrl( final Locale locale ) { | |
| 343 | return toUrl( | |
| 344 | get( | |
| 345 | sSettings.getSetting( STYLESHEET_PREVIEW_LOCALE, "" ), | |
| 346 | locale.getLanguage(), | |
| 347 | locale.getScript(), | |
| 348 | locale.getCountry() | |
| 349 | ) | |
| 350 | ); | |
| 351 | } | |
| 352 | ||
| 353 | private static URL toUrl( final String path ) { | |
| 354 | return HtmlPreview.class.getResource( path ); | |
| 355 | } | |
| 356 | ||
| 357 | private Locale getLocale() { | |
| 358 | return localeProperty().toLocale(); | |
| 359 | } | |
| 360 | ||
| 361 | private LocaleProperty localeProperty() { | |
| 362 | return mWorkspace.localeProperty( KEY_LANGUAGE_LOCALE ); | |
| 363 | } | |
| 364 | ||
| 365 | private String getFontFamily() { | |
| 366 | return fontFamilyProperty().get(); | |
| 367 | } | |
| 368 | ||
| 369 | private StringProperty fontFamilyProperty() { | |
| 370 | return mWorkspace.stringProperty( KEY_UI_FONT_PREVIEW_NAME ); | |
| 371 | } | |
| 372 | ||
| 373 | private double getFontSize() { | |
| 374 | return fontSizeProperty().get(); | |
| 375 | } | |
| 376 | ||
| 377 | /** | |
| 378 | * Returns the font size in points. | |
| 379 | * | |
| 380 | * @return The user-defined font size (in pt). | |
| 381 | */ | |
| 382 | private DoubleProperty fontSizeProperty() { | |
| 383 | return mWorkspace.doubleProperty( KEY_UI_FONT_PREVIEW_SIZE ); | |
| 384 | } | |
| 385 | ||
| 386 | private String getLockText( final boolean locked ) { | |
| 387 | return Character.toString( (locked ? LOCK : UNLOCK_ALT).getChar() ); | |
| 388 | } | |
| 389 | ||
| 390 | /** | |
| 391 | * Maps keyboard events to scrollbar commands so that users may control | |
| 392 | * the {@link HtmlPreview} panel using the keyboard. | |
| 393 | * | |
| 394 | * @param map The map to update with keyboard events. | |
| 395 | */ | |
| 396 | private void addKeyboardEvents( final InputMap map ) { | |
| 397 | map.put( getKeyStroke( VK_DOWN, 0 ), "positiveUnitIncrement" ); | |
| 398 | map.put( getKeyStroke( VK_UP, 0 ), "negativeUnitIncrement" ); | |
| 399 | map.put( getKeyStroke( VK_PAGE_DOWN, 0 ), "positiveBlockIncrement" ); | |
| 400 | map.put( getKeyStroke( VK_PAGE_UP, 0 ), "negativeBlockIncrement" ); | |
| 401 | map.put( getKeyStroke( VK_HOME, 0 ), "minScroll" ); | |
| 402 | map.put( getKeyStroke( VK_END, 0 ), "maxScroll" ); | |
| 403 | } | |
| 404 | ||
| 405 | @Override | |
| 406 | public void componentResized( final ComponentEvent e ) { | |
| 407 | if( mWorkspace.toBoolean( KEY_IMAGES_RESIZE ) ) { | |
| 408 | mFactory.clearCache(); | |
| 409 | } | |
| 410 | ||
| 411 | // Force update on the Swing EDT, otherwise the scrollbar and content | |
| 412 | // will not be updated correctly on some platforms. | |
| 413 | invokeLater( () -> getContent().repaint() ); | |
| 414 | } | |
| 415 | ||
| 416 | @Override | |
| 417 | public void componentMoved( final ComponentEvent e ) { } | |
| 418 | ||
| 419 | @Override | |
| 420 | public void componentShown( final ComponentEvent e ) { } | |
| 421 | ||
| 422 | @Override | |
| 423 | public void componentHidden( final ComponentEvent e ) { } | |
| 63 | * <li>%s --- user-customized stylesheet</li> | |
| 64 | * <li>%s --- font family</li> | |
| 65 | * <li>%d --- font size (must be pixels, not points due to bug)</li> | |
| 66 | * <li>%s --- base href</li> | |
| 67 | * </p> | |
| 68 | */ | |
| 69 | private static final String HTML_HEAD = | |
| 70 | """ | |
| 71 | <!doctype html> | |
| 72 | <html lang='%s'><head><title> </title><meta charset='utf-8'/> | |
| 73 | %s%s%s<style>body{font-family:'%s';font-size: %dpx;}</style>%s</head><body> | |
| 74 | """; | |
| 75 | ||
| 76 | private static final String HTML_TAIL = "</body></html>"; | |
| 77 | ||
| 78 | private static final URL HTML_STYLE_PREVIEW = toUrl( STYLESHEET_PREVIEW ); | |
| 79 | ||
| 80 | private final ChainedReplacedElementFactory mFactory; | |
| 81 | ||
| 82 | /** | |
| 83 | * Reusing this buffer prevents repetitious memory re-allocations. | |
| 84 | */ | |
| 85 | private final StringBuilder mDocument = new StringBuilder( 65536 ); | |
| 86 | ||
| 87 | private HtmlPanel mView; | |
| 88 | private JScrollPane mScrollPane; | |
| 89 | private String mBaseUriPath = ""; | |
| 90 | private String mHead = ""; | |
| 91 | ||
| 92 | private volatile boolean mLocked; | |
| 93 | private final JButton mScrollLockButton = new JButton(); | |
| 94 | private final Workspace mWorkspace; | |
| 95 | ||
| 96 | /** | |
| 97 | * Creates a new preview pane that can scroll to the caret position within the | |
| 98 | * document. | |
| 99 | * | |
| 100 | * @param workspace Contains locale and font size information. | |
| 101 | */ | |
| 102 | public HtmlPreview( final Workspace workspace ) { | |
| 103 | mWorkspace = workspace; | |
| 104 | ||
| 105 | // The order is important: SwingReplacedElementFactory replaces SVG images | |
| 106 | // with a blank image, which will cause the chained factory to cache the | |
| 107 | // image and exit. Instead, the SVG must execute first to rasterize the | |
| 108 | // content. Consequently, the chained factory must maintain insertion order. | |
| 109 | mFactory = new ChainedReplacedElementFactory( | |
| 110 | new SvgReplacedElementFactory(), | |
| 111 | new SwingReplacedElementFactory() | |
| 112 | ); | |
| 113 | ||
| 114 | // Attempts to prevent a flash of black un-styled content upon load. | |
| 115 | setStyle( "-fx-background-color: white;" ); | |
| 116 | ||
| 117 | invokeLater( () -> { | |
| 118 | mHead = generateHead(); | |
| 119 | mView = new HtmlPanel(); | |
| 120 | mScrollPane = new JScrollPane( mView ); | |
| 121 | final var verticalBar = mScrollPane.getVerticalScrollBar(); | |
| 122 | final var verticalPanel = new JPanel( new BorderLayout() ); | |
| 123 | ||
| 124 | final var map = verticalBar.getInputMap( WHEN_IN_FOCUSED_WINDOW ); | |
| 125 | addKeyboardEvents( map ); | |
| 126 | ||
| 127 | mScrollLockButton.setFont( getIconFont( 14 ) ); | |
| 128 | mScrollLockButton.setText( getLockText( mLocked ) ); | |
| 129 | mScrollLockButton.setMargin( new Insets( 1, 0, 0, 0 ) ); | |
| 130 | mScrollLockButton.addActionListener( e -> fireScrollLockEvent( !mLocked ) ); | |
| 131 | ||
| 132 | verticalPanel.add( verticalBar, CENTER ); | |
| 133 | verticalPanel.add( mScrollLockButton, PAGE_END ); | |
| 134 | ||
| 135 | final var wrapper = new JPanel( new BorderLayout() ); | |
| 136 | wrapper.add( mScrollPane, CENTER ); | |
| 137 | wrapper.add( verticalPanel, LINE_END ); | |
| 138 | ||
| 139 | // Enabling the cache attempts to prevent black flashes when resizing. | |
| 140 | setCache( true ); | |
| 141 | setCacheHint( SPEED ); | |
| 142 | setContent( wrapper ); | |
| 143 | wrapper.addComponentListener( this ); | |
| 144 | ||
| 145 | final var context = mView.getSharedContext(); | |
| 146 | final var textRenderer = context.getTextRenderer(); | |
| 147 | context.setReplacedElementFactory( mFactory ); | |
| 148 | textRenderer.setSmoothingThreshold( 0 ); | |
| 149 | ||
| 150 | localeProperty().addListener( ( c, o, n ) -> rerender() ); | |
| 151 | fontFamilyProperty().addListener( ( c, o, n ) -> rerender() ); | |
| 152 | fontSizeProperty().addListener( ( c, o, n ) -> rerender() ); | |
| 153 | } ); | |
| 154 | ||
| 155 | register( this ); | |
| 156 | } | |
| 157 | ||
| 158 | @Subscribe | |
| 159 | public void handle( final ScrollLockEvent event ) { | |
| 160 | mLocked = event.isLocked(); | |
| 161 | invokeLater( () -> mScrollLockButton.setText( getLockText( mLocked ) ) ); | |
| 162 | } | |
| 163 | ||
| 164 | /** | |
| 165 | * Updates the internal HTML source shown in the preview pane. | |
| 166 | * | |
| 167 | * @param html The new HTML document to display. | |
| 168 | */ | |
| 169 | public void render( final String html ) { | |
| 170 | mView.render( decorate( html ), getBaseUri() ); | |
| 171 | } | |
| 172 | ||
| 173 | /** | |
| 174 | * Clears the caches then re-renders the content. | |
| 175 | */ | |
| 176 | public void refresh() { | |
| 177 | mFactory.clearCache(); | |
| 178 | rerender(); | |
| 179 | } | |
| 180 | ||
| 181 | /** | |
| 182 | * Recomputes the HTML head then renders the document. | |
| 183 | */ | |
| 184 | private void rerender() { | |
| 185 | mHead = generateHead(); | |
| 186 | render( mDocument.toString() ); | |
| 187 | } | |
| 188 | ||
| 189 | /** | |
| 190 | * Attaches the HTML head prefix and HTML tail suffix to the given HTML | |
| 191 | * string. | |
| 192 | * | |
| 193 | * @param html The HTML to adorn with opening and closing tags. | |
| 194 | * @return A complete HTML document, ready for rendering. | |
| 195 | */ | |
| 196 | private String decorate( final String html ) { | |
| 197 | mDocument.setLength( 0 ); | |
| 198 | mDocument.append( html ); | |
| 199 | ||
| 200 | // Head and tail must be separate from document due to re-rendering. | |
| 201 | return mHead + mDocument + HTML_TAIL; | |
| 202 | } | |
| 203 | ||
| 204 | /** | |
| 205 | * Called when settings are changed that affect the HTML document preamble. | |
| 206 | * This is a minor performance optimization to avoid generating the head | |
| 207 | * each time that the document itself changes. | |
| 208 | * | |
| 209 | * @return A new doctype and HTML {@code head} element. | |
| 210 | */ | |
| 211 | private String generateHead() { | |
| 212 | final var locale = getLocale(); | |
| 213 | final var base = getBaseUri(); | |
| 214 | final var custom = getCustomStylesheetUrl(); | |
| 215 | ||
| 216 | // Point sizes are converted to pixels because of a rendering bug. | |
| 217 | return format( | |
| 218 | HTML_HEAD, | |
| 219 | locale.getLanguage(), | |
| 220 | toStylesheetString( HTML_STYLE_PREVIEW ), | |
| 221 | toStylesheetString( toUrl( locale ) ), | |
| 222 | toStylesheetString( custom ), | |
| 223 | getFontFamily(), | |
| 224 | toPixels( getFontSize() ), | |
| 225 | base.isBlank() ? "" : format( HTML_BASE, base ) | |
| 226 | ); | |
| 227 | } | |
| 228 | ||
| 229 | /** | |
| 230 | * Clears the preview pane by rendering an empty string. | |
| 231 | */ | |
| 232 | public void clear() { | |
| 233 | render( "" ); | |
| 234 | } | |
| 235 | ||
| 236 | /** | |
| 237 | * Sets the base URI to the containing directory the file being edited. | |
| 238 | * | |
| 239 | * @param path The path to the file being edited. | |
| 240 | */ | |
| 241 | public void setBaseUri( final Path path ) { | |
| 242 | final var parent = path.getParent(); | |
| 243 | mBaseUriPath = parent == null ? "" : parent.toUri().toString(); | |
| 244 | } | |
| 245 | ||
| 246 | /** | |
| 247 | * Scrolls to the closest element matching the given identifier without | |
| 248 | * waiting for the document to be ready. | |
| 249 | * | |
| 250 | * @param id Scroll the preview pane to this unique paragraph identifier. | |
| 251 | */ | |
| 252 | public void scrollTo( final String id ) { | |
| 253 | if( mLocked ) { | |
| 254 | return; | |
| 255 | } | |
| 256 | ||
| 257 | invokeLater( () -> { | |
| 258 | int iter = 0; | |
| 259 | Box box = null; | |
| 260 | ||
| 261 | while( iter++ < 3 && ((box = mView.getBoxById( id )) == null) ) { | |
| 262 | try { | |
| 263 | sleep( 10 ); | |
| 264 | } catch( final Exception ex ) { | |
| 265 | clue( ex ); | |
| 266 | } | |
| 267 | } | |
| 268 | ||
| 269 | scrollTo( box ); | |
| 270 | } ); | |
| 271 | } | |
| 272 | ||
| 273 | /** | |
| 274 | * Scrolls to the location specified by the {@link Box} that corresponds | |
| 275 | * to a point somewhere in the preview pane. If there is no caret, then | |
| 276 | * this will not change the scroll position. Changing the scroll position | |
| 277 | * to the top if the {@link Box} instance is {@code null} will result in | |
| 278 | * jumping around a lot and inconsistent synchronization issues. | |
| 279 | * | |
| 280 | * @param box The rectangular region containing the caret, or {@code null} | |
| 281 | * if the HTML does not have a caret. | |
| 282 | */ | |
| 283 | private void scrollTo( final Box box ) { | |
| 284 | if( box != null ) { | |
| 285 | invokeLater( () -> { | |
| 286 | mView.scrollTo( createPoint( box ) ); | |
| 287 | getScrollPane().repaint(); | |
| 288 | } ); | |
| 289 | } | |
| 290 | } | |
| 291 | ||
| 292 | /** | |
| 293 | * Creates a {@link Point} to use as a reference for scrolling to the area | |
| 294 | * described by the given {@link Box}. The {@link Box} coordinates are used | |
| 295 | * to populate the {@link Point}'s location, with minor adjustments for | |
| 296 | * vertical centering. | |
| 297 | * | |
| 298 | * @param box The {@link Box} that represents a scrolling anchor reference. | |
| 299 | * @return A coordinate suitable for scrolling to. | |
| 300 | */ | |
| 301 | private Point createPoint( final Box box ) { | |
| 302 | assert box != null; | |
| 303 | ||
| 304 | // Scroll back up by half the height of the scroll bar to keep the typing | |
| 305 | // area within the view port. Otherwise the view port will have jumped too | |
| 306 | // high up and the most recently typed letters won't be visible. | |
| 307 | int y = max( box.getAbsY() - getVerticalScrollBarHeight() / 2, 0 ); | |
| 308 | int x = box.getAbsX(); | |
| 309 | ||
| 310 | if( !box.getStyle().isInline() ) { | |
| 311 | final var margin = box.getMargin( mView.getLayoutContext() ); | |
| 312 | y += margin.top(); | |
| 313 | x += margin.left(); | |
| 314 | } | |
| 315 | ||
| 316 | return new Point( x, y ); | |
| 317 | } | |
| 318 | ||
| 319 | private String getBaseUri() { | |
| 320 | return mBaseUriPath; | |
| 321 | } | |
| 322 | ||
| 323 | private JScrollPane getScrollPane() { | |
| 324 | return mScrollPane; | |
| 325 | } | |
| 326 | ||
| 327 | public JScrollBar getVerticalScrollBar() { | |
| 328 | return getScrollPane().getVerticalScrollBar(); | |
| 329 | } | |
| 330 | ||
| 331 | private int getVerticalScrollBarHeight() { | |
| 332 | return getVerticalScrollBar().getHeight(); | |
| 333 | } | |
| 334 | ||
| 335 | /** | |
| 336 | * Returns the ISO 639 alpha-2 or alpha-3 language code followed by a hyphen | |
| 337 | * followed by the ISO 15924 alpha-4 script code, followed by an ISO 3166 | |
| 338 | * alpha-2 country code or UN M.49 numeric-3 area code. For example, this | |
| 339 | * could return "en-Latn-CA" for Canadian English written in the Latin | |
| 340 | * character set. | |
| 341 | * | |
| 342 | * @return Unique identifier for language and country. | |
| 343 | */ | |
| 344 | private static URL toUrl( final Locale locale ) { | |
| 345 | return toUrl( | |
| 346 | get( | |
| 347 | sSettings.getSetting( STYLESHEET_PREVIEW_LOCALE, "" ), | |
| 348 | locale.getLanguage(), | |
| 349 | locale.getScript(), | |
| 350 | locale.getCountry() | |
| 351 | ) | |
| 352 | ); | |
| 353 | } | |
| 354 | ||
| 355 | private static URL toUrl( final String path ) { | |
| 356 | return HtmlPreview.class.getResource( path ); | |
| 357 | } | |
| 358 | ||
| 359 | private Locale getLocale() { | |
| 360 | return localeProperty().toLocale(); | |
| 361 | } | |
| 362 | ||
| 363 | private LocaleProperty localeProperty() { | |
| 364 | return mWorkspace.localeProperty( KEY_LANGUAGE_LOCALE ); | |
| 365 | } | |
| 366 | ||
| 367 | private String getFontFamily() { | |
| 368 | return fontFamilyProperty().get(); | |
| 369 | } | |
| 370 | ||
| 371 | private StringProperty fontFamilyProperty() { | |
| 372 | return mWorkspace.stringProperty( KEY_UI_FONT_PREVIEW_NAME ); | |
| 373 | } | |
| 374 | ||
| 375 | private double getFontSize() { | |
| 376 | return fontSizeProperty().get(); | |
| 377 | } | |
| 378 | ||
| 379 | /** | |
| 380 | * Returns the font size in points. | |
| 381 | * | |
| 382 | * @return The user-defined font size (in pt). | |
| 383 | */ | |
| 384 | private DoubleProperty fontSizeProperty() { | |
| 385 | return mWorkspace.doubleProperty( KEY_UI_FONT_PREVIEW_SIZE ); | |
| 386 | } | |
| 387 | ||
| 388 | private String getLockText( final boolean locked ) { | |
| 389 | return Character.toString( (locked ? LOCK : UNLOCK_ALT).getChar() ); | |
| 390 | } | |
| 391 | ||
| 392 | private URL getCustomStylesheetUrl() { | |
| 393 | try { | |
| 394 | return mWorkspace.toFile( KEY_UI_PREVIEW_STYLESHEET ).toURI().toURL(); | |
| 395 | } catch( final Exception ex ) { | |
| 396 | clue( ex ); | |
| 397 | return null; | |
| 398 | } | |
| 399 | } | |
| 400 | ||
| 401 | /** | |
| 402 | * Maps keyboard events to scrollbar commands so that users may control | |
| 403 | * the {@link HtmlPreview} panel using the keyboard. | |
| 404 | * | |
| 405 | * @param map The map to update with keyboard events. | |
| 406 | */ | |
| 407 | private void addKeyboardEvents( final InputMap map ) { | |
| 408 | map.put( getKeyStroke( VK_DOWN, 0 ), "positiveUnitIncrement" ); | |
| 409 | map.put( getKeyStroke( VK_UP, 0 ), "negativeUnitIncrement" ); | |
| 410 | map.put( getKeyStroke( VK_PAGE_DOWN, 0 ), "positiveBlockIncrement" ); | |
| 411 | map.put( getKeyStroke( VK_PAGE_UP, 0 ), "negativeBlockIncrement" ); | |
| 412 | map.put( getKeyStroke( VK_HOME, 0 ), "minScroll" ); | |
| 413 | map.put( getKeyStroke( VK_END, 0 ), "maxScroll" ); | |
| 414 | } | |
| 415 | ||
| 416 | @Override | |
| 417 | public void componentResized( final ComponentEvent e ) { | |
| 418 | if( mWorkspace.toBoolean( KEY_IMAGES_RESIZE ) ) { | |
| 419 | mFactory.clearCache(); | |
| 420 | } | |
| 421 | ||
| 422 | // Force update on the Swing EDT, otherwise the scrollbar and content | |
| 423 | // will not be updated correctly on some platforms. | |
| 424 | invokeLater( () -> getContent().repaint() ); | |
| 425 | } | |
| 426 | ||
| 427 | @Override | |
| 428 | public void componentMoved( final ComponentEvent e ) { } | |
| 429 | ||
| 430 | @Override | |
| 431 | public void componentShown( final ComponentEvent e ) { } | |
| 432 | ||
| 433 | @Override | |
| 434 | public void componentHidden( final ComponentEvent e ) { } | |
| 435 | ||
| 436 | private static String toStylesheetString( final URL url ) { | |
| 437 | return url == null ? "" : format( HTML_STYLESHEET, url ); | |
| 438 | } | |
| 424 | 439 | } |
| 425 | 440 |
| 6 | 6 | import com.keenwrite.preferences.Workspace; |
| 7 | 7 | import com.keenwrite.ui.heuristics.WordCounter; |
| 8 | import com.whitemagicsoftware.keenquotes.Contractions; | |
| 8 | 9 | import com.whitemagicsoftware.keenquotes.Converter; |
| 9 | 10 | import javafx.beans.property.StringProperty; |
| 10 | 11 | import org.w3c.dom.Document; |
| 11 | 12 | |
| 12 | 13 | import java.io.FileNotFoundException; |
| 13 | 14 | import java.nio.file.Path; |
| 15 | import java.util.List; | |
| 14 | 16 | import java.util.Locale; |
| 15 | 17 | import java.util.Map; |
| ... | ||
| 41 | 43 | compile( "\\p{Blank}", UNICODE_CHARACTER_CLASS ); |
| 42 | 44 | |
| 43 | private final static Converter sTypographer = | |
| 44 | new Converter( lex -> clue( lex.toString() ), CHARS, PARSER_XML ); | |
| 45 | private final static Converter sTypographer = new Converter( | |
| 46 | lex -> clue( lex.toString() ), contractions(), CHARS, PARSER_XML ); | |
| 45 | 47 | |
| 46 | 48 | private final ProcessorContext mContext; |
| ... | ||
| 217 | 219 | } |
| 218 | 220 | |
| 219 | private Locale locale() { return getWorkspace().getLocale(); } | |
| 221 | private Locale locale() {return getWorkspace().getLocale();} | |
| 220 | 222 | |
| 221 | 223 | private String title() { |
| ... | ||
| 286 | 288 | private StringProperty stringProperty( final Key key ) { |
| 287 | 289 | return getWorkspace().stringProperty( key ); |
| 290 | } | |
| 291 | ||
| 292 | /** | |
| 293 | * Creates contracts with a custom set of unambiguous strings. | |
| 294 | * | |
| 295 | * @return List of contractions to use for curling straight quotes. | |
| 296 | */ | |
| 297 | private static Contractions contractions() { | |
| 298 | final var builder = new Contractions.Builder(); | |
| 299 | return builder.withBeganUnambiguous( List.of( "bout" ) ).build(); | |
| 288 | 300 | } |
| 289 | 301 | } |
| 3 | 3 | |
| 4 | 4 | import com.keenwrite.preferences.Workspace; |
| 5 | import com.keenwrite.preview.DiagramUrlGenerator; | |
| 5 | 6 | import com.keenwrite.processors.DefinitionProcessor; |
| 6 | 7 | import com.keenwrite.processors.Processor; |
| ... | ||
| 16 | 17 | import org.jetbrains.annotations.NotNull; |
| 17 | 18 | |
| 18 | import java.io.ByteArrayOutputStream; | |
| 19 | 19 | import java.util.HashSet; |
| 20 | 20 | import java.util.Set; |
| 21 | import java.util.zip.Deflater; | |
| 22 | 21 | |
| 23 | import static com.keenwrite.events.StatusEvent.clue; | |
| 24 | 22 | import static com.keenwrite.preferences.WorkspaceKeys.KEY_IMAGES_SERVER; |
| 25 | 23 | import static com.vladsch.flexmark.html.HtmlRenderer.Builder; |
| 26 | 24 | import static com.vladsch.flexmark.html.renderer.LinkType.LINK; |
| 27 | import static java.lang.String.format; | |
| 28 | import static java.util.Base64.getUrlEncoder; | |
| 29 | import static java.util.zip.Deflater.BEST_COMPRESSION; | |
| 30 | import static java.util.zip.Deflater.FULL_FLUSH; | |
| 31 | 25 | |
| 32 | 26 | /** |
| ... | ||
| 39 | 33 | |
| 40 | 34 | private final Processor<String> mProcessor; |
| 41 | private final ProcessorContext mContext; | |
| 35 | private final Workspace mWorkspace; | |
| 42 | 36 | |
| 43 | 37 | public FencedBlockExtension( |
| 44 | final Processor<String> processor, final ProcessorContext context ) { | |
| 38 | final Processor<String> processor, final Workspace workspace ) { | |
| 45 | 39 | assert processor != null; |
| 46 | assert context != null; | |
| 40 | assert workspace != null; | |
| 47 | 41 | mProcessor = processor; |
| 48 | mContext = context; | |
| 42 | mWorkspace = workspace; | |
| 49 | 43 | } |
| 50 | 44 | |
| ... | ||
| 69 | 63 | public static FencedBlockExtension create( |
| 70 | 64 | final Processor<String> processor, final ProcessorContext context ) { |
| 71 | return new FencedBlockExtension( processor, context ); | |
| 65 | assert processor != null; | |
| 66 | assert context != null; | |
| 67 | return new FencedBlockExtension( processor, context.getWorkspace() ); | |
| 72 | 68 | } |
| 73 | 69 | |
| ... | ||
| 107 | 103 | final var content = node.getContentChars().normalizeEOL(); |
| 108 | 104 | final var text = mProcessor.apply( content ); |
| 109 | final var encoded = encode( text ); | |
| 110 | final var source = getSourceUrl( type, encoded ); | |
| 105 | final var server = mWorkspace.toString( KEY_IMAGES_SERVER ); | |
| 106 | final var source = DiagramUrlGenerator.toUrl( server, type, text ); | |
| 111 | 107 | final var link = context.resolveLink( LINK, source, false ); |
| 112 | 108 | |
| ... | ||
| 121 | 117 | |
| 122 | 118 | return set; |
| 123 | } | |
| 124 | ||
| 125 | private byte[] compress( byte[] source ) { | |
| 126 | final var inLen = source.length; | |
| 127 | final var result = new byte[ inLen ]; | |
| 128 | final var compressor = new Deflater( BEST_COMPRESSION ); | |
| 129 | ||
| 130 | compressor.setInput( source, 0, inLen ); | |
| 131 | compressor.finish(); | |
| 132 | final var outLen = compressor.deflate( result, 0, inLen, FULL_FLUSH ); | |
| 133 | compressor.end(); | |
| 134 | ||
| 135 | try( final var out = new ByteArrayOutputStream() ) { | |
| 136 | out.write( result, 0, outLen ); | |
| 137 | return out.toByteArray(); | |
| 138 | } catch( final Exception ex ) { | |
| 139 | clue( ex ); | |
| 140 | throw new RuntimeException( ex ); | |
| 141 | } | |
| 142 | } | |
| 143 | ||
| 144 | private String encode( final String decoded ) { | |
| 145 | return getUrlEncoder().encodeToString( compress( decoded.getBytes() ) ); | |
| 146 | } | |
| 147 | ||
| 148 | private String getSourceUrl( final String type, final String encoded ) { | |
| 149 | return | |
| 150 | format( "https://%s/%s/svg/%s", getDiagramServerName(), type, encoded ); | |
| 151 | } | |
| 152 | ||
| 153 | private Workspace getWorkspace() { | |
| 154 | return mContext.getWorkspace(); | |
| 155 | } | |
| 156 | ||
| 157 | private String getDiagramServerName() { | |
| 158 | return getWorkspace().toString( KEY_IMAGES_SERVER ); | |
| 159 | 119 | } |
| 160 | 120 | } |
| 173 | 173 | |
| 174 | 174 | /** |
| 175 | * Setting {@code TEXMFCACHE} when run on a fresh system fails on first | |
| 176 | * run. If the cache directory doesn't exist, attempt to create it, then | |
| 175 | * Setting {@code TEXMFCACHE} when run on a fresh system fails on the first | |
| 176 | * try. If the cache directory doesn't exist, attempt to create it, then | |
| 177 | 177 | * call ConTeXt to generate the PDF. This is brittle because if the |
| 178 | 178 | * directory is empty, or not populated with cached data, a false positive |
| ... | ||
| 199 | 199 | |
| 200 | 200 | final var process = builder.start(); |
| 201 | final var stream = process.getInputStream(); | |
| 201 | 202 | |
| 202 | 203 | // Reading from stdout allows slurping page numbers while generating. |
| 203 | final var listener = new PaginationListener( | |
| 204 | process.getInputStream(), stdout ); | |
| 204 | final var listener = new PaginationListener( stream, stdout ); | |
| 205 | 205 | listener.start(); |
| 206 | 206 | |
| 207 | // Even though the process has completed, there may be incomplete I/O. | |
| 207 | 208 | process.waitFor(); |
| 209 | ||
| 210 | // Allow time for any incomplete I/O to take place. | |
| 211 | process.waitFor( 1, SECONDS ); | |
| 212 | ||
| 208 | 213 | final var exit = process.exitValue(); |
| 209 | 214 | process.destroy(); |
| ... | ||
| 336 | 341 | @Override |
| 337 | 342 | public void run() { |
| 338 | try( final var reader = createReader() ) { | |
| 343 | try( final var reader = createReader( mInputStream ) ) { | |
| 339 | 344 | int pageCount = 1; |
| 340 | 345 | int passCount = 1; |
| ... | ||
| 361 | 366 | pageCount = page; |
| 362 | 367 | |
| 363 | // Let the user know that something is happening in the background. | |
| 368 | // Inform the user of pages being typeset. | |
| 364 | 369 | clue( "Main.status.typeset.page", |
| 365 | 370 | pageCount, pageTotal < 1 ? "?" : pageTotal, passCount |
| 366 | 371 | ); |
| 367 | 372 | } |
| 368 | 373 | } |
| 369 | 374 | } catch( final IOException ex ) { |
| 375 | clue( ex ); | |
| 370 | 376 | throw new RuntimeException( ex ); |
| 371 | 377 | } |
| 372 | 378 | } |
| 373 | 379 | |
| 374 | private BufferedReader createReader() { | |
| 375 | return new BufferedReader( new InputStreamReader( mInputStream ) ); | |
| 380 | private BufferedReader createReader( final InputStream inputStream ) { | |
| 381 | return new BufferedReader( new InputStreamReader( inputStream ) ); | |
| 376 | 382 | } |
| 377 | 383 | } |
| 38 | 38 | workspace.document.date.title=Timestamp |
| 39 | 39 | |
| 40 | workspace.editor=Editor | |
| 41 | workspace.editor.autosave=Autosave | |
| 42 | workspace.editor.autosave.desc=Amount of time to wait between saves, in seconds (0 means disabled). | |
| 43 | workspace.editor.autosave.title=Timeout | |
| 44 | ||
| 40 | 45 | workspace.typeset=Typesetting |
| 41 | 46 | workspace.typeset.context=ConTeXt |
| ... | ||
| 96 | 101 | workspace.ui.skin.custom.desc=User-defined JavaFX cascading stylesheet file. |
| 97 | 102 | workspace.ui.skin.custom.title=Path |
| 103 | ||
| 104 | workspace.ui.preview=Preview | |
| 105 | workspace.ui.preview.stylesheet=Stylesheet | |
| 106 | workspace.ui.preview.stylesheet.desc=User-defined HTML cascading stylesheet file. | |
| 107 | workspace.ui.preview.stylesheet.title=Path | |
| 98 | 108 | |
| 99 | 109 | workspace.ui.font=Fonts |
| 1 | Listing English contractions helps converting straight apostrophes into curly apostrophes. The files include: | |
| 2 | ||
| 3 | * began.txt -- Contractions that begin with an apostrophe. | |
| 4 | * ended.txt -- Contractions that end with an apostrophe. | |
| 5 | * inner.txt -- Contractions that have internal apostrophes. | |
| 6 | * outer.txt -- Contractions that start and end with an apostrophe. | |
| 7 | * verbs.txt -- Contractions that form suffixes for a variety of words. | |
| 8 | ||
| 9 | The contractions for verbs must be detected dynamically, all other contractions can be hard-coded into either regular expressions or EBNF grammars. | |
| 10 | ||
| 11 | 1 |
| 1 | 'aporth | |
| 2 | 'bout | |
| 3 | 'boutcha | |
| 4 | 'boutchu | |
| 5 | 'choo | |
| 6 | 'dillo | |
| 7 | 'e'll | |
| 8 | 'ere | |
| 9 | 'e | |
| 10 | 'e's | |
| 11 | 'fraid | |
| 12 | 'fro | |
| 13 | 'ho | |
| 14 | 'kay | |
| 15 | 'lo | |
| 16 | 'n | |
| 17 | 'neath | |
| 18 | 'nother | |
| 19 | 'onna | |
| 20 | 'pon | |
| 21 | 'sblood | |
| 22 | 'scuse | |
| 23 | 'sfar | |
| 24 | 'sfoot | |
| 25 | 'sup | |
| 26 | 't | |
| 27 | 'taint | |
| 28 | 'tain't | |
| 29 | 'tis | |
| 30 | 'tisn't | |
| 31 | 'tshall | |
| 32 | 'twas | |
| 33 | 'twasn't | |
| 34 | 'tween | |
| 35 | 'twere | |
| 36 | 'tweren't | |
| 37 | 'twill | |
| 38 | 'twixt | |
| 39 | 'twon't | |
| 40 | 'twou'd | |
| 41 | 'twou'dn't | |
| 42 | 'twould | |
| 43 | 'twouldn't | |
| 44 | 'um | |
| 45 | 've | |
| 46 | 'zat | |
| 47 | ||
| 48 | 1 |
| 1 | ain' | |
| 2 | an' | |
| 3 | burlin' | |
| 4 | cas' | |
| 5 | didn' | |
| 6 | doan' | |
| 7 | doin' | |
| 8 | fo' | |
| 9 | gerrin' | |
| 10 | gon' | |
| 11 | i' | |
| 12 | Ima' | |
| 13 | mo' | |
| 14 | namsayin' | |
| 15 | o' | |
| 16 | ol' | |
| 17 | o'th' | |
| 18 | po' | |
| 19 | t' | |
| 20 | th' | |
| 21 | ||
| 22 | 1 |
| 1 | aboves'd | |
| 2 | after't | |
| 3 | a'ight | |
| 4 | ain't | |
| 5 | ain'tcha | |
| 6 | all's | |
| 7 | and's | |
| 8 | a'n't | |
| 9 | an't | |
| 10 | anybody'll | |
| 11 | anybody's | |
| 12 | aren'chu | |
| 13 | aren't | |
| 14 | a'right | |
| 15 | as't | |
| 16 | at's | |
| 17 | bain't | |
| 18 | bean't | |
| 19 | before't | |
| 20 | ben't | |
| 21 | better'n | |
| 22 | bettern't | |
| 23 | bisn't | |
| 24 | b'long | |
| 25 | bo's'n | |
| 26 | br'er | |
| 27 | but's | |
| 28 | by'r | |
| 29 | by't | |
| 30 | cain't | |
| 31 | call't | |
| 32 | cam'st | |
| 33 | cann't | |
| 34 | ca'n't | |
| 35 | can't | |
| 36 | can'tcha | |
| 37 | can't've | |
| 38 | can've | |
| 39 | cap'n | |
| 40 | casn't | |
| 41 | ch'ill | |
| 42 | c'mere | |
| 43 | c'min | |
| 44 | c'mon | |
| 45 | col's | |
| 46 | couldn't | |
| 47 | couldn't've | |
| 48 | couldn've | |
| 49 | could've | |
| 50 | cudn't | |
| 51 | damfidon't | |
| 52 | damnfidon't | |
| 53 | daredn't | |
| 54 | daren't | |
| 55 | dasn't | |
| 56 | dassn't | |
| 57 | dat's | |
| 58 | dere's | |
| 59 | der's | |
| 60 | didn't | |
| 61 | didn'tcha | |
| 62 | didn'tchya | |
| 63 | di'n't | |
| 64 | din't | |
| 65 | doesn't | |
| 66 | does't | |
| 67 | don't | |
| 68 | don'tcha | |
| 69 | do't | |
| 70 | dothn't | |
| 71 | dudn't | |
| 72 | dun't | |
| 73 | dursen't | |
| 74 | dursn't | |
| 75 | durstn't | |
| 76 | d'ya | |
| 77 | d'y'all | |
| 78 | d'ye | |
| 79 | d'yer | |
| 80 | d'you | |
| 81 | e'en | |
| 82 | e'er | |
| 83 | everybody's | |
| 84 | everyone's | |
| 85 | ev'ry | |
| 86 | far's | |
| 87 | fo'c's'le | |
| 88 | fo'c'sle | |
| 89 | fo'c'stle | |
| 90 | for't | |
| 91 | f'rever | |
| 92 | f'rexample | |
| 93 | g'bye | |
| 94 | g'day | |
| 95 | g'head | |
| 96 | gi's | |
| 97 | giv'n | |
| 98 | g'night | |
| 99 | g'wan | |
| 100 | hadn't | |
| 101 | hadn't've | |
| 102 | had've | |
| 103 | hain't | |
| 104 | ha'n't | |
| 105 | han't | |
| 106 | ha'pence | |
| 107 | ha'pennies | |
| 108 | ha'penny | |
| 109 | ha'p'orth | |
| 110 | ha'porth | |
| 111 | ha'p'orths | |
| 112 | hasn't | |
| 113 | has't | |
| 114 | haven't | |
| 115 | have't | |
| 116 | havn't | |
| 117 | heav'n | |
| 118 | he'd | |
| 119 | he'd've | |
| 120 | he'l | |
| 121 | he'll | |
| 122 | he'll've | |
| 123 | here'll | |
| 124 | here're | |
| 125 | here's | |
| 126 | her's | |
| 127 | he's | |
| 128 | he'sn't | |
| 129 | he've | |
| 130 | how'd | |
| 131 | how'll | |
| 132 | how'm | |
| 133 | how're | |
| 134 | how's | |
| 135 | how't | |
| 136 | how've | |
| 137 | I'd | |
| 138 | I'd-a | |
| 139 | I'da | |
| 140 | idn't | |
| 141 | I'dn't've | |
| 142 | I'd've | |
| 143 | i'faith | |
| 144 | if'n | |
| 145 | if't | |
| 146 | I'l | |
| 147 | I'll | |
| 148 | I'll've | |
| 149 | I'm | |
| 150 | I'm'a | |
| 151 | I'm-a | |
| 152 | I'ma | |
| 153 | i'm'a | |
| 154 | i'ma | |
| 155 | I'mma | |
| 156 | i'n | |
| 157 | in's | |
| 158 | i'n't | |
| 159 | in't | |
| 160 | into't | |
| 161 | I's | |
| 162 | i's | |
| 163 | I'se | |
| 164 | isn't | |
| 165 | is't | |
| 166 | it'd | |
| 167 | it'd've | |
| 168 | it'll | |
| 169 | it's | |
| 170 | it'sn't | |
| 171 | I've | |
| 172 | I'ven't | |
| 173 | let's | |
| 174 | li'l | |
| 175 | littl'un | |
| 176 | ma'am | |
| 177 | mayn't | |
| 178 | may't | |
| 179 | may've | |
| 180 | m'dear | |
| 181 | mightn't | |
| 182 | mightn't've | |
| 183 | might've | |
| 184 | m'lad | |
| 185 | m'ladies | |
| 186 | m'lady | |
| 187 | m'lord | |
| 188 | m'lords | |
| 189 | mng't | |
| 190 | more'n | |
| 191 | mus'n't | |
| 192 | musn't | |
| 193 | mustn't | |
| 194 | mustn't've | |
| 195 | must've | |
| 196 | needn't | |
| 197 | nee'n't | |
| 198 | ne'er | |
| 199 | ne'er-do-well | |
| 200 | never've | |
| 201 | nobody'd | |
| 202 | nobody's | |
| 203 | nobody've | |
| 204 | nor'easter | |
| 205 | not've | |
| 206 | n't | |
| 207 | o'clock | |
| 208 | o'er | |
| 209 | o'erhead | |
| 210 | o'erload | |
| 211 | o'erloads | |
| 212 | o'erlook | |
| 213 | o'erlooks | |
| 214 | Oi'll | |
| 215 | Oi've | |
| 216 | o'lantern | |
| 217 | o'lanterns | |
| 218 | one's | |
| 219 | on't | |
| 220 | other'n | |
| 221 | oughtn't | |
| 222 | oughtn't've | |
| 223 | p'aps | |
| 224 | penn'orth | |
| 225 | pen'orth | |
| 226 | people'd | |
| 227 | po'boy | |
| 228 | pow'r | |
| 229 | p'r'aps | |
| 230 | p'raps | |
| 231 | pray'r | |
| 232 | p'rhaps | |
| 233 | pudd'n'head | |
| 234 | r'coon | |
| 235 | run-o'-the-mill | |
| 236 | same's | |
| 237 | see't | |
| 238 | se'nnight | |
| 239 | sev'n | |
| 240 | shalln't | |
| 241 | shall's | |
| 242 | shall've | |
| 243 | sha'n't | |
| 244 | shan't | |
| 245 | sh'd | |
| 246 | she'd | |
| 247 | she'd've | |
| 248 | she'l | |
| 249 | she'll | |
| 250 | she'll've | |
| 251 | she's | |
| 252 | she've | |
| 253 | shouldn't | |
| 254 | shouldn't've | |
| 255 | should've | |
| 256 | s'long | |
| 257 | s'matter | |
| 258 | s'more | |
| 259 | s'mores | |
| 260 | somebody'd | |
| 261 | somebody's | |
| 262 | someone's | |
| 263 | something's | |
| 264 | sort've | |
| 265 | so's | |
| 266 | th'are | |
| 267 | th'art | |
| 268 | that'd | |
| 269 | that'd've | |
| 270 | that'll | |
| 271 | that'll've | |
| 272 | that're | |
| 273 | that's | |
| 274 | that've | |
| 275 | them's | |
| 276 | there'd | |
| 277 | there'll | |
| 278 | there're | |
| 279 | there's | |
| 280 | there've | |
| 281 | these're | |
| 282 | these've | |
| 283 | they'd | |
| 284 | they'da | |
| 285 | they'd've | |
| 286 | they'l | |
| 287 | they'll | |
| 288 | they'll've | |
| 289 | they're | |
| 290 | they's | |
| 291 | they've | |
| 292 | th'immortall | |
| 293 | this'd | |
| 294 | this'll | |
| 295 | this's | |
| 296 | this've | |
| 297 | those're | |
| 298 | those've | |
| 299 | tho't | |
| 300 | thou'dst | |
| 301 | thou'lt | |
| 302 | thou'rt | |
| 303 | thou'st | |
| 304 | tops'l | |
| 305 | to't | |
| 306 | to've | |
| 307 | twasn't | |
| 308 | twopenn'orths | |
| 309 | t'ye | |
| 310 | unto't | |
| 311 | upon't | |
| 312 | usedn't | |
| 313 | usen't | |
| 314 | us's | |
| 315 | view't | |
| 316 | wadn't | |
| 317 | wait'll | |
| 318 | wa'n't | |
| 319 | wan't | |
| 320 | warn't | |
| 321 | wasn't | |
| 322 | was't | |
| 323 | wazn't | |
| 324 | we'd | |
| 325 | we'd've | |
| 326 | we'l | |
| 327 | we'll | |
| 328 | we'll've | |
| 329 | we're | |
| 330 | weren't | |
| 331 | we's | |
| 332 | we've | |
| 333 | we'ven't | |
| 334 | what'd | |
| 335 | whate'er | |
| 336 | whatever's | |
| 337 | what'll | |
| 338 | what'm | |
| 339 | what're | |
| 340 | what's | |
| 341 | what've | |
| 342 | when'd | |
| 343 | whene'er | |
| 344 | when'll | |
| 345 | when's | |
| 346 | where'd | |
| 347 | where'er | |
| 348 | where'm | |
| 349 | where're | |
| 350 | where's | |
| 351 | where've | |
| 352 | which'd | |
| 353 | which'll | |
| 354 | which're | |
| 355 | which's | |
| 356 | which've | |
| 357 | who'd | |
| 358 | who'da | |
| 359 | who'd've | |
| 360 | whoe'er | |
| 361 | who'll | |
| 362 | who'm | |
| 363 | whom're | |
| 364 | who're | |
| 365 | who's | |
| 366 | who've | |
| 367 | why'd | |
| 368 | why'm | |
| 369 | whyn't | |
| 370 | why're | |
| 371 | why's | |
| 372 | willn't | |
| 373 | will've | |
| 374 | with't | |
| 375 | wolln't | |
| 376 | wo'n't | |
| 377 | won't | |
| 378 | won't've | |
| 379 | woo't | |
| 380 | worn't | |
| 381 | wou'd | |
| 382 | wouldn't | |
| 383 | wouldn'ta | |
| 384 | wouldn't've | |
| 385 | would've | |
| 386 | wudn't | |
| 387 | y'ad | |
| 388 | y'ain't | |
| 389 | y'all | |
| 390 | ya'll | |
| 391 | y'all'd | |
| 392 | y'all'd've | |
| 393 | y'all'll | |
| 394 | y'all're | |
| 395 | y'allself | |
| 396 | y'allselves | |
| 397 | y'all've | |
| 398 | y'are | |
| 399 | y'ave | |
| 400 | ye'd | |
| 401 | ye'll | |
| 402 | y'ere | |
| 403 | ye're | |
| 404 | yestere'en | |
| 405 | yet's | |
| 406 | ye've | |
| 407 | y'ever | |
| 408 | y'knew | |
| 409 | y'know | |
| 410 | you'd | |
| 411 | you'dn't've | |
| 412 | you'd've | |
| 413 | you'l | |
| 414 | you'll | |
| 415 | you'll've | |
| 416 | you're | |
| 417 | you'ren't | |
| 418 | yours'd | |
| 419 | yours'll | |
| 420 | yours've | |
| 421 | you's | |
| 422 | you'se | |
| 423 | you've | |
| 424 | you'ven't | |
| 425 | yo've | |
| 426 | y'see | |
| 427 | ||
| 428 | 1 |
| 1 | 'n' | |
| 2 | ||
| 3 | 1 |
| 1 | 'd | |
| 2 | 'll | |
| 3 | 'm | |
| 4 | 're | |
| 5 | 's | |
| 6 | 've | |
| 7 | ||
| 8 | 1 |
| 23 | 23 | import static com.keenwrite.util.FontLoader.initFonts; |
| 24 | 24 | |
| 25 | //@ExtendWith(ApplicationExtension.class) | |
| 26 | 25 | public class TreeViewTest extends Application { |
| 27 | 26 | private final SimpleObjectProperty<Node> mTextEditor = |
| 1 | package com.keenwrite.preview; | |
| 2 | ||
| 3 | import org.junit.jupiter.api.Test; | |
| 4 | ||
| 5 | import static com.keenwrite.preview.DiagramUrlGenerator.toUrl; | |
| 6 | import static org.junit.jupiter.api.Assertions.assertEquals; | |
| 7 | ||
| 8 | /** | |
| 9 | * Responsible for testing that images sent to the diagram server will render. | |
| 10 | */ | |
| 11 | class DiagramUrlGeneratorTest { | |
| 12 | private final static String SERVER_NAME = "kroki.io"; | |
| 13 | ||
| 14 | // @formatter:off | |
| 15 | private final static String[] DIAGRAMS = new String[]{ | |
| 16 | "graphviz", | |
| 17 | "digraph G {Hello->World; World->Hello;}", | |
| 18 | "https://kroki.io/graphviz/svg/eJxLyUwvSizIUHBXqPZIzcnJ17ULzy_KSbFWAFO6dmBB61oAE9kNww==", | |
| 19 | ||
| 20 | "blockdiag", | |
| 21 | """ | |
| 22 | blockdiag { | |
| 23 | Kroki -> generates -> "Block diagrams"; | |
| 24 | Kroki -> is -> "very easy!"; | |
| 25 | ||
| 26 | Kroki [color = "greenyellow"]; | |
| 27 | "Block diagrams" [color = "pink"]; | |
| 28 | "very easy!" [color = "orange"]; | |
| 29 | } | |
| 30 | """, | |
| 31 | "https://kroki.io/blockdiag/svg/eJxdzDEKQjEQhOHeU4zpPYFoYesRxGJ9bwghMSsbUYJ4d10UCZbDfPynolOek0Q8FsDeNCestoisNLmy-Qg7R3Blcm5hPcr0ITdaB6X15fv-_YdJixo2CNHI2lmK3sPRA__RwV5SzV80ZAegJjXSyfMFptc71w==" | |
| 32 | }; | |
| 33 | // @formatter:on | |
| 34 | ||
| 35 | /** | |
| 36 | * Test that URL encoding works with Kroki's server. | |
| 37 | */ | |
| 38 | @Test | |
| 39 | public void test_Generation_TextDiagram_UrlEncoded() { | |
| 40 | // Use a map of pairs if this test needs more complexity. | |
| 41 | for( int i = 0; i < DIAGRAMS.length / 3; i += 3 ) { | |
| 42 | final var name = DIAGRAMS[ i ]; | |
| 43 | final var text = DIAGRAMS[ i + 1 ]; | |
| 44 | final var expected = DIAGRAMS[ i + 2 ]; | |
| 45 | final var actual = toUrl( SERVER_NAME, name, text ); | |
| 46 | ||
| 47 | assertEquals( expected, actual ); | |
| 48 | } | |
| 49 | } | |
| 50 | } | |
| 1 | 51 |