| 32 | 32 | Usage |
| 33 | 33 | --- |
| 34 | 1. [Download](https://github.com/DaveJarvis/scrivenvar/releases) the jar file. | |
| 34 | 1. [Download](https://github.com/DaveJarvis/scrivenvar/releases) `scrivenvar.jar`. | |
| 35 | 35 | 1. Double-click `scrivenvar.jar` to start the application. |
| 36 | 36 |
| 33 | 33 | } |
| 34 | 34 | |
| 35 | version = '1.1.6' | |
| 35 | version = '1.2.0' | |
| 36 | 36 | applicationName = 'scrivenvar' |
| 37 | 37 | mainClassName = 'com.scrivenvar.Main' |
| 38 | 38 | sourceCompatibility = JavaVersion.VERSION_1_8 |
| 39 | 39 | |
| 40 | 40 | jar { |
| 41 | 41 | baseName = applicationName |
| 42 | archiveName = "${applicationName}.jar" | |
| 42 | 43 | |
| 43 | 44 | doFirst { |
| 31 | 31 | import com.scrivenvar.service.events.Notifier; |
| 32 | 32 | import java.nio.charset.Charset; |
| 33 | import java.nio.file.Files; | |
| 34 | import java.nio.file.Path; | |
| 35 | import static java.util.Locale.ENGLISH; | |
| 36 | import java.util.function.Consumer; | |
| 37 | import javafx.application.Platform; | |
| 38 | import javafx.beans.binding.Bindings; | |
| 39 | import javafx.beans.property.BooleanProperty; | |
| 40 | import javafx.beans.property.ReadOnlyBooleanProperty; | |
| 41 | import javafx.beans.property.ReadOnlyBooleanWrapper; | |
| 42 | import javafx.beans.property.SimpleBooleanProperty; | |
| 43 | import javafx.beans.value.ChangeListener; | |
| 44 | import javafx.beans.value.ObservableValue; | |
| 45 | import javafx.event.Event; | |
| 46 | import javafx.scene.Node; | |
| 47 | import javafx.scene.control.Tab; | |
| 48 | import javafx.scene.control.Tooltip; | |
| 49 | import javafx.scene.input.InputEvent; | |
| 50 | import javafx.scene.text.Text; | |
| 51 | import javafx.stage.Window; | |
| 52 | import org.fxmisc.richtext.StyleClassedTextArea; | |
| 53 | import static org.fxmisc.richtext.model.TwoDimensional.Bias.Forward; | |
| 54 | import org.fxmisc.richtext.model.TwoDimensional.Position; | |
| 55 | import org.fxmisc.undo.UndoManager; | |
| 56 | import org.fxmisc.wellbehaved.event.EventPattern; | |
| 57 | import org.fxmisc.wellbehaved.event.InputMap; | |
| 58 | import org.mozilla.universalchardet.UniversalDetector; | |
| 59 | ||
| 60 | /** | |
| 61 | * Editor for a single file. | |
| 62 | * | |
| 63 | * @author Karl Tauber and White Magic Software, Ltd. | |
| 64 | */ | |
| 65 | public final class FileEditorTab extends Tab { | |
| 66 | ||
| 67 | private final Notifier alertService = Services.load( Notifier.class ); | |
| 68 | private EditorPane editorPane; | |
| 69 | ||
| 70 | /** | |
| 71 | * Character encoding used by the file (or default encoding if none found). | |
| 72 | */ | |
| 73 | private Charset encoding; | |
| 74 | ||
| 75 | private final ReadOnlyBooleanWrapper modified = new ReadOnlyBooleanWrapper(); | |
| 76 | private final BooleanProperty canUndo = new SimpleBooleanProperty(); | |
| 77 | private final BooleanProperty canRedo = new SimpleBooleanProperty(); | |
| 78 | ||
| 79 | // Might be simpler to revert this back to a property and have the main | |
| 80 | // window listen for changes to it... | |
| 81 | private Path path; | |
| 82 | ||
| 83 | FileEditorTab( final Path path ) { | |
| 84 | setPath( path ); | |
| 85 | ||
| 86 | this.modified.addListener( (observable, oldPath, newPath) -> updateTab() ); | |
| 87 | updateTab(); | |
| 88 | ||
| 89 | setOnSelectionChanged( e -> { | |
| 90 | if( isSelected() ) { | |
| 91 | Platform.runLater( () -> activated() ); | |
| 92 | } | |
| 93 | } ); | |
| 94 | } | |
| 95 | ||
| 96 | private void updateTab() { | |
| 97 | setText( getTabTitle() ); | |
| 98 | setGraphic( getModifiedMark() ); | |
| 99 | setTooltip( getTabTooltip() ); | |
| 100 | } | |
| 101 | ||
| 102 | /** | |
| 103 | * Returns the base filename (without the directory names). | |
| 104 | * | |
| 105 | * @return The untitled text if the path hasn't been set. | |
| 106 | */ | |
| 107 | private String getTabTitle() { | |
| 108 | final Path filePath = getPath(); | |
| 109 | ||
| 110 | return (filePath == null) | |
| 111 | ? Messages.get( "FileEditor.untitled" ) | |
| 112 | : filePath.getFileName().toString(); | |
| 113 | } | |
| 114 | ||
| 115 | /** | |
| 116 | * Returns the full filename represented by the path. | |
| 117 | * | |
| 118 | * @return The untitled text if the path hasn't been set. | |
| 119 | */ | |
| 120 | private Tooltip getTabTooltip() { | |
| 121 | final Path filePath = getPath(); | |
| 122 | return new Tooltip( filePath == null ? "" : filePath.toString() ); | |
| 123 | } | |
| 124 | ||
| 125 | /** | |
| 126 | * Returns a marker to indicate whether the file has been modified. | |
| 127 | * | |
| 128 | * @return "*" when the file has changed; otherwise null. | |
| 129 | */ | |
| 130 | private Text getModifiedMark() { | |
| 131 | return isModified() ? new Text( "*" ) : null; | |
| 132 | } | |
| 133 | ||
| 134 | /** | |
| 135 | * Called when the user switches tab. | |
| 136 | */ | |
| 137 | private void activated() { | |
| 138 | // Tab is closed or no longer active. | |
| 139 | if( getTabPane() == null || !isSelected() ) { | |
| 140 | return; | |
| 141 | } | |
| 142 | ||
| 143 | // Switch to the tab without loading if the contents are already in memory. | |
| 144 | if( getContent() != null ) { | |
| 145 | getEditorPane().requestFocus(); | |
| 146 | return; | |
| 147 | } | |
| 148 | ||
| 149 | // Load the text and update the preview before the undo manager. | |
| 150 | load(); | |
| 151 | ||
| 152 | // Track undo requests -- can only be called *after* load. | |
| 153 | initUndoManager(); | |
| 154 | initLayout(); | |
| 155 | initFocus(); | |
| 156 | } | |
| 157 | ||
| 158 | private void initLayout() { | |
| 159 | setContent( getScrollPane() ); | |
| 160 | } | |
| 161 | ||
| 162 | private Node getScrollPane() { | |
| 163 | return getEditorPane().getScrollPane(); | |
| 164 | } | |
| 165 | ||
| 166 | private void initFocus() { | |
| 167 | getEditorPane().requestFocus(); | |
| 168 | } | |
| 169 | ||
| 170 | private void initUndoManager() { | |
| 171 | final UndoManager undoManager = getUndoManager(); | |
| 172 | ||
| 173 | // Clear undo history after first load. | |
| 174 | undoManager.forgetHistory(); | |
| 175 | ||
| 176 | // Bind the editor undo manager to the properties. | |
| 177 | modified.bind( Bindings.not( undoManager.atMarkedPositionProperty() ) ); | |
| 178 | canUndo.bind( undoManager.undoAvailableProperty() ); | |
| 179 | canRedo.bind( undoManager.redoAvailableProperty() ); | |
| 180 | } | |
| 181 | ||
| 182 | /** | |
| 183 | * Searches from the caret position forward for the given string. | |
| 184 | * | |
| 185 | * @param needle The text string to match. | |
| 186 | */ | |
| 187 | public void searchNext( final String needle ) { | |
| 188 | final String haystack = getEditorText(); | |
| 189 | int index = haystack.indexOf( needle, getCaretPosition() ); | |
| 190 | ||
| 191 | // Wrap around. | |
| 192 | if( index == -1 ) { | |
| 193 | index = haystack.indexOf( needle, 0 ); | |
| 194 | } | |
| 195 | ||
| 196 | if( index >= 0 ) { | |
| 197 | setCaretPosition( index ); | |
| 198 | getEditor().selectRange( index, index + needle.length() ); | |
| 199 | } | |
| 200 | } | |
| 201 | ||
| 202 | /** | |
| 203 | * Returns the index into the text where the caret blinks happily away. | |
| 204 | * | |
| 205 | * @return A number from 0 to the editor's document text length. | |
| 206 | */ | |
| 207 | public int getCaretPosition() { | |
| 208 | return getEditor().getCaretPosition(); | |
| 209 | } | |
| 210 | ||
| 211 | /** | |
| 212 | * Moves the caret to a given offset. | |
| 213 | * | |
| 214 | * @param offset The new caret offset. | |
| 215 | */ | |
| 216 | private void setCaretPosition( final int offset ) { | |
| 217 | getEditor().moveTo( offset ); | |
| 218 | getEditor().requestFollowCaret(); | |
| 219 | } | |
| 220 | ||
| 221 | /** | |
| 222 | * Returns the caret's current row and column position. | |
| 223 | * | |
| 224 | * @return The caret's offset into the document. | |
| 225 | */ | |
| 226 | public Position getCaretOffset() { | |
| 227 | return getEditor().offsetToPosition( getCaretPosition(), Forward ); | |
| 228 | } | |
| 229 | ||
| 230 | /** | |
| 231 | * Allows observers to synchronize caret position changes. | |
| 232 | * | |
| 233 | * @return An observable caret property value. | |
| 234 | */ | |
| 235 | public final ObservableValue<Integer> caretPositionProperty() { | |
| 236 | return getEditor().caretPositionProperty(); | |
| 237 | } | |
| 238 | ||
| 239 | /** | |
| 240 | * Returns the text area associated with this tab. | |
| 241 | * | |
| 242 | * @return A text editor. | |
| 243 | */ | |
| 244 | private StyleClassedTextArea getEditor() { | |
| 245 | return getEditorPane().getEditor(); | |
| 246 | } | |
| 247 | ||
| 248 | /** | |
| 249 | * Returns true if the given path exactly matches this tab's path. | |
| 250 | * | |
| 251 | * @param check The path to compare against. | |
| 252 | * | |
| 253 | * @return true The paths are the same. | |
| 254 | */ | |
| 255 | public boolean isPath( final Path check ) { | |
| 256 | final Path filePath = getPath(); | |
| 257 | ||
| 258 | return filePath == null ? false : filePath.equals( check ); | |
| 259 | } | |
| 260 | ||
| 261 | /** | |
| 262 | * Reads the entire file contents from the path associated with this tab. | |
| 263 | */ | |
| 264 | private void load() { | |
| 265 | final Path filePath = getPath(); | |
| 266 | ||
| 267 | if( filePath != null ) { | |
| 268 | try { | |
| 269 | getEditorPane().setText( asString( Files.readAllBytes( filePath ) ) ); | |
| 270 | } catch( final Exception ex ) { | |
| 271 | getNotifyService().notify( ex ); | |
| 272 | } | |
| 273 | } | |
| 274 | } | |
| 275 | ||
| 276 | /** | |
| 277 | * Saves the entire file contents from the path associated with this tab. | |
| 278 | * | |
| 279 | * @return true The file has been saved. | |
| 280 | */ | |
| 281 | public boolean save() { | |
| 282 | try { | |
| 283 | Files.write( getPath(), asBytes( getEditorPane().getText() ) ); | |
| 284 | getEditorPane().getUndoManager().mark(); | |
| 285 | return true; | |
| 286 | } catch( final Exception ex ) { | |
| 287 | return alert( | |
| 288 | "FileEditor.saveFailed.title", "FileEditor.saveFailed.message", ex | |
| 289 | ); | |
| 290 | } | |
| 291 | } | |
| 292 | ||
| 293 | /** | |
| 294 | * Creates an alert dialog and waits for it to close. | |
| 295 | * | |
| 296 | * @param titleKey Resource bundle key for the alert dialog title. | |
| 297 | * @param messageKey Resource bundle key for the alert dialog message. | |
| 298 | * @param e The unexpected happening. | |
| 299 | * | |
| 300 | * @return false | |
| 301 | */ | |
| 302 | private boolean alert( | |
| 303 | final String titleKey, final String messageKey, final Exception e ) { | |
| 304 | final Notifier service = getNotifyService(); | |
| 305 | final Path filePath = getPath(); | |
| 306 | ||
| 307 | final Notification message = service.createNotification( | |
| 308 | Messages.get( titleKey ), | |
| 309 | Messages.get( messageKey ), | |
| 310 | filePath == null ? "" : filePath, | |
| 311 | e.getMessage() | |
| 312 | ); | |
| 313 | ||
| 314 | service.createError( getWindow(), message ).showAndWait(); | |
| 315 | return false; | |
| 316 | } | |
| 317 | ||
| 318 | private Window getWindow() { | |
| 319 | return getEditorPane().getScene().getWindow(); | |
| 320 | } | |
| 321 | ||
| 322 | /** | |
| 323 | * Returns a best guess at the file encoding. If the encoding could not be | |
| 324 | * detected, this will return the default charset for the JVM. | |
| 325 | * | |
| 326 | * @param bytes The bytes to perform character encoding detection. | |
| 327 | * | |
| 328 | * @return The character encoding. | |
| 329 | */ | |
| 330 | private Charset detectEncoding( final byte[] bytes ) { | |
| 331 | final UniversalDetector detector = new UniversalDetector( null ); | |
| 332 | detector.handleData( bytes, 0, bytes.length ); | |
| 333 | detector.dataEnd(); | |
| 334 | ||
| 335 | final String charset = detector.getDetectedCharset(); | |
| 336 | final Charset charEncoding = charset == null | |
| 337 | ? Charset.defaultCharset() | |
| 338 | : Charset.forName( charset.toUpperCase( ENGLISH ) ); | |
| 339 | ||
| 340 | detector.reset(); | |
| 341 | ||
| 342 | return charEncoding; | |
| 343 | } | |
| 344 | ||
| 345 | /** | |
| 346 | * Converts the given string to an array of bytes using the encoding that was | |
| 347 | * originally detected (if any) and associated with this file. | |
| 348 | * | |
| 349 | * @param text The text to convert into the original file encoding. | |
| 350 | * | |
| 351 | * @return A series of bytes ready for writing to a file. | |
| 352 | */ | |
| 353 | private byte[] asBytes( final String text ) { | |
| 354 | return text.getBytes( getEncoding() ); | |
| 355 | } | |
| 356 | ||
| 357 | /** | |
| 358 | * Converts the given bytes into a Java String. This will call setEncoding | |
| 359 | * with the encoding detected by the CharsetDetector. | |
| 360 | * | |
| 361 | * @param text The text of unknown character encoding. | |
| 362 | * | |
| 363 | * @return The text, in its auto-detected encoding, as a String. | |
| 364 | */ | |
| 365 | private String asString( final byte[] text ) { | |
| 366 | setEncoding( detectEncoding( text ) ); | |
| 367 | return new String( text, getEncoding() ); | |
| 368 | } | |
| 369 | ||
| 370 | public Path getPath() { | |
| 371 | return this.path; | |
| 372 | } | |
| 373 | ||
| 374 | public void setPath( final Path path ) { | |
| 375 | this.path = path; | |
| 376 | } | |
| 377 | ||
| 378 | /** | |
| 379 | * Answers whether this tab has an initialized path reference. | |
| 380 | * | |
| 381 | * @return false This tab has no path. | |
| 382 | */ | |
| 383 | public boolean isFileOpen() { | |
| 384 | return this.path != null; | |
| 385 | } | |
| 386 | ||
| 387 | public boolean isModified() { | |
| 388 | return this.modified.get(); | |
| 389 | } | |
| 390 | ||
| 391 | ReadOnlyBooleanProperty modifiedProperty() { | |
| 392 | return this.modified.getReadOnlyProperty(); | |
| 393 | } | |
| 394 | ||
| 395 | BooleanProperty canUndoProperty() { | |
| 396 | return this.canUndo; | |
| 397 | } | |
| 398 | ||
| 399 | BooleanProperty canRedoProperty() { | |
| 400 | return this.canRedo; | |
| 401 | } | |
| 402 | ||
| 403 | private UndoManager getUndoManager() { | |
| 404 | return getEditorPane().getUndoManager(); | |
| 405 | } | |
| 406 | ||
| 407 | /** | |
| 408 | * Forwards the request to the editor pane. | |
| 409 | * | |
| 410 | * @param <T> The type of event listener to add. | |
| 411 | * @param <U> The type of consumer to add. | |
| 412 | * @param event The event that should trigger updates to the listener. | |
| 413 | * @param consumer The listener to receive update events. | |
| 414 | */ | |
| 415 | public <T extends Event, U extends T> void addEventListener( | |
| 416 | final EventPattern<? super T, ? extends U> event, | |
| 417 | final Consumer<? super U> consumer ) { | |
| 418 | getEditorPane().addEventListener( event, consumer ); | |
| 419 | } | |
| 420 | ||
| 421 | /** | |
| 422 | * Forwards to the editor pane's listeners for keyboard events. | |
| 423 | * | |
| 424 | * @param map The new input map to replace the existing keyboard listener. | |
| 425 | */ | |
| 426 | public void addEventListener( final InputMap<InputEvent> map ) { | |
| 427 | getEditorPane().addEventListener( map ); | |
| 428 | } | |
| 429 | ||
| 430 | /** | |
| 431 | * Forwards to the editor pane's listeners for keyboard events. | |
| 432 | * | |
| 433 | * @param map The existing input map to remove from the keyboard listeners. | |
| 434 | */ | |
| 435 | public void removeEventListener( final InputMap<InputEvent> map ) { | |
| 436 | getEditorPane().removeEventListener( map ); | |
| 437 | } | |
| 438 | ||
| 439 | /** | |
| 440 | * Forwards to the editor pane's listeners for text change events. | |
| 441 | * | |
| 442 | * @param listener The listener to notify when the text changes. | |
| 443 | */ | |
| 444 | public void addTextChangeListener( final ChangeListener<String> listener ) { | |
| 445 | getEditorPane().addTextChangeListener( listener ); | |
| 446 | } | |
| 447 | ||
| 448 | /** | |
| 449 | * Forwards to the editor pane's listeners for caret paragraph change events. | |
| 450 | * | |
| 451 | * @param listener The listener to notify when the caret changes paragraphs. | |
| 452 | */ | |
| 453 | public void addCaretParagraphListener( final ChangeListener<Integer> listener ) { | |
| 454 | getEditorPane().addCaretParagraphListener( listener ); | |
| 455 | } | |
| 456 | ||
| 457 | /** | |
| 458 | * Forwards the request to the editor pane. | |
| 459 | * | |
| 460 | * @return The text to process. | |
| 461 | */ | |
| 462 | public String getEditorText() { | |
| 463 | return getEditorPane().getText(); | |
| 464 | } | |
| 465 | ||
| 466 | /** | |
| 467 | * Returns the editor pane, or creates one if it doesn't yet exist. | |
| 468 | * | |
| 469 | * @return The editor pane, never null. | |
| 470 | */ | |
| 471 | public EditorPane getEditorPane() { | |
| 472 | if( this.editorPane == null ) { | |
| 473 | this.editorPane = new MarkdownEditorPane(); | |
| 474 | } | |
| 475 | ||
| 476 | return this.editorPane; | |
| 477 | } | |
| 478 | ||
| 479 | private Notifier getNotifyService() { | |
| 480 | return this.alertService; | |
| 481 | } | |
| 482 | ||
| 483 | private Charset getEncoding() { | |
| 33 | import static java.nio.charset.StandardCharsets.UTF_8; | |
| 34 | import java.nio.file.Files; | |
| 35 | import java.nio.file.Path; | |
| 36 | import static java.util.Locale.ENGLISH; | |
| 37 | import java.util.function.Consumer; | |
| 38 | import javafx.application.Platform; | |
| 39 | import javafx.beans.binding.Bindings; | |
| 40 | import javafx.beans.property.BooleanProperty; | |
| 41 | import javafx.beans.property.ReadOnlyBooleanProperty; | |
| 42 | import javafx.beans.property.ReadOnlyBooleanWrapper; | |
| 43 | import javafx.beans.property.SimpleBooleanProperty; | |
| 44 | import javafx.beans.value.ChangeListener; | |
| 45 | import javafx.beans.value.ObservableValue; | |
| 46 | import javafx.event.Event; | |
| 47 | import javafx.scene.Node; | |
| 48 | import javafx.scene.Scene; | |
| 49 | import javafx.scene.control.Tab; | |
| 50 | import javafx.scene.control.Tooltip; | |
| 51 | import javafx.scene.input.InputEvent; | |
| 52 | import javafx.scene.text.Text; | |
| 53 | import javafx.stage.Window; | |
| 54 | import org.fxmisc.richtext.StyleClassedTextArea; | |
| 55 | import static org.fxmisc.richtext.model.TwoDimensional.Bias.Forward; | |
| 56 | import org.fxmisc.richtext.model.TwoDimensional.Position; | |
| 57 | import org.fxmisc.undo.UndoManager; | |
| 58 | import org.fxmisc.wellbehaved.event.EventPattern; | |
| 59 | import org.fxmisc.wellbehaved.event.InputMap; | |
| 60 | import org.mozilla.universalchardet.UniversalDetector; | |
| 61 | ||
| 62 | /** | |
| 63 | * Editor for a single file. | |
| 64 | * | |
| 65 | * @author Karl Tauber and White Magic Software, Ltd. | |
| 66 | */ | |
| 67 | public final class FileEditorTab extends Tab { | |
| 68 | ||
| 69 | private final Notifier alertService = Services.load( Notifier.class ); | |
| 70 | private EditorPane editorPane; | |
| 71 | ||
| 72 | /** | |
| 73 | * Character encoding used by the file (or default encoding if none found). | |
| 74 | */ | |
| 75 | private Charset encoding; | |
| 76 | ||
| 77 | private final ReadOnlyBooleanWrapper modified = new ReadOnlyBooleanWrapper(); | |
| 78 | private final BooleanProperty canUndo = new SimpleBooleanProperty(); | |
| 79 | private final BooleanProperty canRedo = new SimpleBooleanProperty(); | |
| 80 | ||
| 81 | private Path path; | |
| 82 | ||
| 83 | FileEditorTab( final Path path ) { | |
| 84 | setPath( path ); | |
| 85 | ||
| 86 | this.modified.addListener( (observable, oldPath, newPath) -> updateTab() ); | |
| 87 | ||
| 88 | setOnSelectionChanged( e -> { | |
| 89 | if( isSelected() ) { | |
| 90 | Platform.runLater( () -> activated() ); | |
| 91 | } | |
| 92 | } ); | |
| 93 | } | |
| 94 | ||
| 95 | private void updateTab() { | |
| 96 | setText( getTabTitle() ); | |
| 97 | setGraphic( getModifiedMark() ); | |
| 98 | setTooltip( getTabTooltip() ); | |
| 99 | } | |
| 100 | ||
| 101 | /** | |
| 102 | * Returns the base filename (without the directory names). | |
| 103 | * | |
| 104 | * @return The untitled text if the path hasn't been set. | |
| 105 | */ | |
| 106 | private String getTabTitle() { | |
| 107 | final Path filePath = getPath(); | |
| 108 | ||
| 109 | return (filePath == null) | |
| 110 | ? Messages.get( "FileEditor.untitled" ) | |
| 111 | : filePath.getFileName().toString(); | |
| 112 | } | |
| 113 | ||
| 114 | /** | |
| 115 | * Returns the full filename represented by the path. | |
| 116 | * | |
| 117 | * @return The untitled text if the path hasn't been set. | |
| 118 | */ | |
| 119 | private Tooltip getTabTooltip() { | |
| 120 | final Path filePath = getPath(); | |
| 121 | return new Tooltip( filePath == null ? "" : filePath.toString() ); | |
| 122 | } | |
| 123 | ||
| 124 | /** | |
| 125 | * Returns a marker to indicate whether the file has been modified. | |
| 126 | * | |
| 127 | * @return "*" when the file has changed; otherwise null. | |
| 128 | */ | |
| 129 | private Text getModifiedMark() { | |
| 130 | return isModified() ? new Text( "*" ) : null; | |
| 131 | } | |
| 132 | ||
| 133 | /** | |
| 134 | * Called when the user switches tab. | |
| 135 | */ | |
| 136 | private void activated() { | |
| 137 | // Tab is closed or no longer active. | |
| 138 | if( getTabPane() == null || !isSelected() ) { | |
| 139 | return; | |
| 140 | } | |
| 141 | ||
| 142 | // Switch to the tab without loading if the contents are already in memory. | |
| 143 | if( getContent() != null ) { | |
| 144 | getEditorPane().requestFocus(); | |
| 145 | return; | |
| 146 | } | |
| 147 | ||
| 148 | // Load the text and update the preview before the undo manager. | |
| 149 | load(); | |
| 150 | ||
| 151 | // Track undo requests -- can only be called *after* load. | |
| 152 | initUndoManager(); | |
| 153 | initLayout(); | |
| 154 | initFocus(); | |
| 155 | } | |
| 156 | ||
| 157 | private void initLayout() { | |
| 158 | setContent( getScrollPane() ); | |
| 159 | } | |
| 160 | ||
| 161 | private Node getScrollPane() { | |
| 162 | return getEditorPane().getScrollPane(); | |
| 163 | } | |
| 164 | ||
| 165 | private void initFocus() { | |
| 166 | getEditorPane().requestFocus(); | |
| 167 | } | |
| 168 | ||
| 169 | private void initUndoManager() { | |
| 170 | final UndoManager undoManager = getUndoManager(); | |
| 171 | ||
| 172 | // Clear undo history after first load. | |
| 173 | undoManager.forgetHistory(); | |
| 174 | ||
| 175 | // Bind the editor undo manager to the properties. | |
| 176 | modified.bind( Bindings.not( undoManager.atMarkedPositionProperty() ) ); | |
| 177 | canUndo.bind( undoManager.undoAvailableProperty() ); | |
| 178 | canRedo.bind( undoManager.redoAvailableProperty() ); | |
| 179 | } | |
| 180 | ||
| 181 | /** | |
| 182 | * Searches from the caret position forward for the given string. | |
| 183 | * | |
| 184 | * @param needle The text string to match. | |
| 185 | */ | |
| 186 | public void searchNext( final String needle ) { | |
| 187 | final String haystack = getEditorText(); | |
| 188 | int index = haystack.indexOf( needle, getCaretPosition() ); | |
| 189 | ||
| 190 | // Wrap around. | |
| 191 | if( index == -1 ) { | |
| 192 | index = haystack.indexOf( needle, 0 ); | |
| 193 | } | |
| 194 | ||
| 195 | if( index >= 0 ) { | |
| 196 | setCaretPosition( index ); | |
| 197 | getEditor().selectRange( index, index + needle.length() ); | |
| 198 | } | |
| 199 | } | |
| 200 | ||
| 201 | /** | |
| 202 | * Returns the index into the text where the caret blinks happily away. | |
| 203 | * | |
| 204 | * @return A number from 0 to the editor's document text length. | |
| 205 | */ | |
| 206 | public int getCaretPosition() { | |
| 207 | return getEditor().getCaretPosition(); | |
| 208 | } | |
| 209 | ||
| 210 | /** | |
| 211 | * Moves the caret to a given offset. | |
| 212 | * | |
| 213 | * @param offset The new caret offset. | |
| 214 | */ | |
| 215 | private void setCaretPosition( final int offset ) { | |
| 216 | getEditor().moveTo( offset ); | |
| 217 | getEditor().requestFollowCaret(); | |
| 218 | } | |
| 219 | ||
| 220 | /** | |
| 221 | * Returns the caret's current row and column position. | |
| 222 | * | |
| 223 | * @return The caret's offset into the document. | |
| 224 | */ | |
| 225 | public Position getCaretOffset() { | |
| 226 | return getEditor().offsetToPosition( getCaretPosition(), Forward ); | |
| 227 | } | |
| 228 | ||
| 229 | /** | |
| 230 | * Allows observers to synchronize caret position changes. | |
| 231 | * | |
| 232 | * @return An observable caret property value. | |
| 233 | */ | |
| 234 | public final ObservableValue<Integer> caretPositionProperty() { | |
| 235 | return getEditor().caretPositionProperty(); | |
| 236 | } | |
| 237 | ||
| 238 | /** | |
| 239 | * Returns the text area associated with this tab. | |
| 240 | * | |
| 241 | * @return A text editor. | |
| 242 | */ | |
| 243 | private StyleClassedTextArea getEditor() { | |
| 244 | return getEditorPane().getEditor(); | |
| 245 | } | |
| 246 | ||
| 247 | /** | |
| 248 | * Returns true if the given path exactly matches this tab's path. | |
| 249 | * | |
| 250 | * @param check The path to compare against. | |
| 251 | * | |
| 252 | * @return true The paths are the same. | |
| 253 | */ | |
| 254 | public boolean isPath( final Path check ) { | |
| 255 | final Path filePath = getPath(); | |
| 256 | ||
| 257 | return filePath == null ? false : filePath.equals( check ); | |
| 258 | } | |
| 259 | ||
| 260 | /** | |
| 261 | * Reads the entire file contents from the path associated with this tab. | |
| 262 | */ | |
| 263 | private void load() { | |
| 264 | final Path filePath = getPath(); | |
| 265 | ||
| 266 | if( filePath != null ) { | |
| 267 | try { | |
| 268 | getEditorPane().setText( asString( Files.readAllBytes( filePath ) ) ); | |
| 269 | } catch( final Exception ex ) { | |
| 270 | getNotifyService().notify( ex ); | |
| 271 | } | |
| 272 | } | |
| 273 | } | |
| 274 | ||
| 275 | /** | |
| 276 | * Saves the entire file contents from the path associated with this tab. | |
| 277 | * | |
| 278 | * @return true The file has been saved. | |
| 279 | */ | |
| 280 | public boolean save() { | |
| 281 | try { | |
| 282 | Files.write( getPath(), asBytes( getEditorPane().getText() ) ); | |
| 283 | getEditorPane().getUndoManager().mark(); | |
| 284 | return true; | |
| 285 | } catch( final Exception ex ) { | |
| 286 | return alert( | |
| 287 | "FileEditor.saveFailed.title", "FileEditor.saveFailed.message", ex | |
| 288 | ); | |
| 289 | } | |
| 290 | } | |
| 291 | ||
| 292 | /** | |
| 293 | * Creates an alert dialog and waits for it to close. | |
| 294 | * | |
| 295 | * @param titleKey Resource bundle key for the alert dialog title. | |
| 296 | * @param messageKey Resource bundle key for the alert dialog message. | |
| 297 | * @param e The unexpected happening. | |
| 298 | * | |
| 299 | * @return false | |
| 300 | */ | |
| 301 | private boolean alert( | |
| 302 | final String titleKey, final String messageKey, final Exception e ) { | |
| 303 | final Notifier service = getNotifyService(); | |
| 304 | final Path filePath = getPath(); | |
| 305 | ||
| 306 | final Notification message = service.createNotification( | |
| 307 | Messages.get( titleKey ), | |
| 308 | Messages.get( messageKey ), | |
| 309 | filePath == null ? "" : filePath, | |
| 310 | e.getMessage() | |
| 311 | ); | |
| 312 | ||
| 313 | try { | |
| 314 | service.createError( getWindow(), message ).showAndWait(); | |
| 315 | } catch( final Exception ex ) { | |
| 316 | getNotifyService().notify( ex ); | |
| 317 | } | |
| 318 | ||
| 319 | return false; | |
| 320 | } | |
| 321 | ||
| 322 | private Window getWindow() { | |
| 323 | final Scene scene = getEditorPane().getScene(); | |
| 324 | ||
| 325 | if( scene == null ) { | |
| 326 | throw new UnsupportedOperationException( "" ); | |
| 327 | } | |
| 328 | ||
| 329 | return scene.getWindow(); | |
| 330 | } | |
| 331 | ||
| 332 | /** | |
| 333 | * Returns a best guess at the file encoding. If the encoding could not be | |
| 334 | * detected, this will return the default charset for the JVM. | |
| 335 | * | |
| 336 | * @param bytes The bytes to perform character encoding detection. | |
| 337 | * | |
| 338 | * @return The character encoding. | |
| 339 | */ | |
| 340 | private Charset detectEncoding( final byte[] bytes ) { | |
| 341 | final UniversalDetector detector = new UniversalDetector( null ); | |
| 342 | detector.handleData( bytes, 0, bytes.length ); | |
| 343 | detector.dataEnd(); | |
| 344 | ||
| 345 | final String charset = detector.getDetectedCharset(); | |
| 346 | final Charset charEncoding = charset == null | |
| 347 | ? Charset.defaultCharset() | |
| 348 | : Charset.forName( charset.toUpperCase( ENGLISH ) ); | |
| 349 | ||
| 350 | detector.reset(); | |
| 351 | ||
| 352 | return charEncoding; | |
| 353 | } | |
| 354 | ||
| 355 | /** | |
| 356 | * Converts the given string to an array of bytes using the encoding that was | |
| 357 | * originally detected (if any) and associated with this file. | |
| 358 | * | |
| 359 | * @param text The text to convert into the original file encoding. | |
| 360 | * | |
| 361 | * @return A series of bytes ready for writing to a file. | |
| 362 | */ | |
| 363 | private byte[] asBytes( final String text ) { | |
| 364 | return text.getBytes( getEncoding() ); | |
| 365 | } | |
| 366 | ||
| 367 | /** | |
| 368 | * Converts the given bytes into a Java String. This will call setEncoding | |
| 369 | * with the encoding detected by the CharsetDetector. | |
| 370 | * | |
| 371 | * @param text The text of unknown character encoding. | |
| 372 | * | |
| 373 | * @return The text, in its auto-detected encoding, as a String. | |
| 374 | */ | |
| 375 | private String asString( final byte[] text ) { | |
| 376 | setEncoding( detectEncoding( text ) ); | |
| 377 | return new String( text, getEncoding() ); | |
| 378 | } | |
| 379 | ||
| 380 | public Path getPath() { | |
| 381 | return this.path; | |
| 382 | } | |
| 383 | ||
| 384 | public void setPath( final Path path ) { | |
| 385 | this.path = path; | |
| 386 | ||
| 387 | updateTab(); | |
| 388 | } | |
| 389 | ||
| 390 | /** | |
| 391 | * Answers whether this tab has an initialized path reference. | |
| 392 | * | |
| 393 | * @return false This tab has no path. | |
| 394 | */ | |
| 395 | public boolean isFileOpen() { | |
| 396 | return this.path != null; | |
| 397 | } | |
| 398 | ||
| 399 | public boolean isModified() { | |
| 400 | return this.modified.get(); | |
| 401 | } | |
| 402 | ||
| 403 | ReadOnlyBooleanProperty modifiedProperty() { | |
| 404 | return this.modified.getReadOnlyProperty(); | |
| 405 | } | |
| 406 | ||
| 407 | BooleanProperty canUndoProperty() { | |
| 408 | return this.canUndo; | |
| 409 | } | |
| 410 | ||
| 411 | BooleanProperty canRedoProperty() { | |
| 412 | return this.canRedo; | |
| 413 | } | |
| 414 | ||
| 415 | private UndoManager getUndoManager() { | |
| 416 | return getEditorPane().getUndoManager(); | |
| 417 | } | |
| 418 | ||
| 419 | /** | |
| 420 | * Forwards the request to the editor pane. | |
| 421 | * | |
| 422 | * @param <T> The type of event listener to add. | |
| 423 | * @param <U> The type of consumer to add. | |
| 424 | * @param event The event that should trigger updates to the listener. | |
| 425 | * @param consumer The listener to receive update events. | |
| 426 | */ | |
| 427 | public <T extends Event, U extends T> void addEventListener( | |
| 428 | final EventPattern<? super T, ? extends U> event, | |
| 429 | final Consumer<? super U> consumer ) { | |
| 430 | getEditorPane().addEventListener( event, consumer ); | |
| 431 | } | |
| 432 | ||
| 433 | /** | |
| 434 | * Forwards to the editor pane's listeners for keyboard events. | |
| 435 | * | |
| 436 | * @param map The new input map to replace the existing keyboard listener. | |
| 437 | */ | |
| 438 | public void addEventListener( final InputMap<InputEvent> map ) { | |
| 439 | getEditorPane().addEventListener( map ); | |
| 440 | } | |
| 441 | ||
| 442 | /** | |
| 443 | * Forwards to the editor pane's listeners for keyboard events. | |
| 444 | * | |
| 445 | * @param map The existing input map to remove from the keyboard listeners. | |
| 446 | */ | |
| 447 | public void removeEventListener( final InputMap<InputEvent> map ) { | |
| 448 | getEditorPane().removeEventListener( map ); | |
| 449 | } | |
| 450 | ||
| 451 | /** | |
| 452 | * Forwards to the editor pane's listeners for text change events. | |
| 453 | * | |
| 454 | * @param listener The listener to notify when the text changes. | |
| 455 | */ | |
| 456 | public void addTextChangeListener( final ChangeListener<String> listener ) { | |
| 457 | getEditorPane().addTextChangeListener( listener ); | |
| 458 | } | |
| 459 | ||
| 460 | /** | |
| 461 | * Forwards to the editor pane's listeners for caret paragraph change events. | |
| 462 | * | |
| 463 | * @param listener The listener to notify when the caret changes paragraphs. | |
| 464 | */ | |
| 465 | public void addCaretParagraphListener( final ChangeListener<Integer> listener ) { | |
| 466 | getEditorPane().addCaretParagraphListener( listener ); | |
| 467 | } | |
| 468 | ||
| 469 | /** | |
| 470 | * Forwards the request to the editor pane. | |
| 471 | * | |
| 472 | * @return The text to process. | |
| 473 | */ | |
| 474 | public String getEditorText() { | |
| 475 | return getEditorPane().getText(); | |
| 476 | } | |
| 477 | ||
| 478 | /** | |
| 479 | * Returns the editor pane, or creates one if it doesn't yet exist. | |
| 480 | * | |
| 481 | * @return The editor pane, never null. | |
| 482 | */ | |
| 483 | public synchronized EditorPane getEditorPane() { | |
| 484 | if( this.editorPane == null ) { | |
| 485 | this.editorPane = new MarkdownEditorPane(); | |
| 486 | } | |
| 487 | ||
| 488 | return this.editorPane; | |
| 489 | } | |
| 490 | ||
| 491 | private Notifier getNotifyService() { | |
| 492 | return this.alertService; | |
| 493 | } | |
| 494 | ||
| 495 | /** | |
| 496 | * Returns the encoding for the file, defaulting to UTF-8 if it hasn't been | |
| 497 | * determined. | |
| 498 | * | |
| 499 | * @return The file encoding or UTF-8 if unknown. | |
| 500 | */ | |
| 501 | private Charset getEncoding() { | |
| 502 | if( this.encoding == null ) { | |
| 503 | this.encoding = UTF_8; | |
| 504 | } | |
| 505 | ||
| 484 | 506 | return this.encoding; |
| 485 | 507 | } |
| 68 | 68 | import org.fxmisc.wellbehaved.event.EventPattern; |
| 69 | 69 | import org.fxmisc.wellbehaved.event.InputMap; |
| 70 | import static com.scrivenvar.Messages.get; | |
| 71 | import static com.scrivenvar.Messages.get; | |
| 72 | import static com.scrivenvar.Messages.get; | |
| 73 | import static com.scrivenvar.Messages.get; | |
| 74 | import static com.scrivenvar.Messages.get; | |
| 75 | import static com.scrivenvar.Messages.get; | |
| 76 | import static com.scrivenvar.Messages.get; | |
| 77 | ||
| 78 | /** | |
| 79 | * Tab pane for file editors. | |
| 80 | * | |
| 81 | * @author Karl Tauber and White Magic Software, Ltd. | |
| 82 | */ | |
| 83 | public final class FileEditorTabPane extends TabPane { | |
| 84 | ||
| 85 | private final static String FILTER_EXTENSION_TITLES = "Dialog.file.choose.filter"; | |
| 86 | ||
| 87 | private final Options options = Services.load( Options.class ); | |
| 88 | private final Settings settings = Services.load( Settings.class ); | |
| 89 | private final Notifier notifyService = Services.load(Notifier.class ); | |
| 90 | ||
| 91 | private final ReadOnlyObjectWrapper<Path> openDefinition = new ReadOnlyObjectWrapper<>(); | |
| 92 | private final ReadOnlyObjectWrapper<FileEditorTab> activeFileEditor = new ReadOnlyObjectWrapper<>(); | |
| 93 | private final ReadOnlyBooleanWrapper anyFileEditorModified = new ReadOnlyBooleanWrapper(); | |
| 94 | ||
| 95 | /** | |
| 96 | * Constructs a new file editor tab pane. | |
| 97 | */ | |
| 98 | public FileEditorTabPane() { | |
| 99 | final ObservableList<Tab> tabs = getTabs(); | |
| 100 | ||
| 101 | setFocusTraversable( false ); | |
| 102 | setTabClosingPolicy( TabClosingPolicy.ALL_TABS ); | |
| 103 | ||
| 104 | addTabSelectionListener( | |
| 105 | (ObservableValue<? extends Tab> tabPane, | |
| 106 | final Tab oldTab, final Tab newTab) -> { | |
| 107 | ||
| 108 | if( newTab != null ) { | |
| 109 | activeFileEditor.set( (FileEditorTab)newTab ); | |
| 110 | } | |
| 111 | } | |
| 112 | ); | |
| 113 | ||
| 114 | final ChangeListener<Boolean> modifiedListener = (observable, oldValue, newValue) -> { | |
| 115 | for( final Tab tab : tabs ) { | |
| 116 | if( ((FileEditorTab)tab).isModified() ) { | |
| 117 | this.anyFileEditorModified.set( true ); | |
| 118 | break; | |
| 119 | } | |
| 120 | } | |
| 121 | }; | |
| 122 | ||
| 123 | tabs.addListener( | |
| 124 | (ListChangeListener<Tab>)change -> { | |
| 125 | while( change.next() ) { | |
| 126 | if( change.wasAdded() ) { | |
| 127 | change.getAddedSubList().stream().forEach( (tab) -> { | |
| 128 | ((FileEditorTab)tab).modifiedProperty().addListener( modifiedListener ); | |
| 129 | } ); | |
| 130 | } else if( change.wasRemoved() ) { | |
| 131 | change.getRemoved().stream().forEach( (tab) -> { | |
| 132 | ((FileEditorTab)tab).modifiedProperty().removeListener( modifiedListener ); | |
| 133 | } ); | |
| 134 | } | |
| 135 | } | |
| 136 | ||
| 137 | // Changes in the tabs may also change anyFileEditorModified property | |
| 138 | // (e.g. closed modified file) | |
| 139 | modifiedListener.changed( null, null, null ); | |
| 140 | } | |
| 141 | ); | |
| 142 | } | |
| 143 | ||
| 144 | /** | |
| 145 | * Delegates to the active file editor. | |
| 146 | * | |
| 147 | * @param <T> Event type. | |
| 148 | * @param <U> Consumer type. | |
| 149 | * @param event Event to pass to the editor. | |
| 150 | * @param consumer Consumer to pass to the editor. | |
| 151 | */ | |
| 152 | public <T extends Event, U extends T> void addEventListener( | |
| 153 | final EventPattern<? super T, ? extends U> event, | |
| 154 | final Consumer<? super U> consumer ) { | |
| 155 | getActiveFileEditor().addEventListener( event, consumer ); | |
| 156 | } | |
| 157 | ||
| 158 | /** | |
| 159 | * Delegates to the active file editor pane, and, ultimately, to its text | |
| 160 | * area. | |
| 161 | * | |
| 162 | * @param map The map of methods to events. | |
| 163 | */ | |
| 164 | public void addEventListener( final InputMap<InputEvent> map ) { | |
| 165 | getActiveFileEditor().addEventListener( map ); | |
| 166 | } | |
| 167 | ||
| 168 | /** | |
| 169 | * Remove a keyboard event listener from the active file editor. | |
| 170 | * | |
| 171 | * @param map The keyboard events to remove. | |
| 172 | */ | |
| 173 | public void removeEventListener( final InputMap<InputEvent> map ) { | |
| 174 | getActiveFileEditor().removeEventListener( map ); | |
| 175 | } | |
| 176 | ||
| 177 | /** | |
| 178 | * Allows observers to be notified when the current file editor tab changes. | |
| 179 | * | |
| 180 | * @param listener The listener to notify of tab change events. | |
| 181 | */ | |
| 182 | public void addTabSelectionListener( final ChangeListener<Tab> listener ) { | |
| 183 | // Observe the tab so that when a new tab is opened or selected, | |
| 184 | // a notification is kicked off. | |
| 185 | getSelectionModel().selectedItemProperty().addListener( listener ); | |
| 186 | } | |
| 187 | ||
| 188 | /** | |
| 189 | * Allows clients to manipulate the editor content directly. | |
| 190 | * | |
| 191 | * @return The text area for the active file editor. | |
| 192 | */ | |
| 193 | public StyledTextArea getEditor() { | |
| 194 | return getActiveFileEditor().getEditorPane().getEditor(); | |
| 195 | } | |
| 196 | ||
| 197 | public FileEditorTab getActiveFileEditor() { | |
| 198 | return this.activeFileEditor.get(); | |
| 199 | } | |
| 200 | ||
| 201 | public ReadOnlyObjectProperty<FileEditorTab> activeFileEditorProperty() { | |
| 202 | return this.activeFileEditor.getReadOnlyProperty(); | |
| 203 | } | |
| 204 | ||
| 205 | ReadOnlyBooleanProperty anyFileEditorModifiedProperty() { | |
| 206 | return this.anyFileEditorModified.getReadOnlyProperty(); | |
| 207 | } | |
| 208 | ||
| 209 | private FileEditorTab createFileEditor( final Path path ) { | |
| 210 | final FileEditorTab tab = new FileEditorTab( path ); | |
| 211 | ||
| 212 | tab.setOnCloseRequest( e -> { | |
| 213 | if( !canCloseEditor( tab ) ) { | |
| 214 | e.consume(); | |
| 215 | } | |
| 216 | } ); | |
| 217 | ||
| 218 | return tab; | |
| 219 | } | |
| 220 | ||
| 221 | /** | |
| 222 | * Called when the user selects New from the File menu. | |
| 223 | * | |
| 224 | * @return The newly added tab. | |
| 225 | */ | |
| 226 | void newEditor() { | |
| 227 | final FileEditorTab tab = createFileEditor( null ); | |
| 228 | ||
| 229 | getTabs().add( tab ); | |
| 230 | getSelectionModel().select( tab ); | |
| 231 | } | |
| 232 | ||
| 233 | void openFileDialog() { | |
| 234 | final String title = get( "Dialog.file.choose.open.title" ); | |
| 235 | final FileChooser dialog = createFileChooser( title ); | |
| 236 | final List<File> files = dialog.showOpenMultipleDialog( getWindow() ); | |
| 237 | ||
| 238 | if( files != null ) { | |
| 239 | openFiles( files ); | |
| 240 | } | |
| 241 | } | |
| 242 | ||
| 243 | /** | |
| 244 | * Opens the files into new editors, unless one of those files was a | |
| 245 | * definition file. The definition file is loaded into the definition pane, | |
| 246 | * but only the first one selected (multiple definition files will result in a | |
| 247 | * warning). | |
| 248 | * | |
| 249 | * @param files The list of non-definition files that the were requested to | |
| 250 | * open. | |
| 251 | * | |
| 252 | * @return A list of files that can be opened in text editors. | |
| 253 | */ | |
| 254 | private void openFiles( final List<File> files ) { | |
| 255 | final FileTypePredicate predicate | |
| 256 | = new FileTypePredicate( createExtensionFilter( DEFINITION ).getExtensions() ); | |
| 257 | ||
| 258 | // The user might have opened multiple definitions files. These will | |
| 259 | // be discarded from the text editable files. | |
| 260 | final List<File> definitions | |
| 261 | = files.stream().filter( predicate ).collect( Collectors.toList() ); | |
| 262 | ||
| 263 | // Create a modifiable list to remove any definition files that were | |
| 264 | // opened. | |
| 265 | final List<File> editors = new ArrayList<>( files ); | |
| 266 | ||
| 267 | if( editors.size() > 0 ) { | |
| 268 | saveLastDirectory( editors.get( 0 ) ); | |
| 269 | } | |
| 270 | ||
| 271 | editors.removeAll( definitions ); | |
| 272 | ||
| 273 | // Open editor-friendly files (e.g,. Markdown, XML) in new tabs. | |
| 274 | if( editors.size() > 0 ) { | |
| 275 | openEditors( editors, 0 ); | |
| 276 | } | |
| 277 | ||
| 278 | if( definitions.size() > 0 ) { | |
| 279 | openDefinition( definitions.get( 0 ) ); | |
| 280 | } | |
| 281 | } | |
| 282 | ||
| 283 | private void openEditors( final List<File> files, final int activeIndex ) { | |
| 284 | final int fileTally = files.size(); | |
| 285 | final List<Tab> tabs = getTabs(); | |
| 286 | ||
| 287 | // Close single unmodified "Untitled" tab. | |
| 288 | if( tabs.size() == 1 ) { | |
| 289 | final FileEditorTab fileEditor = (FileEditorTab)(tabs.get( 0 )); | |
| 290 | ||
| 291 | if( fileEditor.getPath() == null && !fileEditor.isModified() ) { | |
| 292 | closeEditor( fileEditor, false ); | |
| 293 | } | |
| 294 | } | |
| 295 | ||
| 296 | for( int i = 0; i < fileTally; i++ ) { | |
| 297 | final Path path = files.get( i ).toPath(); | |
| 298 | ||
| 299 | FileEditorTab fileEditorTab = findEditor( path ); | |
| 300 | ||
| 301 | // Only open new files. | |
| 302 | if( fileEditorTab == null ) { | |
| 303 | fileEditorTab = createFileEditor( path ); | |
| 304 | getTabs().add( fileEditorTab ); | |
| 305 | } | |
| 306 | ||
| 307 | // Select the first file in the list. | |
| 308 | if( i == activeIndex ) { | |
| 309 | getSelectionModel().select( fileEditorTab ); | |
| 310 | } | |
| 311 | } | |
| 312 | } | |
| 313 | ||
| 314 | /** | |
| 315 | * Returns a property that changes when a new definition file is opened. | |
| 316 | * | |
| 317 | * @return The path to a definition file that was opened. | |
| 318 | */ | |
| 319 | public ReadOnlyObjectProperty<Path> onOpenDefinitionFileProperty() { | |
| 320 | return getOnOpenDefinitionFile().getReadOnlyProperty(); | |
| 321 | } | |
| 322 | ||
| 323 | private ReadOnlyObjectWrapper<Path> getOnOpenDefinitionFile() { | |
| 324 | return this.openDefinition; | |
| 325 | } | |
| 326 | ||
| 327 | /** | |
| 328 | * Called when the user has opened a definition file (using the file open | |
| 329 | * dialog box). This will replace the current set of definitions for the | |
| 330 | * active tab. | |
| 331 | * | |
| 332 | * @param definition The file to open. | |
| 333 | */ | |
| 334 | private void openDefinition( final File definition ) { | |
| 335 | // TODO: Prevent reading this file twice when a new text document is opened. | |
| 336 | // (might be a matter of checking the value first). | |
| 337 | getOnOpenDefinitionFile().set( definition.toPath() ); | |
| 338 | } | |
| 339 | ||
| 340 | boolean saveEditor( final FileEditorTab fileEditor ) { | |
| 341 | if( fileEditor == null || !fileEditor.isModified() ) { | |
| 342 | return true; | |
| 343 | } | |
| 344 | ||
| 345 | if( fileEditor.getPath() == null ) { | |
| 346 | getSelectionModel().select( fileEditor ); | |
| 347 | ||
| 348 | final FileChooser fileChooser = createFileChooser( Messages.get( "Dialog.file.choose.save.title" ) ); | |
| 349 | final File file = fileChooser.showSaveDialog( getWindow() ); | |
| 350 | if( file == null ) { | |
| 351 | return false; | |
| 352 | } | |
| 353 | ||
| 354 | saveLastDirectory( file ); | |
| 355 | fileEditor.setPath( file.toPath() ); | |
| 356 | } | |
| 357 | ||
| 358 | return fileEditor.save(); | |
| 359 | } | |
| 360 | ||
| 361 | boolean saveAllEditors() { | |
| 362 | boolean success = true; | |
| 363 | ||
| 364 | for( FileEditorTab fileEditor : getAllEditors() ) { | |
| 365 | if( !saveEditor( fileEditor ) ) { | |
| 366 | success = false; | |
| 367 | } | |
| 368 | } | |
| 369 | ||
| 370 | return success; | |
| 371 | } | |
| 372 | ||
| 373 | /** | |
| 374 | * Answers whether the file has had modifications. ' | |
| 375 | * | |
| 376 | * @param tab THe tab to check for modifications. | |
| 377 | * | |
| 378 | * @return false The file is unmodified. | |
| 379 | */ | |
| 380 | boolean canCloseEditor( final FileEditorTab tab ) { | |
| 381 | if( !tab.isModified() ) { | |
| 382 | return true; | |
| 383 | } | |
| 384 | ||
| 385 | final Notification message = getNotifyService().createNotification( | |
| 386 | Messages.get( "Alert.file.close.title" ), | |
| 387 | Messages.get( "Alert.file.close.text" ), | |
| 388 | tab.getText() | |
| 389 | ); | |
| 390 | ||
| 391 | final Alert alert = getNotifyService().createConfirmation( | |
| 392 | getWindow(), message ); | |
| 393 | final ButtonType response = alert.showAndWait().get(); | |
| 394 | ||
| 395 | return response == YES ? saveEditor( tab ) : response == NO; | |
| 396 | } | |
| 397 | ||
| 398 | private Notifier getNotifyService() { | |
| 399 | return this.notifyService; | |
| 400 | } | |
| 401 | ||
| 402 | boolean closeEditor( FileEditorTab fileEditor, boolean save ) { | |
| 403 | if( fileEditor == null ) { | |
| 404 | return true; | |
| 405 | } | |
| 406 | ||
| 407 | final Tab tab = fileEditor; | |
| 408 | ||
| 409 | if( save ) { | |
| 410 | Event event = new Event( tab, tab, Tab.TAB_CLOSE_REQUEST_EVENT ); | |
| 411 | Event.fireEvent( tab, event ); | |
| 412 | ||
| 413 | if( event.isConsumed() ) { | |
| 414 | return false; | |
| 415 | } | |
| 416 | } | |
| 417 | ||
| 418 | getTabs().remove( tab ); | |
| 419 | ||
| 420 | if( tab.getOnClosed() != null ) { | |
| 421 | Event.fireEvent( tab, new Event( Tab.CLOSED_EVENT ) ); | |
| 422 | } | |
| 423 | ||
| 424 | return true; | |
| 425 | } | |
| 426 | ||
| 427 | boolean closeAllEditors() { | |
| 428 | final FileEditorTab[] allEditors = getAllEditors(); | |
| 429 | final FileEditorTab activeEditor = getActiveFileEditor(); | |
| 430 | ||
| 431 | // try to save active tab first because in case the user decides to cancel, | |
| 432 | // then it stays active | |
| 433 | if( activeEditor != null && !canCloseEditor( activeEditor ) ) { | |
| 434 | return false; | |
| 435 | } | |
| 436 | ||
| 437 | // This should be called any time a tab changes. | |
| 438 | persistPreferences(); | |
| 439 | ||
| 440 | // save modified tabs | |
| 441 | for( int i = 0; i < allEditors.length; i++ ) { | |
| 442 | final FileEditorTab fileEditor = allEditors[ i ]; | |
| 443 | ||
| 444 | if( fileEditor == activeEditor ) { | |
| 445 | continue; | |
| 446 | } | |
| 447 | ||
| 448 | if( fileEditor.isModified() ) { | |
| 449 | // activate the modified tab to make its modified content visible to the user | |
| 450 | getSelectionModel().select( i ); | |
| 451 | ||
| 452 | if( !canCloseEditor( fileEditor ) ) { | |
| 453 | return false; | |
| 454 | } | |
| 455 | } | |
| 456 | } | |
| 457 | ||
| 458 | // Close all tabs. | |
| 459 | for( final FileEditorTab fileEditor : allEditors ) { | |
| 460 | if( !closeEditor( fileEditor, false ) ) { | |
| 461 | return false; | |
| 462 | } | |
| 463 | } | |
| 464 | ||
| 465 | return getTabs().isEmpty(); | |
| 466 | } | |
| 467 | ||
| 468 | private FileEditorTab[] getAllEditors() { | |
| 469 | final ObservableList<Tab> tabs = getTabs(); | |
| 470 | final int length = tabs.size(); | |
| 471 | final FileEditorTab[] allEditors = new FileEditorTab[ length ]; | |
| 472 | ||
| 473 | for( int i = 0; i < length; i++ ) { | |
| 474 | allEditors[ i ] = (FileEditorTab)tabs.get( i ); | |
| 475 | } | |
| 476 | ||
| 477 | return allEditors; | |
| 478 | } | |
| 479 | ||
| 480 | /** | |
| 481 | * Returns the file editor tab that has the given path. | |
| 482 | * | |
| 483 | * @return null No file editor tab for the given path was found. | |
| 484 | */ | |
| 485 | private FileEditorTab findEditor( final Path path ) { | |
| 486 | for( final Tab tab : getTabs() ) { | |
| 487 | final FileEditorTab fileEditor = (FileEditorTab)tab; | |
| 488 | ||
| 489 | if( fileEditor.isPath( path ) ) { | |
| 490 | return fileEditor; | |
| 491 | } | |
| 492 | } | |
| 493 | ||
| 494 | return null; | |
| 495 | } | |
| 496 | ||
| 497 | private FileChooser createFileChooser( String title ) { | |
| 498 | final FileChooser fileChooser = new FileChooser(); | |
| 499 | ||
| 500 | fileChooser.setTitle( title ); | |
| 501 | fileChooser.getExtensionFilters().addAll( | |
| 502 | createExtensionFilters() ); | |
| 503 | ||
| 504 | final String lastDirectory = getPreferences().get( "lastDirectory", null ); | |
| 505 | File file = new File( (lastDirectory != null) ? lastDirectory : "." ); | |
| 506 | ||
| 507 | if( !file.isDirectory() ) { | |
| 508 | file = new File( "." ); | |
| 509 | } | |
| 510 | ||
| 511 | fileChooser.setInitialDirectory( file ); | |
| 512 | return fileChooser; | |
| 513 | } | |
| 514 | ||
| 515 | private List<ExtensionFilter> createExtensionFilters() { | |
| 516 | final List<ExtensionFilter> list = new ArrayList<>(); | |
| 517 | ||
| 518 | // TODO: Return a list of all properties that match the filter prefix. | |
| 519 | // This will allow dynamic filters to be added and removed just by | |
| 520 | // updating the properties file. | |
| 521 | list.add( createExtensionFilter( MARKDOWN ) ); | |
| 522 | list.add( createExtensionFilter( DEFINITION ) ); | |
| 523 | list.add( createExtensionFilter( XML ) ); | |
| 524 | list.add( createExtensionFilter( ALL ) ); | |
| 525 | return list; | |
| 526 | } | |
| 527 | ||
| 528 | /** | |
| 529 | * Returns a filter for file name extensions recognized by the application | |
| 530 | * that can be opened by the user. | |
| 531 | * | |
| 532 | * @param filetype Used to find the globbing pattern for extensions. | |
| 533 | * | |
| 534 | * @return A filename filter suitable for use by a FileDialog instance. | |
| 535 | */ | |
| 536 | private ExtensionFilter createExtensionFilter( final FileType filetype ) { | |
| 537 | final String tKey = String.format( "%s.title.%s", FILTER_EXTENSION_TITLES, filetype ); | |
| 538 | final String eKey = String.format( "%s.%s", GLOB_PREFIX_FILE, filetype ); | |
| 539 | ||
| 540 | return new ExtensionFilter( Messages.get( tKey ), getExtensions( eKey ) ); | |
| 541 | } | |
| 542 | ||
| 543 | private List<String> getExtensions( final String key ) { | |
| 544 | return getSettings().getStringSettingList( key ); | |
| 545 | } | |
| 546 | ||
| 547 | private void saveLastDirectory( final File file ) { | |
| 548 | getPreferences().put( "lastDirectory", file.getParent() ); | |
| 549 | } | |
| 550 | ||
| 551 | public void restorePreferences() { | |
| 552 | int activeIndex = 0; | |
| 553 | ||
| 554 | final Preferences preferences = getPreferences(); | |
| 555 | final String[] fileNames = Utils.getPrefsStrings( preferences, "file" ); | |
| 556 | final String activeFileName = preferences.get( "activeFile", null ); | |
| 557 | ||
| 558 | final ArrayList<File> files = new ArrayList<>( fileNames.length ); | |
| 559 | ||
| 560 | for( final String fileName : fileNames ) { | |
| 561 | final File file = new File( fileName ); | |
| 562 | ||
| 563 | if( file.exists() ) { | |
| 564 | files.add( file ); | |
| 565 | ||
| 566 | if( fileName.equals( activeFileName ) ) { | |
| 567 | activeIndex = files.size() - 1; | |
| 568 | } | |
| 569 | } | |
| 570 | } | |
| 571 | ||
| 572 | if( files.isEmpty() ) { | |
| 573 | newEditor(); | |
| 574 | } else { | |
| 575 | openEditors( files, activeIndex ); | |
| 576 | } | |
| 577 | } | |
| 578 | ||
| 579 | public void persistPreferences() { | |
| 580 | final ObservableList<Tab> allEditors = getTabs(); | |
| 581 | final List<String> fileNames = new ArrayList<>( allEditors.size() ); | |
| 582 | ||
| 583 | for( final Tab tab : allEditors ) { | |
| 584 | final FileEditorTab fileEditor = (FileEditorTab)tab; | |
| 585 | final Path filePath = fileEditor.getPath(); | |
| 586 | ||
| 587 | if( filePath != null ) { | |
| 588 | fileNames.add( filePath.toString() ); | |
| 589 | } | |
| 590 | } | |
| 591 | ||
| 592 | final Preferences preferences = getPreferences(); | |
| 593 | Utils.putPrefsStrings( preferences, "file", fileNames.toArray( new String[ fileNames.size() ] ) ); | |
| 594 | ||
| 595 | final FileEditorTab activeEditor = getActiveFileEditor(); | |
| 596 | final Path filePath = activeEditor == null ? null : activeEditor.getPath(); | |
| 597 | ||
| 598 | if( filePath == null ) { | |
| 599 | preferences.remove( "activeFile" ); | |
| 600 | } else { | |
| 70 | ||
| 71 | /** | |
| 72 | * Tab pane for file editors. | |
| 73 | * | |
| 74 | * @author Karl Tauber and White Magic Software, Ltd. | |
| 75 | */ | |
| 76 | public final class FileEditorTabPane extends TabPane { | |
| 77 | ||
| 78 | private final static String FILTER_EXTENSION_TITLES = "Dialog.file.choose.filter"; | |
| 79 | ||
| 80 | private final Options options = Services.load( Options.class ); | |
| 81 | private final Settings settings = Services.load( Settings.class ); | |
| 82 | private final Notifier notifyService = Services.load( Notifier.class ); | |
| 83 | ||
| 84 | private final ReadOnlyObjectWrapper<Path> openDefinition = new ReadOnlyObjectWrapper<>(); | |
| 85 | private final ReadOnlyObjectWrapper<FileEditorTab> activeFileEditor = new ReadOnlyObjectWrapper<>(); | |
| 86 | private final ReadOnlyBooleanWrapper anyFileEditorModified = new ReadOnlyBooleanWrapper(); | |
| 87 | ||
| 88 | /** | |
| 89 | * Constructs a new file editor tab pane. | |
| 90 | */ | |
| 91 | public FileEditorTabPane() { | |
| 92 | final ObservableList<Tab> tabs = getTabs(); | |
| 93 | ||
| 94 | setFocusTraversable( false ); | |
| 95 | setTabClosingPolicy( TabClosingPolicy.ALL_TABS ); | |
| 96 | ||
| 97 | addTabSelectionListener( | |
| 98 | (ObservableValue<? extends Tab> tabPane, | |
| 99 | final Tab oldTab, final Tab newTab) -> { | |
| 100 | ||
| 101 | if( newTab != null ) { | |
| 102 | activeFileEditor.set( (FileEditorTab)newTab ); | |
| 103 | } | |
| 104 | } | |
| 105 | ); | |
| 106 | ||
| 107 | final ChangeListener<Boolean> modifiedListener = (observable, oldValue, newValue) -> { | |
| 108 | for( final Tab tab : tabs ) { | |
| 109 | if( ((FileEditorTab)tab).isModified() ) { | |
| 110 | this.anyFileEditorModified.set( true ); | |
| 111 | break; | |
| 112 | } | |
| 113 | } | |
| 114 | }; | |
| 115 | ||
| 116 | tabs.addListener( | |
| 117 | (ListChangeListener<Tab>)change -> { | |
| 118 | while( change.next() ) { | |
| 119 | if( change.wasAdded() ) { | |
| 120 | change.getAddedSubList().stream().forEach( (tab) -> { | |
| 121 | ((FileEditorTab)tab).modifiedProperty().addListener( modifiedListener ); | |
| 122 | } ); | |
| 123 | } | |
| 124 | else if( change.wasRemoved() ) { | |
| 125 | change.getRemoved().stream().forEach( (tab) -> { | |
| 126 | ((FileEditorTab)tab).modifiedProperty().removeListener( modifiedListener ); | |
| 127 | } ); | |
| 128 | } | |
| 129 | } | |
| 130 | ||
| 131 | // Changes in the tabs may also change anyFileEditorModified property | |
| 132 | // (e.g. closed modified file) | |
| 133 | modifiedListener.changed( null, null, null ); | |
| 134 | } | |
| 135 | ); | |
| 136 | } | |
| 137 | ||
| 138 | /** | |
| 139 | * Delegates to the active file editor. | |
| 140 | * | |
| 141 | * @param <T> Event type. | |
| 142 | * @param <U> Consumer type. | |
| 143 | * @param event Event to pass to the editor. | |
| 144 | * @param consumer Consumer to pass to the editor. | |
| 145 | */ | |
| 146 | public <T extends Event, U extends T> void addEventListener( | |
| 147 | final EventPattern<? super T, ? extends U> event, | |
| 148 | final Consumer<? super U> consumer ) { | |
| 149 | getActiveFileEditor().addEventListener( event, consumer ); | |
| 150 | } | |
| 151 | ||
| 152 | /** | |
| 153 | * Delegates to the active file editor pane, and, ultimately, to its text | |
| 154 | * area. | |
| 155 | * | |
| 156 | * @param map The map of methods to events. | |
| 157 | */ | |
| 158 | public void addEventListener( final InputMap<InputEvent> map ) { | |
| 159 | getActiveFileEditor().addEventListener( map ); | |
| 160 | } | |
| 161 | ||
| 162 | /** | |
| 163 | * Remove a keyboard event listener from the active file editor. | |
| 164 | * | |
| 165 | * @param map The keyboard events to remove. | |
| 166 | */ | |
| 167 | public void removeEventListener( final InputMap<InputEvent> map ) { | |
| 168 | getActiveFileEditor().removeEventListener( map ); | |
| 169 | } | |
| 170 | ||
| 171 | /** | |
| 172 | * Allows observers to be notified when the current file editor tab changes. | |
| 173 | * | |
| 174 | * @param listener The listener to notify of tab change events. | |
| 175 | */ | |
| 176 | public void addTabSelectionListener( final ChangeListener<Tab> listener ) { | |
| 177 | // Observe the tab so that when a new tab is opened or selected, | |
| 178 | // a notification is kicked off. | |
| 179 | getSelectionModel().selectedItemProperty().addListener( listener ); | |
| 180 | } | |
| 181 | ||
| 182 | /** | |
| 183 | * Allows clients to manipulate the editor content directly. | |
| 184 | * | |
| 185 | * @return The text area for the active file editor. | |
| 186 | */ | |
| 187 | public StyledTextArea getEditor() { | |
| 188 | return getActiveFileEditor().getEditorPane().getEditor(); | |
| 189 | } | |
| 190 | ||
| 191 | public FileEditorTab getActiveFileEditor() { | |
| 192 | return this.activeFileEditor.get(); | |
| 193 | } | |
| 194 | ||
| 195 | public ReadOnlyObjectProperty<FileEditorTab> activeFileEditorProperty() { | |
| 196 | return this.activeFileEditor.getReadOnlyProperty(); | |
| 197 | } | |
| 198 | ||
| 199 | ReadOnlyBooleanProperty anyFileEditorModifiedProperty() { | |
| 200 | return this.anyFileEditorModified.getReadOnlyProperty(); | |
| 201 | } | |
| 202 | ||
| 203 | private FileEditorTab createFileEditor( final Path path ) { | |
| 204 | final FileEditorTab tab = new FileEditorTab( path ); | |
| 205 | ||
| 206 | tab.setOnCloseRequest( e -> { | |
| 207 | if( !canCloseEditor( tab ) ) { | |
| 208 | e.consume(); | |
| 209 | } | |
| 210 | } ); | |
| 211 | ||
| 212 | return tab; | |
| 213 | } | |
| 214 | ||
| 215 | /** | |
| 216 | * Called when the user selects New from the File menu. | |
| 217 | * | |
| 218 | * @return The newly added tab. | |
| 219 | */ | |
| 220 | void newEditor() { | |
| 221 | final FileEditorTab tab = createFileEditor( null ); | |
| 222 | ||
| 223 | getTabs().add( tab ); | |
| 224 | getSelectionModel().select( tab ); | |
| 225 | } | |
| 226 | ||
| 227 | void openFileDialog() { | |
| 228 | final String title = get( "Dialog.file.choose.open.title" ); | |
| 229 | final FileChooser dialog = createFileChooser( title ); | |
| 230 | final List<File> files = dialog.showOpenMultipleDialog( getWindow() ); | |
| 231 | ||
| 232 | if( files != null ) { | |
| 233 | openFiles( files ); | |
| 234 | } | |
| 235 | } | |
| 236 | ||
| 237 | /** | |
| 238 | * Opens the files into new editors, unless one of those files was a | |
| 239 | * definition file. The definition file is loaded into the definition pane, | |
| 240 | * but only the first one selected (multiple definition files will result in a | |
| 241 | * warning). | |
| 242 | * | |
| 243 | * @param files The list of non-definition files that the were requested to | |
| 244 | * open. | |
| 245 | * | |
| 246 | * @return A list of files that can be opened in text editors. | |
| 247 | */ | |
| 248 | private void openFiles( final List<File> files ) { | |
| 249 | final FileTypePredicate predicate | |
| 250 | = new FileTypePredicate( createExtensionFilter( DEFINITION ).getExtensions() ); | |
| 251 | ||
| 252 | // The user might have opened multiple definitions files. These will | |
| 253 | // be discarded from the text editable files. | |
| 254 | final List<File> definitions | |
| 255 | = files.stream().filter( predicate ).collect( Collectors.toList() ); | |
| 256 | ||
| 257 | // Create a modifiable list to remove any definition files that were | |
| 258 | // opened. | |
| 259 | final List<File> editors = new ArrayList<>( files ); | |
| 260 | ||
| 261 | if( editors.size() > 0 ) { | |
| 262 | saveLastDirectory( editors.get( 0 ) ); | |
| 263 | } | |
| 264 | ||
| 265 | editors.removeAll( definitions ); | |
| 266 | ||
| 267 | // Open editor-friendly files (e.g,. Markdown, XML) in new tabs. | |
| 268 | if( editors.size() > 0 ) { | |
| 269 | openEditors( editors, 0 ); | |
| 270 | } | |
| 271 | ||
| 272 | if( definitions.size() > 0 ) { | |
| 273 | openDefinition( definitions.get( 0 ) ); | |
| 274 | } | |
| 275 | } | |
| 276 | ||
| 277 | private void openEditors( final List<File> files, final int activeIndex ) { | |
| 278 | final int fileTally = files.size(); | |
| 279 | final List<Tab> tabs = getTabs(); | |
| 280 | ||
| 281 | // Close single unmodified "Untitled" tab. | |
| 282 | if( tabs.size() == 1 ) { | |
| 283 | final FileEditorTab fileEditor = (FileEditorTab)(tabs.get( 0 )); | |
| 284 | ||
| 285 | if( fileEditor.getPath() == null && !fileEditor.isModified() ) { | |
| 286 | closeEditor( fileEditor, false ); | |
| 287 | } | |
| 288 | } | |
| 289 | ||
| 290 | for( int i = 0; i < fileTally; i++ ) { | |
| 291 | final Path path = files.get( i ).toPath(); | |
| 292 | ||
| 293 | FileEditorTab fileEditorTab = findEditor( path ); | |
| 294 | ||
| 295 | // Only open new files. | |
| 296 | if( fileEditorTab == null ) { | |
| 297 | fileEditorTab = createFileEditor( path ); | |
| 298 | getTabs().add( fileEditorTab ); | |
| 299 | } | |
| 300 | ||
| 301 | // Select the first file in the list. | |
| 302 | if( i == activeIndex ) { | |
| 303 | getSelectionModel().select( fileEditorTab ); | |
| 304 | } | |
| 305 | } | |
| 306 | } | |
| 307 | ||
| 308 | /** | |
| 309 | * Returns a property that changes when a new definition file is opened. | |
| 310 | * | |
| 311 | * @return The path to a definition file that was opened. | |
| 312 | */ | |
| 313 | public ReadOnlyObjectProperty<Path> onOpenDefinitionFileProperty() { | |
| 314 | return getOnOpenDefinitionFile().getReadOnlyProperty(); | |
| 315 | } | |
| 316 | ||
| 317 | private ReadOnlyObjectWrapper<Path> getOnOpenDefinitionFile() { | |
| 318 | return this.openDefinition; | |
| 319 | } | |
| 320 | ||
| 321 | /** | |
| 322 | * Called when the user has opened a definition file (using the file open | |
| 323 | * dialog box). This will replace the current set of definitions for the | |
| 324 | * active tab. | |
| 325 | * | |
| 326 | * @param definition The file to open. | |
| 327 | */ | |
| 328 | private void openDefinition( final File definition ) { | |
| 329 | // TODO: Prevent reading this file twice when a new text document is opened. | |
| 330 | // (might be a matter of checking the value first). | |
| 331 | getOnOpenDefinitionFile().set( definition.toPath() ); | |
| 332 | } | |
| 333 | ||
| 334 | boolean saveEditor( final FileEditorTab tab ) { | |
| 335 | if( tab == null || !tab.isModified() ) { | |
| 336 | return true; | |
| 337 | } | |
| 338 | ||
| 339 | return tab.getPath() == null ? saveEditorAs( tab ) : tab.save(); | |
| 340 | } | |
| 341 | ||
| 342 | boolean saveEditorAs( final FileEditorTab tab ) { | |
| 343 | if( tab == null ) { | |
| 344 | return true; | |
| 345 | } | |
| 346 | ||
| 347 | getSelectionModel().select( tab ); | |
| 348 | ||
| 349 | final FileChooser fileChooser = createFileChooser( get( "Dialog.file.choose.save.title" ) ); | |
| 350 | final File file = fileChooser.showSaveDialog( getWindow() ); | |
| 351 | if( file == null ) { | |
| 352 | return false; | |
| 353 | } | |
| 354 | ||
| 355 | saveLastDirectory( file ); | |
| 356 | tab.setPath( file.toPath() ); | |
| 357 | ||
| 358 | return tab.save(); | |
| 359 | } | |
| 360 | ||
| 361 | boolean saveAllEditors() { | |
| 362 | boolean success = true; | |
| 363 | ||
| 364 | for( FileEditorTab fileEditor : getAllEditors() ) { | |
| 365 | if( !saveEditor( fileEditor ) ) { | |
| 366 | success = false; | |
| 367 | } | |
| 368 | } | |
| 369 | ||
| 370 | return success; | |
| 371 | } | |
| 372 | ||
| 373 | /** | |
| 374 | * Answers whether the file has had modifications. ' | |
| 375 | * | |
| 376 | * @param tab THe tab to check for modifications. | |
| 377 | * | |
| 378 | * @return false The file is unmodified. | |
| 379 | */ | |
| 380 | boolean canCloseEditor( final FileEditorTab tab ) { | |
| 381 | if( !tab.isModified() ) { | |
| 382 | return true; | |
| 383 | } | |
| 384 | ||
| 385 | final Notification message = getNotifyService().createNotification( | |
| 386 | Messages.get( "Alert.file.close.title" ), | |
| 387 | Messages.get( "Alert.file.close.text" ), | |
| 388 | tab.getText() | |
| 389 | ); | |
| 390 | ||
| 391 | final Alert alert = getNotifyService().createConfirmation( | |
| 392 | getWindow(), message ); | |
| 393 | final ButtonType response = alert.showAndWait().get(); | |
| 394 | ||
| 395 | return response == YES ? saveEditor( tab ) : response == NO; | |
| 396 | } | |
| 397 | ||
| 398 | private Notifier getNotifyService() { | |
| 399 | return this.notifyService; | |
| 400 | } | |
| 401 | ||
| 402 | boolean closeEditor( FileEditorTab fileEditor, boolean save ) { | |
| 403 | if( fileEditor == null ) { | |
| 404 | return true; | |
| 405 | } | |
| 406 | ||
| 407 | final Tab tab = fileEditor; | |
| 408 | ||
| 409 | if( save ) { | |
| 410 | Event event = new Event( tab, tab, Tab.TAB_CLOSE_REQUEST_EVENT ); | |
| 411 | Event.fireEvent( tab, event ); | |
| 412 | ||
| 413 | if( event.isConsumed() ) { | |
| 414 | return false; | |
| 415 | } | |
| 416 | } | |
| 417 | ||
| 418 | getTabs().remove( tab ); | |
| 419 | ||
| 420 | if( tab.getOnClosed() != null ) { | |
| 421 | Event.fireEvent( tab, new Event( Tab.CLOSED_EVENT ) ); | |
| 422 | } | |
| 423 | ||
| 424 | return true; | |
| 425 | } | |
| 426 | ||
| 427 | boolean closeAllEditors() { | |
| 428 | final FileEditorTab[] allEditors = getAllEditors(); | |
| 429 | final FileEditorTab activeEditor = getActiveFileEditor(); | |
| 430 | ||
| 431 | // try to save active tab first because in case the user decides to cancel, | |
| 432 | // then it stays active | |
| 433 | if( activeEditor != null && !canCloseEditor( activeEditor ) ) { | |
| 434 | return false; | |
| 435 | } | |
| 436 | ||
| 437 | // This should be called any time a tab changes. | |
| 438 | persistPreferences(); | |
| 439 | ||
| 440 | // save modified tabs | |
| 441 | for( int i = 0; i < allEditors.length; i++ ) { | |
| 442 | final FileEditorTab fileEditor = allEditors[ i ]; | |
| 443 | ||
| 444 | if( fileEditor == activeEditor ) { | |
| 445 | continue; | |
| 446 | } | |
| 447 | ||
| 448 | if( fileEditor.isModified() ) { | |
| 449 | // activate the modified tab to make its modified content visible to the user | |
| 450 | getSelectionModel().select( i ); | |
| 451 | ||
| 452 | if( !canCloseEditor( fileEditor ) ) { | |
| 453 | return false; | |
| 454 | } | |
| 455 | } | |
| 456 | } | |
| 457 | ||
| 458 | // Close all tabs. | |
| 459 | for( final FileEditorTab fileEditor : allEditors ) { | |
| 460 | if( !closeEditor( fileEditor, false ) ) { | |
| 461 | return false; | |
| 462 | } | |
| 463 | } | |
| 464 | ||
| 465 | return getTabs().isEmpty(); | |
| 466 | } | |
| 467 | ||
| 468 | private FileEditorTab[] getAllEditors() { | |
| 469 | final ObservableList<Tab> tabs = getTabs(); | |
| 470 | final int length = tabs.size(); | |
| 471 | final FileEditorTab[] allEditors = new FileEditorTab[ length ]; | |
| 472 | ||
| 473 | for( int i = 0; i < length; i++ ) { | |
| 474 | allEditors[ i ] = (FileEditorTab)tabs.get( i ); | |
| 475 | } | |
| 476 | ||
| 477 | return allEditors; | |
| 478 | } | |
| 479 | ||
| 480 | /** | |
| 481 | * Returns the file editor tab that has the given path. | |
| 482 | * | |
| 483 | * @return null No file editor tab for the given path was found. | |
| 484 | */ | |
| 485 | private FileEditorTab findEditor( final Path path ) { | |
| 486 | for( final Tab tab : getTabs() ) { | |
| 487 | final FileEditorTab fileEditor = (FileEditorTab)tab; | |
| 488 | ||
| 489 | if( fileEditor.isPath( path ) ) { | |
| 490 | return fileEditor; | |
| 491 | } | |
| 492 | } | |
| 493 | ||
| 494 | return null; | |
| 495 | } | |
| 496 | ||
| 497 | private FileChooser createFileChooser( String title ) { | |
| 498 | final FileChooser fileChooser = new FileChooser(); | |
| 499 | ||
| 500 | fileChooser.setTitle( title ); | |
| 501 | fileChooser.getExtensionFilters().addAll( | |
| 502 | createExtensionFilters() ); | |
| 503 | ||
| 504 | final String lastDirectory = getPreferences().get( "lastDirectory", null ); | |
| 505 | File file = new File( (lastDirectory != null) ? lastDirectory : "." ); | |
| 506 | ||
| 507 | if( !file.isDirectory() ) { | |
| 508 | file = new File( "." ); | |
| 509 | } | |
| 510 | ||
| 511 | fileChooser.setInitialDirectory( file ); | |
| 512 | return fileChooser; | |
| 513 | } | |
| 514 | ||
| 515 | private List<ExtensionFilter> createExtensionFilters() { | |
| 516 | final List<ExtensionFilter> list = new ArrayList<>(); | |
| 517 | ||
| 518 | // TODO: Return a list of all properties that match the filter prefix. | |
| 519 | // This will allow dynamic filters to be added and removed just by | |
| 520 | // updating the properties file. | |
| 521 | list.add( createExtensionFilter( MARKDOWN ) ); | |
| 522 | list.add( createExtensionFilter( DEFINITION ) ); | |
| 523 | list.add( createExtensionFilter( XML ) ); | |
| 524 | list.add( createExtensionFilter( ALL ) ); | |
| 525 | return list; | |
| 526 | } | |
| 527 | ||
| 528 | /** | |
| 529 | * Returns a filter for file name extensions recognized by the application | |
| 530 | * that can be opened by the user. | |
| 531 | * | |
| 532 | * @param filetype Used to find the globbing pattern for extensions. | |
| 533 | * | |
| 534 | * @return A filename filter suitable for use by a FileDialog instance. | |
| 535 | */ | |
| 536 | private ExtensionFilter createExtensionFilter( final FileType filetype ) { | |
| 537 | final String tKey = String.format( "%s.title.%s", FILTER_EXTENSION_TITLES, filetype ); | |
| 538 | final String eKey = String.format( "%s.%s", GLOB_PREFIX_FILE, filetype ); | |
| 539 | ||
| 540 | return new ExtensionFilter( Messages.get( tKey ), getExtensions( eKey ) ); | |
| 541 | } | |
| 542 | ||
| 543 | private List<String> getExtensions( final String key ) { | |
| 544 | return getSettings().getStringSettingList( key ); | |
| 545 | } | |
| 546 | ||
| 547 | private void saveLastDirectory( final File file ) { | |
| 548 | getPreferences().put( "lastDirectory", file.getParent() ); | |
| 549 | } | |
| 550 | ||
| 551 | public void restorePreferences() { | |
| 552 | int activeIndex = 0; | |
| 553 | ||
| 554 | final Preferences preferences = getPreferences(); | |
| 555 | final String[] fileNames = Utils.getPrefsStrings( preferences, "file" ); | |
| 556 | final String activeFileName = preferences.get( "activeFile", null ); | |
| 557 | ||
| 558 | final ArrayList<File> files = new ArrayList<>( fileNames.length ); | |
| 559 | ||
| 560 | for( final String fileName : fileNames ) { | |
| 561 | final File file = new File( fileName ); | |
| 562 | ||
| 563 | if( file.exists() ) { | |
| 564 | files.add( file ); | |
| 565 | ||
| 566 | if( fileName.equals( activeFileName ) ) { | |
| 567 | activeIndex = files.size() - 1; | |
| 568 | } | |
| 569 | } | |
| 570 | } | |
| 571 | ||
| 572 | if( files.isEmpty() ) { | |
| 573 | newEditor(); | |
| 574 | } | |
| 575 | else { | |
| 576 | openEditors( files, activeIndex ); | |
| 577 | } | |
| 578 | } | |
| 579 | ||
| 580 | public void persistPreferences() { | |
| 581 | final ObservableList<Tab> allEditors = getTabs(); | |
| 582 | final List<String> fileNames = new ArrayList<>( allEditors.size() ); | |
| 583 | ||
| 584 | for( final Tab tab : allEditors ) { | |
| 585 | final FileEditorTab fileEditor = (FileEditorTab)tab; | |
| 586 | final Path filePath = fileEditor.getPath(); | |
| 587 | ||
| 588 | if( filePath != null ) { | |
| 589 | fileNames.add( filePath.toString() ); | |
| 590 | } | |
| 591 | } | |
| 592 | ||
| 593 | final Preferences preferences = getPreferences(); | |
| 594 | Utils.putPrefsStrings( preferences, "file", fileNames.toArray( new String[ fileNames.size() ] ) ); | |
| 595 | ||
| 596 | final FileEditorTab activeEditor = getActiveFileEditor(); | |
| 597 | final Path filePath = activeEditor == null ? null : activeEditor.getPath(); | |
| 598 | ||
| 599 | if( filePath == null ) { | |
| 600 | preferences.remove( "activeFile" ); | |
| 601 | } | |
| 602 | else { | |
| 601 | 603 | preferences.put( "activeFile", filePath.toString() ); |
| 602 | 604 | } |
| 43 | 43 | DEFINITION( "definition" ), |
| 44 | 44 | XML( "xml" ), |
| 45 | CSV( "csv" ), | |
| 45 | 46 | JSON( "json" ), |
| 46 | 47 | TOML( "toml" ), |
| 522 | 522 | } |
| 523 | 523 | |
| 524 | private void fileSaveAll() { | |
| 525 | getFileEditorPane().saveAllEditors(); | |
| 526 | } | |
| 527 | ||
| 528 | private void fileExit() { | |
| 529 | final Window window = getWindow(); | |
| 530 | fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) ); | |
| 531 | } | |
| 532 | ||
| 533 | //---- Tools actions | |
| 534 | private void toolsScript() { | |
| 535 | final String script = getStartupScript(); | |
| 536 | ||
| 537 | final RScriptDialog dialog = new RScriptDialog( | |
| 538 | getWindow(), "Dialog.rScript.title", script ); | |
| 539 | final Optional<String> result = dialog.showAndWait(); | |
| 540 | ||
| 541 | result.ifPresent( (String s) -> { | |
| 542 | putStartupScript( s ); | |
| 543 | } ); | |
| 544 | } | |
| 545 | ||
| 546 | /** | |
| 547 | * Gets the R startup script from the user preferences. | |
| 548 | */ | |
| 549 | private String getStartupScript() { | |
| 550 | return getPreferences().get( PERSIST_R_STARTUP, "" ); | |
| 551 | } | |
| 552 | ||
| 553 | /** | |
| 554 | * Puts an R startup script into the user preferences. | |
| 555 | */ | |
| 556 | private void putStartupScript( final String s ) { | |
| 557 | try { | |
| 558 | getPreferences().put( PERSIST_R_STARTUP, s ); | |
| 559 | } catch( final Exception ex ) { | |
| 560 | getNotifier().notify( ex ); | |
| 561 | } | |
| 562 | } | |
| 563 | ||
| 564 | //---- Help actions ------------------------------------------------------- | |
| 565 | private void helpAbout() { | |
| 566 | Alert alert = new Alert( AlertType.INFORMATION ); | |
| 567 | alert.setTitle( get( "Dialog.about.title" ) ); | |
| 568 | alert.setHeaderText( get( "Dialog.about.header" ) ); | |
| 569 | alert.setContentText( get( "Dialog.about.content" ) ); | |
| 570 | alert.setGraphic( new ImageView( new Image( FILE_LOGO_32 ) ) ); | |
| 571 | alert.initOwner( getWindow() ); | |
| 572 | ||
| 573 | alert.showAndWait(); | |
| 574 | } | |
| 575 | ||
| 576 | //---- Convenience accessors ---------------------------------------------- | |
| 577 | private float getFloat( final String key, final float defaultValue ) { | |
| 578 | return getPreferences().getFloat( key, defaultValue ); | |
| 579 | } | |
| 580 | ||
| 581 | private Preferences getPreferences() { | |
| 582 | return getOptions().getState(); | |
| 583 | } | |
| 584 | ||
| 585 | protected Scene getScene() { | |
| 586 | if( this.scene == null ) { | |
| 587 | this.scene = createScene(); | |
| 588 | } | |
| 589 | ||
| 590 | return this.scene; | |
| 591 | } | |
| 592 | ||
| 593 | public Window getWindow() { | |
| 594 | return getScene().getWindow(); | |
| 595 | } | |
| 596 | ||
| 597 | private MarkdownEditorPane getActiveEditor() { | |
| 598 | final EditorPane pane = getActiveFileEditor().getEditorPane(); | |
| 599 | ||
| 600 | return pane instanceof MarkdownEditorPane ? (MarkdownEditorPane)pane : null; | |
| 601 | } | |
| 602 | ||
| 603 | private FileEditorTab getActiveFileEditor() { | |
| 604 | return getFileEditorPane().getActiveFileEditor(); | |
| 605 | } | |
| 606 | ||
| 607 | //---- Member accessors --------------------------------------------------- | |
| 608 | private void setProcessors( final Map<FileEditorTab, Processor<String>> map ) { | |
| 609 | this.processors = map; | |
| 610 | } | |
| 611 | ||
| 612 | private Map<FileEditorTab, Processor<String>> getProcessors() { | |
| 613 | if( this.processors == null ) { | |
| 614 | setProcessors( new HashMap<>() ); | |
| 615 | } | |
| 616 | ||
| 617 | return this.processors; | |
| 618 | } | |
| 619 | ||
| 620 | private FileEditorTabPane getFileEditorPane() { | |
| 621 | if( this.fileEditorPane == null ) { | |
| 622 | this.fileEditorPane = createFileEditorPane(); | |
| 623 | } | |
| 624 | ||
| 625 | return this.fileEditorPane; | |
| 626 | } | |
| 627 | ||
| 628 | private HTMLPreviewPane getPreviewPane() { | |
| 629 | if( this.previewPane == null ) { | |
| 630 | this.previewPane = createPreviewPane(); | |
| 631 | } | |
| 632 | ||
| 633 | return this.previewPane; | |
| 634 | } | |
| 635 | ||
| 636 | private void setDefinitionSource( final DefinitionSource definitionSource ) { | |
| 637 | this.definitionSource = definitionSource; | |
| 638 | } | |
| 639 | ||
| 640 | private DefinitionSource getDefinitionSource() { | |
| 641 | if( this.definitionSource == null ) { | |
| 642 | this.definitionSource = new EmptyDefinitionSource(); | |
| 643 | } | |
| 644 | ||
| 645 | return this.definitionSource; | |
| 646 | } | |
| 647 | ||
| 648 | private DefinitionPane getDefinitionPane() { | |
| 649 | if( this.definitionPane == null ) { | |
| 650 | this.definitionPane = createDefinitionPane(); | |
| 651 | } | |
| 652 | ||
| 653 | return this.definitionPane; | |
| 654 | } | |
| 655 | ||
| 656 | private Options getOptions() { | |
| 657 | return this.options; | |
| 658 | } | |
| 659 | ||
| 660 | private Snitch getSnitch() { | |
| 661 | return this.snitch; | |
| 662 | } | |
| 663 | ||
| 664 | private Notifier getNotifier() { | |
| 665 | return this.notifier; | |
| 666 | } | |
| 667 | ||
| 668 | public void setMenuBar( final MenuBar menuBar ) { | |
| 669 | this.menuBar = menuBar; | |
| 670 | } | |
| 671 | ||
| 672 | public MenuBar getMenuBar() { | |
| 673 | return this.menuBar; | |
| 674 | } | |
| 675 | ||
| 676 | private Text getLineNumberText() { | |
| 677 | if( this.lineNumberText == null ) { | |
| 678 | this.lineNumberText = createLineNumberText(); | |
| 679 | } | |
| 680 | ||
| 681 | return this.lineNumberText; | |
| 682 | } | |
| 683 | ||
| 684 | private synchronized StatusBar getStatusBar() { | |
| 685 | if( this.statusBar == null ) { | |
| 686 | this.statusBar = createStatusBar(); | |
| 687 | } | |
| 688 | ||
| 689 | return this.statusBar; | |
| 690 | } | |
| 691 | ||
| 692 | private TextField getFindTextField() { | |
| 693 | if( this.findTextField == null ) { | |
| 694 | this.findTextField = createFindTextField(); | |
| 695 | } | |
| 696 | ||
| 697 | return this.findTextField; | |
| 698 | } | |
| 699 | ||
| 700 | //---- Member creators ---------------------------------------------------- | |
| 701 | /** | |
| 702 | * Factory to create processors that are suited to different file types. | |
| 703 | * | |
| 704 | * @param tab The tab that is subjected to processing. | |
| 705 | * | |
| 706 | * @return A processor suited to the file type specified by the tab's path. | |
| 707 | */ | |
| 708 | private Processor<String> createProcessor( final FileEditorTab tab ) { | |
| 709 | return createProcessorFactory().createProcessor( tab ); | |
| 710 | } | |
| 711 | ||
| 712 | private ProcessorFactory createProcessorFactory() { | |
| 713 | return new ProcessorFactory( getPreviewPane(), getResolvedMap() ); | |
| 714 | } | |
| 715 | ||
| 716 | private DefinitionSource createDefinitionSource( final String path ) { | |
| 717 | final DefinitionSource ds | |
| 718 | = createDefinitionFactory().createDefinitionSource( path ); | |
| 719 | ||
| 720 | if( ds instanceof FileDefinitionSource ) { | |
| 721 | try { | |
| 722 | getSnitch().listen( ((FileDefinitionSource)ds).getPath() ); | |
| 723 | } catch( final IOException ex ) { | |
| 724 | error( ex ); | |
| 725 | } | |
| 726 | } | |
| 727 | ||
| 728 | return ds; | |
| 729 | } | |
| 730 | ||
| 731 | private TextField createFindTextField() { | |
| 732 | return new TextField(); | |
| 733 | } | |
| 734 | ||
| 735 | /** | |
| 736 | * Create an editor pane to hold file editor tabs. | |
| 737 | * | |
| 738 | * @return A new instance, never null. | |
| 739 | */ | |
| 740 | private FileEditorTabPane createFileEditorPane() { | |
| 741 | return new FileEditorTabPane(); | |
| 742 | } | |
| 743 | ||
| 744 | private HTMLPreviewPane createPreviewPane() { | |
| 745 | return new HTMLPreviewPane(); | |
| 746 | } | |
| 747 | ||
| 748 | private DefinitionPane createDefinitionPane() { | |
| 749 | return new DefinitionPane( getTreeView() ); | |
| 750 | } | |
| 751 | ||
| 752 | private DefinitionFactory createDefinitionFactory() { | |
| 753 | return new DefinitionFactory(); | |
| 754 | } | |
| 755 | ||
| 756 | private StatusBar createStatusBar() { | |
| 757 | return new StatusBar(); | |
| 758 | } | |
| 759 | ||
| 760 | private Scene createScene() { | |
| 761 | final SplitPane splitPane = new SplitPane( | |
| 762 | getDefinitionPane().getNode(), | |
| 763 | getFileEditorPane().getNode(), | |
| 764 | getPreviewPane().getNode() ); | |
| 765 | ||
| 766 | splitPane.setDividerPositions( | |
| 767 | getFloat( K_PANE_SPLIT_DEFINITION, .10f ), | |
| 768 | getFloat( K_PANE_SPLIT_EDITOR, .45f ), | |
| 769 | getFloat( K_PANE_SPLIT_PREVIEW, .45f ) ); | |
| 770 | ||
| 771 | // See: http://broadlyapplicable.blogspot.ca/2015/03/javafx-capture-restorePreferences-splitpane.html | |
| 772 | final BorderPane borderPane = new BorderPane(); | |
| 773 | borderPane.setPrefSize( 1024, 800 ); | |
| 774 | borderPane.setTop( createMenuBar() ); | |
| 775 | borderPane.setBottom( getStatusBar() ); | |
| 776 | borderPane.setCenter( splitPane ); | |
| 777 | ||
| 778 | final VBox box = new VBox(); | |
| 779 | box.setAlignment( Pos.BASELINE_CENTER ); | |
| 780 | box.getChildren().add( getLineNumberText() ); | |
| 781 | getStatusBar().getRightItems().add( box ); | |
| 782 | ||
| 783 | return new Scene( borderPane ); | |
| 784 | } | |
| 785 | ||
| 786 | private Text createLineNumberText() { | |
| 787 | return new Text( get( STATUS_BAR_LINE, 1, 1, 1 ) ); | |
| 788 | } | |
| 789 | ||
| 790 | private Node createMenuBar() { | |
| 791 | final BooleanBinding activeFileEditorIsNull = getFileEditorPane().activeFileEditorProperty().isNull(); | |
| 792 | ||
| 793 | // File actions | |
| 794 | final Action fileNewAction = new Action( get( "Main.menu.file.new" ), "Shortcut+N", FILE_ALT, e -> fileNew() ); | |
| 795 | final Action fileOpenAction = new Action( get( "Main.menu.file.open" ), "Shortcut+O", FOLDER_OPEN_ALT, e -> fileOpen() ); | |
| 796 | final Action fileCloseAction = new Action( get( "Main.menu.file.close" ), "Shortcut+W", null, e -> fileClose(), activeFileEditorIsNull ); | |
| 797 | final Action fileCloseAllAction = new Action( get( "Main.menu.file.close_all" ), null, null, e -> fileCloseAll(), activeFileEditorIsNull ); | |
| 798 | final Action fileSaveAction = new Action( get( "Main.menu.file.save" ), "Shortcut+S", FLOPPY_ALT, e -> fileSave(), | |
| 799 | createActiveBooleanProperty( FileEditorTab::modifiedProperty ).not() ); | |
| 800 | final Action fileSaveAllAction = new Action( get( "Main.menu.file.save_all" ), "Shortcut+Shift+S", null, e -> fileSaveAll(), | |
| 801 | Bindings.not( getFileEditorPane().anyFileEditorModifiedProperty() ) ); | |
| 802 | final Action fileExitAction = new Action( get( "Main.menu.file.exit" ), null, null, e -> fileExit() ); | |
| 803 | ||
| 804 | // Edit actions | |
| 805 | final Action editUndoAction = new Action( get( "Main.menu.edit.undo" ), "Shortcut+Z", UNDO, | |
| 806 | e -> getActiveEditor().undo(), | |
| 807 | createActiveBooleanProperty( FileEditorTab::canUndoProperty ).not() ); | |
| 808 | final Action editRedoAction = new Action( get( "Main.menu.edit.redo" ), "Shortcut+Y", REPEAT, | |
| 809 | e -> getActiveEditor().redo(), | |
| 810 | createActiveBooleanProperty( FileEditorTab::canRedoProperty ).not() ); | |
| 811 | final Action editFindAction = new Action( Messages.get( "Main.menu.edit.find" ), "Ctrl+F", SEARCH, | |
| 812 | e -> find(), | |
| 813 | activeFileEditorIsNull ); | |
| 814 | final Action editReplaceAction = new Action( Messages.get( "Main.menu.edit.find.replace" ), "Shortcut+H", RETWEET, | |
| 815 | e -> getActiveEditor().replace(), | |
| 816 | activeFileEditorIsNull ); | |
| 817 | final Action editFindNextAction = new Action( Messages.get( "Main.menu.edit.find.next" ), "F3", null, | |
| 818 | e -> findNext(), | |
| 819 | activeFileEditorIsNull ); | |
| 820 | final Action editFindPreviousAction = new Action( Messages.get( "Main.menu.edit.find.previous" ), "Shift+F3", null, | |
| 821 | e -> getActiveEditor().findPrevious(), | |
| 822 | activeFileEditorIsNull ); | |
| 823 | ||
| 824 | // Insert actions | |
| 825 | final Action insertBoldAction = new Action( get( "Main.menu.insert.bold" ), "Shortcut+B", BOLD, | |
| 826 | e -> getActiveEditor().surroundSelection( "**", "**" ), | |
| 827 | activeFileEditorIsNull ); | |
| 828 | final Action insertItalicAction = new Action( get( "Main.menu.insert.italic" ), "Shortcut+I", ITALIC, | |
| 829 | e -> getActiveEditor().surroundSelection( "*", "*" ), | |
| 830 | activeFileEditorIsNull ); | |
| 831 | final Action insertSuperscriptAction = new Action( get( "Main.menu.insert.superscript" ), "Shortcut+[", SUPERSCRIPT, | |
| 832 | e -> getActiveEditor().surroundSelection( "^", "^" ), | |
| 833 | activeFileEditorIsNull ); | |
| 834 | final Action insertSubscriptAction = new Action( get( "Main.menu.insert.subscript" ), "Shortcut+]", SUBSCRIPT, | |
| 835 | e -> getActiveEditor().surroundSelection( "~", "~" ), | |
| 836 | activeFileEditorIsNull ); | |
| 837 | final Action insertStrikethroughAction = new Action( get( "Main.menu.insert.strikethrough" ), "Shortcut+T", STRIKETHROUGH, | |
| 838 | e -> getActiveEditor().surroundSelection( "~~", "~~" ), | |
| 839 | activeFileEditorIsNull ); | |
| 840 | final Action insertBlockquoteAction = new Action( get( "Main.menu.insert.blockquote" ), "Ctrl+Q", QUOTE_LEFT, // not Shortcut+Q because of conflict on Mac | |
| 841 | e -> getActiveEditor().surroundSelection( "\n\n> ", "" ), | |
| 842 | activeFileEditorIsNull ); | |
| 843 | final Action insertCodeAction = new Action( get( "Main.menu.insert.code" ), "Shortcut+K", CODE, | |
| 844 | e -> getActiveEditor().surroundSelection( "`", "`" ), | |
| 845 | activeFileEditorIsNull ); | |
| 846 | final Action insertFencedCodeBlockAction = new Action( get( "Main.menu.insert.fenced_code_block" ), "Shortcut+Shift+K", FILE_CODE_ALT, | |
| 847 | e -> getActiveEditor().surroundSelection( "\n\n```\n", "\n```\n\n", get( "Main.menu.insert.fenced_code_block.prompt" ) ), | |
| 848 | activeFileEditorIsNull ); | |
| 849 | ||
| 850 | final Action insertLinkAction = new Action( get( "Main.menu.insert.link" ), "Shortcut+L", LINK, | |
| 851 | e -> getActiveEditor().insertLink(), | |
| 852 | activeFileEditorIsNull ); | |
| 853 | final Action insertImageAction = new Action( get( "Main.menu.insert.image" ), "Shortcut+G", PICTURE_ALT, | |
| 854 | e -> getActiveEditor().insertImage(), | |
| 855 | activeFileEditorIsNull ); | |
| 856 | ||
| 857 | final Action[] headers = new Action[ 6 ]; | |
| 858 | ||
| 859 | // Insert header actions (H1 ... H6) | |
| 860 | for( int i = 1; i <= 6; i++ ) { | |
| 861 | final String hashes = new String( new char[ i ] ).replace( "\0", "#" ); | |
| 862 | final String markup = String.format( "%n%n%s ", hashes ); | |
| 863 | final String text = get( "Main.menu.insert.header_" + i ); | |
| 864 | final String accelerator = "Shortcut+" + i; | |
| 865 | final String prompt = get( "Main.menu.insert.header_" + i + ".prompt" ); | |
| 866 | ||
| 867 | headers[ i - 1 ] = new Action( text, accelerator, HEADER, | |
| 868 | e -> getActiveEditor().surroundSelection( markup, "", prompt ), | |
| 869 | activeFileEditorIsNull ); | |
| 870 | } | |
| 871 | ||
| 872 | final Action insertUnorderedListAction = new Action( | |
| 873 | get( "Main.menu.insert.unordered_list" ), "Shortcut+U", LIST_UL, | |
| 874 | e -> getActiveEditor().surroundSelection( "\n\n* ", "" ), | |
| 875 | activeFileEditorIsNull ); | |
| 876 | final Action insertOrderedListAction = new Action( | |
| 877 | get( "Main.menu.insert.ordered_list" ), "Shortcut+Shift+O", LIST_OL, | |
| 878 | e -> getActiveEditor().surroundSelection( "\n\n1. ", "" ), | |
| 879 | activeFileEditorIsNull ); | |
| 880 | final Action insertHorizontalRuleAction = new Action( | |
| 881 | get( "Main.menu.insert.horizontal_rule" ), "Shortcut+H", null, | |
| 882 | e -> getActiveEditor().surroundSelection( "\n\n---\n\n", "" ), | |
| 883 | activeFileEditorIsNull ); | |
| 884 | ||
| 885 | // Tools actions | |
| 886 | final Action toolsScriptAction = new Action( | |
| 887 | get( "Main.menu.tools.script" ), null, null, e -> toolsScript() ); | |
| 888 | ||
| 889 | // Help actions | |
| 890 | final Action helpAboutAction = new Action( | |
| 891 | get( "Main.menu.help.about" ), null, null, e -> helpAbout() ); | |
| 892 | ||
| 893 | //---- MenuBar ---- | |
| 894 | final Menu fileMenu = ActionUtils.createMenu( get( "Main.menu.file" ), | |
| 895 | fileNewAction, | |
| 896 | fileOpenAction, | |
| 897 | null, | |
| 898 | fileCloseAction, | |
| 899 | fileCloseAllAction, | |
| 900 | null, | |
| 901 | fileSaveAction, | |
| 524 | private void fileSaveAs() { | |
| 525 | final FileEditorTab editor = getActiveFileEditor(); | |
| 526 | getFileEditorPane().saveEditorAs( editor ); | |
| 527 | getProcessors().remove( editor ); | |
| 528 | ||
| 529 | try { | |
| 530 | refreshSelectedTab( editor ); | |
| 531 | } catch( final Exception ex ) { | |
| 532 | getNotifier().notify( ex ); | |
| 533 | } | |
| 534 | } | |
| 535 | ||
| 536 | private void fileSaveAll() { | |
| 537 | getFileEditorPane().saveAllEditors(); | |
| 538 | } | |
| 539 | ||
| 540 | private void fileExit() { | |
| 541 | final Window window = getWindow(); | |
| 542 | fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) ); | |
| 543 | } | |
| 544 | ||
| 545 | //---- Tools actions | |
| 546 | private void toolsScript() { | |
| 547 | final String script = getStartupScript(); | |
| 548 | ||
| 549 | final RScriptDialog dialog = new RScriptDialog( | |
| 550 | getWindow(), "Dialog.rScript.title", script ); | |
| 551 | final Optional<String> result = dialog.showAndWait(); | |
| 552 | ||
| 553 | result.ifPresent( (String s) -> { | |
| 554 | putStartupScript( s ); | |
| 555 | } ); | |
| 556 | } | |
| 557 | ||
| 558 | /** | |
| 559 | * Gets the R startup script from the user preferences. | |
| 560 | */ | |
| 561 | private String getStartupScript() { | |
| 562 | return getPreferences().get( PERSIST_R_STARTUP, "" ); | |
| 563 | } | |
| 564 | ||
| 565 | /** | |
| 566 | * Puts an R startup script into the user preferences. | |
| 567 | */ | |
| 568 | private void putStartupScript( final String s ) { | |
| 569 | try { | |
| 570 | getPreferences().put( PERSIST_R_STARTUP, s ); | |
| 571 | } catch( final Exception ex ) { | |
| 572 | getNotifier().notify( ex ); | |
| 573 | } | |
| 574 | } | |
| 575 | ||
| 576 | //---- Help actions ------------------------------------------------------- | |
| 577 | private void helpAbout() { | |
| 578 | Alert alert = new Alert( AlertType.INFORMATION ); | |
| 579 | alert.setTitle( get( "Dialog.about.title" ) ); | |
| 580 | alert.setHeaderText( get( "Dialog.about.header" ) ); | |
| 581 | alert.setContentText( get( "Dialog.about.content" ) ); | |
| 582 | alert.setGraphic( new ImageView( new Image( FILE_LOGO_32 ) ) ); | |
| 583 | alert.initOwner( getWindow() ); | |
| 584 | ||
| 585 | alert.showAndWait(); | |
| 586 | } | |
| 587 | ||
| 588 | //---- Convenience accessors ---------------------------------------------- | |
| 589 | private float getFloat( final String key, final float defaultValue ) { | |
| 590 | return getPreferences().getFloat( key, defaultValue ); | |
| 591 | } | |
| 592 | ||
| 593 | private Preferences getPreferences() { | |
| 594 | return getOptions().getState(); | |
| 595 | } | |
| 596 | ||
| 597 | protected Scene getScene() { | |
| 598 | if( this.scene == null ) { | |
| 599 | this.scene = createScene(); | |
| 600 | } | |
| 601 | ||
| 602 | return this.scene; | |
| 603 | } | |
| 604 | ||
| 605 | public Window getWindow() { | |
| 606 | return getScene().getWindow(); | |
| 607 | } | |
| 608 | ||
| 609 | private MarkdownEditorPane getActiveEditor() { | |
| 610 | final EditorPane pane = getActiveFileEditor().getEditorPane(); | |
| 611 | ||
| 612 | return pane instanceof MarkdownEditorPane ? (MarkdownEditorPane)pane : null; | |
| 613 | } | |
| 614 | ||
| 615 | private FileEditorTab getActiveFileEditor() { | |
| 616 | return getFileEditorPane().getActiveFileEditor(); | |
| 617 | } | |
| 618 | ||
| 619 | //---- Member accessors --------------------------------------------------- | |
| 620 | private void setProcessors( final Map<FileEditorTab, Processor<String>> map ) { | |
| 621 | this.processors = map; | |
| 622 | } | |
| 623 | ||
| 624 | private Map<FileEditorTab, Processor<String>> getProcessors() { | |
| 625 | if( this.processors == null ) { | |
| 626 | setProcessors( new HashMap<>() ); | |
| 627 | } | |
| 628 | ||
| 629 | return this.processors; | |
| 630 | } | |
| 631 | ||
| 632 | private FileEditorTabPane getFileEditorPane() { | |
| 633 | if( this.fileEditorPane == null ) { | |
| 634 | this.fileEditorPane = createFileEditorPane(); | |
| 635 | } | |
| 636 | ||
| 637 | return this.fileEditorPane; | |
| 638 | } | |
| 639 | ||
| 640 | private HTMLPreviewPane getPreviewPane() { | |
| 641 | if( this.previewPane == null ) { | |
| 642 | this.previewPane = createPreviewPane(); | |
| 643 | } | |
| 644 | ||
| 645 | return this.previewPane; | |
| 646 | } | |
| 647 | ||
| 648 | private void setDefinitionSource( final DefinitionSource definitionSource ) { | |
| 649 | this.definitionSource = definitionSource; | |
| 650 | } | |
| 651 | ||
| 652 | private DefinitionSource getDefinitionSource() { | |
| 653 | if( this.definitionSource == null ) { | |
| 654 | this.definitionSource = new EmptyDefinitionSource(); | |
| 655 | } | |
| 656 | ||
| 657 | return this.definitionSource; | |
| 658 | } | |
| 659 | ||
| 660 | private DefinitionPane getDefinitionPane() { | |
| 661 | if( this.definitionPane == null ) { | |
| 662 | this.definitionPane = createDefinitionPane(); | |
| 663 | } | |
| 664 | ||
| 665 | return this.definitionPane; | |
| 666 | } | |
| 667 | ||
| 668 | private Options getOptions() { | |
| 669 | return this.options; | |
| 670 | } | |
| 671 | ||
| 672 | private Snitch getSnitch() { | |
| 673 | return this.snitch; | |
| 674 | } | |
| 675 | ||
| 676 | private Notifier getNotifier() { | |
| 677 | return this.notifier; | |
| 678 | } | |
| 679 | ||
| 680 | public void setMenuBar( final MenuBar menuBar ) { | |
| 681 | this.menuBar = menuBar; | |
| 682 | } | |
| 683 | ||
| 684 | public MenuBar getMenuBar() { | |
| 685 | return this.menuBar; | |
| 686 | } | |
| 687 | ||
| 688 | private Text getLineNumberText() { | |
| 689 | if( this.lineNumberText == null ) { | |
| 690 | this.lineNumberText = createLineNumberText(); | |
| 691 | } | |
| 692 | ||
| 693 | return this.lineNumberText; | |
| 694 | } | |
| 695 | ||
| 696 | private synchronized StatusBar getStatusBar() { | |
| 697 | if( this.statusBar == null ) { | |
| 698 | this.statusBar = createStatusBar(); | |
| 699 | } | |
| 700 | ||
| 701 | return this.statusBar; | |
| 702 | } | |
| 703 | ||
| 704 | private TextField getFindTextField() { | |
| 705 | if( this.findTextField == null ) { | |
| 706 | this.findTextField = createFindTextField(); | |
| 707 | } | |
| 708 | ||
| 709 | return this.findTextField; | |
| 710 | } | |
| 711 | ||
| 712 | //---- Member creators ---------------------------------------------------- | |
| 713 | /** | |
| 714 | * Factory to create processors that are suited to different file types. | |
| 715 | * | |
| 716 | * @param tab The tab that is subjected to processing. | |
| 717 | * | |
| 718 | * @return A processor suited to the file type specified by the tab's path. | |
| 719 | */ | |
| 720 | private Processor<String> createProcessor( final FileEditorTab tab ) { | |
| 721 | return createProcessorFactory().createProcessor( tab ); | |
| 722 | } | |
| 723 | ||
| 724 | private ProcessorFactory createProcessorFactory() { | |
| 725 | return new ProcessorFactory( getPreviewPane(), getResolvedMap() ); | |
| 726 | } | |
| 727 | ||
| 728 | private DefinitionSource createDefinitionSource( final String path ) { | |
| 729 | final DefinitionSource ds | |
| 730 | = createDefinitionFactory().createDefinitionSource( path ); | |
| 731 | ||
| 732 | if( ds instanceof FileDefinitionSource ) { | |
| 733 | try { | |
| 734 | getSnitch().listen( ((FileDefinitionSource)ds).getPath() ); | |
| 735 | } catch( final IOException ex ) { | |
| 736 | error( ex ); | |
| 737 | } | |
| 738 | } | |
| 739 | ||
| 740 | return ds; | |
| 741 | } | |
| 742 | ||
| 743 | private TextField createFindTextField() { | |
| 744 | return new TextField(); | |
| 745 | } | |
| 746 | ||
| 747 | /** | |
| 748 | * Create an editor pane to hold file editor tabs. | |
| 749 | * | |
| 750 | * @return A new instance, never null. | |
| 751 | */ | |
| 752 | private FileEditorTabPane createFileEditorPane() { | |
| 753 | return new FileEditorTabPane(); | |
| 754 | } | |
| 755 | ||
| 756 | private HTMLPreviewPane createPreviewPane() { | |
| 757 | return new HTMLPreviewPane(); | |
| 758 | } | |
| 759 | ||
| 760 | private DefinitionPane createDefinitionPane() { | |
| 761 | return new DefinitionPane( getTreeView() ); | |
| 762 | } | |
| 763 | ||
| 764 | private DefinitionFactory createDefinitionFactory() { | |
| 765 | return new DefinitionFactory(); | |
| 766 | } | |
| 767 | ||
| 768 | private StatusBar createStatusBar() { | |
| 769 | return new StatusBar(); | |
| 770 | } | |
| 771 | ||
| 772 | private Scene createScene() { | |
| 773 | final SplitPane splitPane = new SplitPane( | |
| 774 | getDefinitionPane().getNode(), | |
| 775 | getFileEditorPane().getNode(), | |
| 776 | getPreviewPane().getNode() ); | |
| 777 | ||
| 778 | splitPane.setDividerPositions( | |
| 779 | getFloat( K_PANE_SPLIT_DEFINITION, .10f ), | |
| 780 | getFloat( K_PANE_SPLIT_EDITOR, .45f ), | |
| 781 | getFloat( K_PANE_SPLIT_PREVIEW, .45f ) ); | |
| 782 | ||
| 783 | // See: http://broadlyapplicable.blogspot.ca/2015/03/javafx-capture-restorePreferences-splitpane.html | |
| 784 | final BorderPane borderPane = new BorderPane(); | |
| 785 | borderPane.setPrefSize( 1024, 800 ); | |
| 786 | borderPane.setTop( createMenuBar() ); | |
| 787 | borderPane.setBottom( getStatusBar() ); | |
| 788 | borderPane.setCenter( splitPane ); | |
| 789 | ||
| 790 | final VBox box = new VBox(); | |
| 791 | box.setAlignment( Pos.BASELINE_CENTER ); | |
| 792 | box.getChildren().add( getLineNumberText() ); | |
| 793 | getStatusBar().getRightItems().add( box ); | |
| 794 | ||
| 795 | return new Scene( borderPane ); | |
| 796 | } | |
| 797 | ||
| 798 | private Text createLineNumberText() { | |
| 799 | return new Text( get( STATUS_BAR_LINE, 1, 1, 1 ) ); | |
| 800 | } | |
| 801 | ||
| 802 | private Node createMenuBar() { | |
| 803 | final BooleanBinding activeFileEditorIsNull = getFileEditorPane().activeFileEditorProperty().isNull(); | |
| 804 | ||
| 805 | // File actions | |
| 806 | final Action fileNewAction = new Action( get( "Main.menu.file.new" ), "Shortcut+N", FILE_ALT, e -> fileNew() ); | |
| 807 | final Action fileOpenAction = new Action( get( "Main.menu.file.open" ), "Shortcut+O", FOLDER_OPEN_ALT, e -> fileOpen() ); | |
| 808 | final Action fileCloseAction = new Action( get( "Main.menu.file.close" ), "Shortcut+W", null, e -> fileClose(), activeFileEditorIsNull ); | |
| 809 | final Action fileCloseAllAction = new Action( get( "Main.menu.file.close_all" ), null, null, e -> fileCloseAll(), activeFileEditorIsNull ); | |
| 810 | final Action fileSaveAction = new Action( get( "Main.menu.file.save" ), "Shortcut+S", FLOPPY_ALT, e -> fileSave(), | |
| 811 | createActiveBooleanProperty( FileEditorTab::modifiedProperty ).not() ); | |
| 812 | final Action fileSaveAsAction = new Action( Messages.get( "Main.menu.file.save_as" ), null, null, e -> fileSaveAs(), activeFileEditorIsNull ); | |
| 813 | final Action fileSaveAllAction = new Action( get( "Main.menu.file.save_all" ), "Shortcut+Shift+S", null, e -> fileSaveAll(), | |
| 814 | Bindings.not( getFileEditorPane().anyFileEditorModifiedProperty() ) ); | |
| 815 | final Action fileExitAction = new Action( get( "Main.menu.file.exit" ), null, null, e -> fileExit() ); | |
| 816 | ||
| 817 | // Edit actions | |
| 818 | final Action editUndoAction = new Action( get( "Main.menu.edit.undo" ), "Shortcut+Z", UNDO, | |
| 819 | e -> getActiveEditor().undo(), | |
| 820 | createActiveBooleanProperty( FileEditorTab::canUndoProperty ).not() ); | |
| 821 | final Action editRedoAction = new Action( get( "Main.menu.edit.redo" ), "Shortcut+Y", REPEAT, | |
| 822 | e -> getActiveEditor().redo(), | |
| 823 | createActiveBooleanProperty( FileEditorTab::canRedoProperty ).not() ); | |
| 824 | final Action editFindAction = new Action( Messages.get( "Main.menu.edit.find" ), "Ctrl+F", SEARCH, | |
| 825 | e -> find(), | |
| 826 | activeFileEditorIsNull ); | |
| 827 | final Action editReplaceAction = new Action( Messages.get( "Main.menu.edit.find.replace" ), "Shortcut+H", RETWEET, | |
| 828 | e -> getActiveEditor().replace(), | |
| 829 | activeFileEditorIsNull ); | |
| 830 | final Action editFindNextAction = new Action( Messages.get( "Main.menu.edit.find.next" ), "F3", null, | |
| 831 | e -> findNext(), | |
| 832 | activeFileEditorIsNull ); | |
| 833 | final Action editFindPreviousAction = new Action( Messages.get( "Main.menu.edit.find.previous" ), "Shift+F3", null, | |
| 834 | e -> getActiveEditor().findPrevious(), | |
| 835 | activeFileEditorIsNull ); | |
| 836 | ||
| 837 | // Insert actions | |
| 838 | final Action insertBoldAction = new Action( get( "Main.menu.insert.bold" ), "Shortcut+B", BOLD, | |
| 839 | e -> getActiveEditor().surroundSelection( "**", "**" ), | |
| 840 | activeFileEditorIsNull ); | |
| 841 | final Action insertItalicAction = new Action( get( "Main.menu.insert.italic" ), "Shortcut+I", ITALIC, | |
| 842 | e -> getActiveEditor().surroundSelection( "*", "*" ), | |
| 843 | activeFileEditorIsNull ); | |
| 844 | final Action insertSuperscriptAction = new Action( get( "Main.menu.insert.superscript" ), "Shortcut+[", SUPERSCRIPT, | |
| 845 | e -> getActiveEditor().surroundSelection( "^", "^" ), | |
| 846 | activeFileEditorIsNull ); | |
| 847 | final Action insertSubscriptAction = new Action( get( "Main.menu.insert.subscript" ), "Shortcut+]", SUBSCRIPT, | |
| 848 | e -> getActiveEditor().surroundSelection( "~", "~" ), | |
| 849 | activeFileEditorIsNull ); | |
| 850 | final Action insertStrikethroughAction = new Action( get( "Main.menu.insert.strikethrough" ), "Shortcut+T", STRIKETHROUGH, | |
| 851 | e -> getActiveEditor().surroundSelection( "~~", "~~" ), | |
| 852 | activeFileEditorIsNull ); | |
| 853 | final Action insertBlockquoteAction = new Action( get( "Main.menu.insert.blockquote" ), "Ctrl+Q", QUOTE_LEFT, // not Shortcut+Q because of conflict on Mac | |
| 854 | e -> getActiveEditor().surroundSelection( "\n\n> ", "" ), | |
| 855 | activeFileEditorIsNull ); | |
| 856 | final Action insertCodeAction = new Action( get( "Main.menu.insert.code" ), "Shortcut+K", CODE, | |
| 857 | e -> getActiveEditor().surroundSelection( "`", "`" ), | |
| 858 | activeFileEditorIsNull ); | |
| 859 | final Action insertFencedCodeBlockAction = new Action( get( "Main.menu.insert.fenced_code_block" ), "Shortcut+Shift+K", FILE_CODE_ALT, | |
| 860 | e -> getActiveEditor().surroundSelection( "\n\n```\n", "\n```\n\n", get( "Main.menu.insert.fenced_code_block.prompt" ) ), | |
| 861 | activeFileEditorIsNull ); | |
| 862 | ||
| 863 | final Action insertLinkAction = new Action( get( "Main.menu.insert.link" ), "Shortcut+L", LINK, | |
| 864 | e -> getActiveEditor().insertLink(), | |
| 865 | activeFileEditorIsNull ); | |
| 866 | final Action insertImageAction = new Action( get( "Main.menu.insert.image" ), "Shortcut+G", PICTURE_ALT, | |
| 867 | e -> getActiveEditor().insertImage(), | |
| 868 | activeFileEditorIsNull ); | |
| 869 | ||
| 870 | final Action[] headers = new Action[ 6 ]; | |
| 871 | ||
| 872 | // Insert header actions (H1 ... H6) | |
| 873 | for( int i = 1; i <= 6; i++ ) { | |
| 874 | final String hashes = new String( new char[ i ] ).replace( "\0", "#" ); | |
| 875 | final String markup = String.format( "%n%n%s ", hashes ); | |
| 876 | final String text = get( "Main.menu.insert.header_" + i ); | |
| 877 | final String accelerator = "Shortcut+" + i; | |
| 878 | final String prompt = get( "Main.menu.insert.header_" + i + ".prompt" ); | |
| 879 | ||
| 880 | headers[ i - 1 ] = new Action( text, accelerator, HEADER, | |
| 881 | e -> getActiveEditor().surroundSelection( markup, "", prompt ), | |
| 882 | activeFileEditorIsNull ); | |
| 883 | } | |
| 884 | ||
| 885 | final Action insertUnorderedListAction = new Action( | |
| 886 | get( "Main.menu.insert.unordered_list" ), "Shortcut+U", LIST_UL, | |
| 887 | e -> getActiveEditor().surroundSelection( "\n\n* ", "" ), | |
| 888 | activeFileEditorIsNull ); | |
| 889 | final Action insertOrderedListAction = new Action( | |
| 890 | get( "Main.menu.insert.ordered_list" ), "Shortcut+Shift+O", LIST_OL, | |
| 891 | e -> getActiveEditor().surroundSelection( "\n\n1. ", "" ), | |
| 892 | activeFileEditorIsNull ); | |
| 893 | final Action insertHorizontalRuleAction = new Action( | |
| 894 | get( "Main.menu.insert.horizontal_rule" ), "Shortcut+H", null, | |
| 895 | e -> getActiveEditor().surroundSelection( "\n\n---\n\n", "" ), | |
| 896 | activeFileEditorIsNull ); | |
| 897 | ||
| 898 | // Tools actions | |
| 899 | final Action toolsScriptAction = new Action( | |
| 900 | get( "Main.menu.tools.script" ), null, null, e -> toolsScript() ); | |
| 901 | ||
| 902 | // Help actions | |
| 903 | final Action helpAboutAction = new Action( | |
| 904 | get( "Main.menu.help.about" ), null, null, e -> helpAbout() ); | |
| 905 | ||
| 906 | //---- MenuBar ---- | |
| 907 | final Menu fileMenu = ActionUtils.createMenu( get( "Main.menu.file" ), | |
| 908 | fileNewAction, | |
| 909 | fileOpenAction, | |
| 910 | null, | |
| 911 | fileCloseAction, | |
| 912 | fileCloseAllAction, | |
| 913 | null, | |
| 914 | fileSaveAction, | |
| 915 | fileSaveAsAction, | |
| 902 | 916 | fileSaveAllAction, |
| 903 | 917 | null, |
| 64 | 64 | removeItem.setOnAction( (ActionEvent e) -> { |
| 65 | 65 | final TreeItem c = getTreeItem(); |
| 66 | boolean remove = c.getParent().getChildren().remove( c ); | |
| 67 | System.out.println( "Remove" ); | |
| 66 | c.getParent().getChildren().remove( c ); | |
| 68 | 67 | } ); |
| 69 | 68 |
| 171 | 171 | */ |
| 172 | 172 | private void resolve( |
| 173 | final JsonNode rootNode, final String path, final Map<String, String> map ) { | |
| 173 | final JsonNode rootNode, | |
| 174 | final String path, | |
| 175 | final Map<String, String> map ) { | |
| 174 | 176 | |
| 175 | 177 | if( rootNode != null ) { |
| 47 | 47 | |
| 48 | 48 | private TextArea scriptArea; |
| 49 | private String originalText = ""; | |
| 49 | 50 | |
| 50 | 51 | public RScriptDialog( |
| 51 | 52 | final Window parent, final String title, final String script ) { |
| 52 | 53 | super( parent, title ); |
| 54 | setOriginalText( script ); | |
| 53 | 55 | getScriptArea().setText( script ); |
| 54 | 56 | } |
| ... | ||
| 76 | 78 | |
| 77 | 79 | setResultConverter( dialogButton -> { |
| 78 | return dialogButton == OK ? textArea.getText() : ""; | |
| 80 | return dialogButton == OK ? textArea.getText() : getOriginalText(); | |
| 79 | 81 | } ); |
| 80 | 82 | } |
| 81 | 83 | |
| 82 | 84 | private TextArea getScriptArea() { |
| 83 | 85 | if( this.scriptArea == null ) { |
| 84 | 86 | this.scriptArea = new TextArea(); |
| 85 | 87 | } |
| 86 | 88 | |
| 87 | 89 | return this.scriptArea; |
| 90 | } | |
| 91 | ||
| 92 | private String getOriginalText() { | |
| 93 | return this.originalText; | |
| 94 | } | |
| 95 | ||
| 96 | private void setOriginalText( final String originalText ) { | |
| 97 | this.originalText = originalText; | |
| 88 | 98 | } |
| 89 | 99 | } |
| 74 | 74 | getUndoManager().redo(); |
| 75 | 75 | } |
| 76 | ||
| 76 | ||
| 77 | /** | |
| 78 | * TOD: Implement this. | |
| 79 | */ | |
| 77 | 80 | public void replace() { |
| 78 | System.out.println( "replace" ); | |
| 79 | 81 | } |
| 80 | ||
| 82 | ||
| 83 | /** | |
| 84 | * TOD: Implement this. | |
| 85 | */ | |
| 81 | 86 | public void findPrevious() { |
| 82 | System.out.println( "find previous" ); | |
| 83 | 87 | } |
| 84 | 88 |
| 48 | 48 | import org.fxmisc.richtext.StyleClassedTextArea; |
| 49 | 49 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; |
| 50 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 51 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 52 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 53 | 50 | |
| 54 | 51 | /** |
| ... | ||
| 74 | 71 | |
| 75 | 72 | addEventListener( keyPressed( ENTER ), this::enterPressed ); |
| 76 | ||
| 77 | // TODO: Wait for implementation that allows cutting lines, not paragraphs. | |
| 78 | // addEventListener( keyPressed( X, SHORTCUT_DOWN ), this::cutLine ); | |
| 79 | 73 | } |
| 80 | 74 | |
| 1 | /* | |
| 2 | * Copyright 2017 White Magic Software, Ltd. | |
| 3 | * | |
| 4 | * All rights reserved. | |
| 5 | * | |
| 6 | * Redistribution and use in source and binary forms, with or without | |
| 7 | * modification, are permitted provided that the following conditions are met: | |
| 8 | * | |
| 9 | * o Redistributions of source code must retain the above copyright | |
| 10 | * notice, this list of conditions and the following disclaimer. | |
| 11 | * | |
| 12 | * o Redistributions in binary form must reproduce the above copyright | |
| 13 | * notice, this list of conditions and the following disclaimer in the | |
| 14 | * documentation and/or other materials provided with the distribution. | |
| 15 | * | |
| 16 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | |
| 17 | * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | |
| 18 | * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | |
| 19 | * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT | |
| 20 | * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | |
| 21 | * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT | |
| 22 | * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | |
| 23 | * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | |
| 24 | * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | |
| 25 | * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | |
| 26 | * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | |
| 27 | */ | |
| 28 | package com.scrivenvar.processors; | |
| 29 | ||
| 30 | /** | |
| 31 | * This is the default processor used when an unknown filename extension is | |
| 32 | * encountered. | |
| 33 | * | |
| 34 | * @author White Magic Software, Ltd. | |
| 35 | */ | |
| 36 | public class IdentityProcessor extends AbstractProcessor<String> { | |
| 37 | ||
| 38 | /** | |
| 39 | * Passes the link to the super constructor. | |
| 40 | * | |
| 41 | * @param link The next processor in the chain to use for text processing. | |
| 42 | */ | |
| 43 | public IdentityProcessor( final Processor<String> link ) { | |
| 44 | super( link ); | |
| 45 | } | |
| 46 | ||
| 47 | /** | |
| 48 | * Returns the given string, modified with "pre" tags. | |
| 49 | * | |
| 50 | * @param t The string to return, enclosed in "pre" tags. | |
| 51 | * | |
| 52 | * @return t | |
| 53 | */ | |
| 54 | @Override | |
| 55 | public String processLink( final String t ) { | |
| 56 | final StringBuilder result = new StringBuilder( t.length() + 16 ); | |
| 57 | ||
| 58 | return result.append( "<pre>" ).append( t ).append( "</pre>" ).toString(); | |
| 59 | } | |
| 60 | } | |
| 1 | 61 |
| 72 | 72 | public Processor<String> createProcessor( final FileEditorTab tab ) { |
| 73 | 73 | final Path path = tab.getPath(); |
| 74 | Processor<String> processor = null; | |
| 74 | final Processor<String> processor; | |
| 75 | 75 | |
| 76 | 76 | switch( lookup( path ) ) { |
| ... | ||
| 92 | 92 | |
| 93 | 93 | default: |
| 94 | unknownExtension( path ); | |
| 94 | processor = createIdentityProcessor( tab ); | |
| 95 | 95 | break; |
| 96 | 96 | } |
| ... | ||
| 124 | 124 | |
| 125 | 125 | return mpp; |
| 126 | } | |
| 127 | ||
| 128 | protected Processor<String> createIdentityProcessor( final FileEditorTab tab ) { | |
| 129 | final Processor<String> hpp = new HTMLPreviewProcessor( getPreviewPane() ); | |
| 130 | final Processor<String> ip = new IdentityProcessor( hpp ); | |
| 131 | ||
| 132 | return ip; | |
| 126 | 133 | } |
| 127 | 134 | |
| 1 | # R Scripts | |
| 2 | ||
| 3 | These R scripts illustrate how R can be used within an application to perform calculations using variables. Authors are free to write their own scripts, of course. These scripts serve as an example of how to automate certain tasks while writing. | |
| 4 | ||
| 5 | ## Configuration | |
| 6 | ||
| 7 | Configure the editor to use the R scripts as follows: | |
| 8 | ||
| 9 | 1. Copy the R scripts into same directory as your Markdown files. | |
| 10 | 1. Start the editor. | |
| 11 | 1. Click **Tools → R Script**. | |
| 12 | 1. Copy and paste the following: | |
| 13 | ||
| 14 | assign( 'anchor', as.Date( '$date.anchor$', format='%Y-%m-%d' ), envir = .GlobalEnv ); | |
| 15 | setwd( '$application.r.working.directory$' ); | |
| 16 | source( 'pluralize.R' ); | |
| 17 | source( 'csv.R' ); | |
| 18 | source( 'conversion.R' ); | |
| 19 | ||
| 20 | 1. Click **File → New** to create a new file. | |
| 21 | 1. Click **File → Save As** to set a filename. | |
| 22 | 1. Set **Name** to: `variables.yaml` | |
| 23 | 1. Click **OK**. | |
| 24 | 1. Paste the following definitions: | |
| 25 | ||
| 26 | date: | |
| 27 | anchor: 2017-01-01 | |
| 28 | editor: | |
| 29 | examples: | |
| 30 | season: 2017-09-02 | |
| 31 | math: | |
| 32 | x: 1 | |
| 33 | y: $editor.examples.math.x$ + 1 | |
| 34 | z: $editor.examples.math.y$ + 1 | |
| 35 | name: | |
| 36 | given: Josephene | |
| 37 | ||
| 38 | 1. Save and close the file. | |
| 39 | 1. Click **File → Open** | |
| 40 | 1. Change **Markdown Files** to **Definition Files**. | |
| 41 | 1. Select `variables.yaml`. | |
| 42 | 1. Click **Open**. | |
| 43 | ||
| 44 | R functionality is configured. | |
| 45 | ||
| 46 | ## Definitions | |
| 47 | ||
| 48 | The variables definitions within `variables.yaml` are available to R using the R syntax. An additional variable, `application.r.working.directory` is added to the list of variables. The value is set to the working directory of the file being edited. Hover the mouse cursor over the file tab in the editor to see the full path to the file. | |
| 49 | ||
| 50 | ## Examples | |
| 51 | ||
| 52 | This section demonstrates how to use the R functions when editing. Complete the following steps to begin: | |
| 53 | ||
| 54 | 1. Click **File → New** to create a new file. | |
| 55 | 1. Click **File → Save As** to set a filename. | |
| 56 | 1. Set **Name** to: `example.Rmd` | |
| 57 | 1. Click **OK**. | |
| 58 | ||
| 59 | The examples are ready for use within the editor. | |
| 60 | ||
| 61 | ### Arithmetic | |
| 62 | ||
| 63 | Type the following to perform a simple calculation: | |
| 64 | ||
| 65 | `r# 1+1` | |
| 66 | ||
| 67 | The preview pane shows `2.0`. | |
| 68 | ||
| 69 | ### Functions | |
| 70 | ||
| 71 | Call the [format](https://stat.ethz.ch/R-manual/R-devel/library/base/html/format.html) function to truncate unwanted decimal places as follows: | |
| 72 | ||
| 73 | `r# format(1+1,digits=1)` | |
| 74 | ||
| 75 | The preview pane shows `2`. | |
| 76 | ||
| 77 | ### Pluralize | |
| 78 | ||
| 79 | Many English words can be pluralized as follows: | |
| 80 | ||
| 81 | `r# pl('wolf',2)` | |
| 82 | ||
| 83 | The preview pane shows `wolves`. The `pluralize.R` file contains a partial implementation of Damian Conway's algorithmic approach to English pluralization. | |
| 84 | ||
| 85 | ### Chicago Manual of Style | |
| 86 | ||
| 87 | Apply the Chicago Manual of Style for words less than one-hundred as follows: | |
| 88 | ||
| 89 | `r# cms(1)` `r# cms(99)` `r# cms(101)` | |
| 90 | ||
| 91 | The preview pane shows numbers written out as `one` and `ninety-nine`, followed by the digits 101. | |
| 92 | ||
| 93 | ### Data Import | |
| 94 | ||
| 95 | Import and display information from a CSV file as follows: | |
| 96 | ||
| 97 | 1. Click **File → New** to create a new file. | |
| 98 | 1. Click **File → Save As** to rename the file. | |
| 99 | 1. Set the filename to: `data.csv` | |
| 100 | 1. Paste the following into `data.csv`: | |
| 101 | ||
| 102 | Animal,Quantity,Country | |
| 103 | Aardwolf,1,Africa | |
| 104 | Keel-billed toucan,1,Belize | |
| 105 | Beaver,2,Canada | |
| 106 | Mute swan,3,Denmark | |
| 107 | Lion,5,Ethiopia | |
| 108 | Brown bear,8,Finland | |
| 109 | Dolphin,13,Greece | |
| 110 | Turul,21,Hungary | |
| 111 | Gyrfalcon,34,Iceland | |
| 112 | Red-billed streamertail,55,Jamaica | |
| 113 | ||
| 114 | 1. Click the `example.Rmd` tab. | |
| 115 | 1. Type the following: | |
| 116 | ||
| 117 | `r# csv2md('data.csv',total=F)` | |
| 118 | ||
| 119 | 1. Type the following to sum all numeric columns, use: | |
| 120 | ||
| 121 | `r# csv2md('data.csv')` | |
| 122 | ||
| 123 | This imports the data from an external file and formats the information into a table, automatically. Update the data as follows: | |
| 124 | ||
| 125 | 1. Click the `data.csv` tab to edit the data. | |
| 126 | 1. Change the data by adding a new row. | |
| 127 | 1. Save the file. | |
| 128 | 1. Click the `example.Rmd` tab. | |
| 129 | ||
| 130 | The preview pane shows the revised contents. | |
| 131 | ||
| 132 | ### Elapsed Time | |
| 133 | ||
| 134 | The duration of a timeline, given in numbers of days, can be computed into English as follows: | |
| 135 | ||
| 136 | `r# elapsed(1,1)` | |
| 137 | ||
| 138 | The preview pane shows `same day`. Change the expression to: | |
| 139 | ||
| 140 | `r# elapsed(1,2)` | |
| 141 | ||
| 142 | The preview pane shows `one day`. Change the expression to: | |
| 143 | ||
| 144 | `r# elapsed(1,112358)` | |
| 145 | ||
| 146 | The preview pane shows `307 years, seven months, and sixteen days`, combined using the Chicago Manual of Style, the pluralization function, and a [serial comma](https://www.behance.net/gallery/19417363/The-Oxford-Comma). | |
| 147 | ||
| 148 | ### Variable Syntax | |
| 149 | ||
| 150 | The syntax for a variable changes when using an R Markdown file (denoted by the `.Rmd` filename extension), as opposed to a regular Markdown file (`.md`). Return to the example file and type the following: | |
| 151 | ||
| 152 | `r# v$date$anchor` | |
| 153 | ||
| 154 | The preview pane shows the date. | |
| 155 | ||
| 156 | ### Autocomplete | |
| 157 | ||
| 158 | Automatically insert a variable reference into the text as follows: | |
| 159 | ||
| 160 | 1. Type: `Jos` | |
| 161 | * Note the capital letter, matches are case sensitive. | |
| 162 | 1. Hold down the `Control` key. | |
| 163 | 1. Tap the `Spacebar` | |
| 164 | ||
| 165 | The editor shows: | |
| 166 | ||
| 167 | `r#x( v$editor$examples$name$given )` | |
| 168 | ||
| 169 | The preview pane shows: | |
| 170 | ||
| 171 | Josephine | |
| 172 | ||
| 173 | Here, the `x` function evaluates its parameter as an expression. This allows variables to include expressions in their definition. | |
| 174 | ||
| 175 | ### Variable Definition Expressions | |
| 176 | ||
| 177 | Definition file variables are have the ability to reference other definitions. Try the following: | |
| 178 | ||
| 179 | `r#x( v$editor$examples$math$x )` | |
| 180 | `r#x( v$editor$examples$math$y )` | |
| 181 | `r#x( v$editor$examples$math$z )` | |
| 182 | ||
| 183 | The preview pane shows: | |
| 184 | ||
| 185 | x = 1.0; y = 2.0; z = 3.0 | |
| 186 | ||
| 187 | ### Case | |
| 188 | ||
| 189 | Ensure words begin with a lowercase letter as follows: | |
| 190 | ||
| 191 | `r#lc( v$editor$examples$name$given )` | |
| 192 | ||
| 193 | The preview pane shows: | |
| 194 | ||
| 195 | josephine | |
| 196 | ||
| 197 | Similarly, ensure an uppercase letter as follows: | |
| 198 | ||
| 199 | `r#uc( 'hello, world!' )` | |
| 200 | ||
| 201 | The preview pane shows: | |
| 202 | ||
| 203 | Hello, world! | |
| 204 | ||
| 205 | ### Month | |
| 206 | ||
| 207 | Display the month name given a month number as follows: | |
| 208 | ||
| 209 | `r# month( 1 )` | |
| 210 | ||
| 211 | The preview pane shows: | |
| 212 | ||
| 213 | January | |
| 214 | ||
| 215 | ## Summary | |
| 216 | ||
| 217 | Authors can inline R statements into documents, directly, so long as those statements generate text. Plots, graphs, and images must be referenced as external image files or URLs. | |
| 1 | 218 |
| 1 | # ######################################################################## | |
| 2 | # | |
| 3 | # Substitute R expressions in a document with their evaluated value. The | |
| 4 | # anchor variable must be set for functions that use relative dates. | |
| 5 | # | |
| 6 | # ######################################################################## | |
| 7 | ||
| 8 | # Evaluates an expression; writes s if there is no expression. | |
| 9 | x <- function( s ) { | |
| 10 | return( | |
| 11 | tryCatch({ | |
| 12 | r = eval( parse( text=s ) ) | |
| 13 | ||
| 14 | # If the result isn't primitive, then it was probably parsed into | |
| 15 | # an unprintable object (e.g., "gray" becomes a colour). In those | |
| 16 | # cases, return the original text string. Otherwise, an atomic | |
| 17 | # value means a primitive type (string, integer, etc.) that can be | |
| 18 | # written directly into the document. | |
| 19 | # | |
| 20 | # See: http://stackoverflow.com/a/19501276/59087 | |
| 21 | if( is.atomic( r ) ) { | |
| 22 | r | |
| 23 | } | |
| 24 | else { | |
| 25 | s | |
| 26 | } | |
| 27 | }, | |
| 28 | warning = function( w ) { | |
| 29 | s | |
| 30 | }, | |
| 31 | error = function( e ) { | |
| 32 | s | |
| 33 | }) | |
| 34 | ) | |
| 35 | } | |
| 36 | ||
| 37 | # Returns a date offset by a given number of days, relative to the given | |
| 38 | # date (d). This does not use the anchor, but is used to get the anchor's | |
| 39 | # value as a date. | |
| 40 | when <- function( d, n = 0, format = "%Y-%m-%d" ) { | |
| 41 | as.Date( d, format = format ) + x( n ) | |
| 42 | } | |
| 43 | ||
| 44 | # Full date (s) offset by an optional number of days before or after. | |
| 45 | # This will remove leading zeros (applying leading spaces instead, which | |
| 46 | # are ignored by any worthwhile typesetting engine). | |
| 47 | annal <- function( days = 0, format = "%Y-%m-%d", oformat = "%B %d, %Y" ) { | |
| 48 | format( when( anchor, days ), format = oformat ) | |
| 49 | } | |
| 50 | ||
| 51 | # Extracts the year from a date string. | |
| 52 | year <- function( days = 0, format = "%Y-%m-%d" ) { | |
| 53 | annal( days, format, "%Y" ) | |
| 54 | } | |
| 55 | ||
| 56 | # Day of the week (in days since the anchor date). | |
| 57 | weekday <- function( n ) { | |
| 58 | weekdays( when( anchor, n ) ) | |
| 59 | } | |
| 60 | ||
| 61 | # String concatenate function alias because paste0 is a terrible name. | |
| 62 | concat <- paste0 | |
| 63 | ||
| 64 | # Translates a number from digits to words using Chicago Manual of Style. | |
| 65 | # This does not translate numbers greater than one hundred. If ordinal | |
| 66 | # is TRUE, this will return the ordinal name. This will not produce ordinals | |
| 67 | # for numbers greater than 100 | |
| 68 | cms <- function( n, ordinal = FALSE ) { | |
| 69 | n <- x( n ) | |
| 70 | ||
| 71 | # We're done here. | |
| 72 | if( n == 0 ) { | |
| 73 | if( ordinal ) { | |
| 74 | return( "zeroth" ) | |
| 75 | } | |
| 76 | ||
| 77 | return( "zero" ) | |
| 78 | } | |
| 79 | ||
| 80 | # Concatenate this a little later. | |
| 81 | if( n < 0 ) { | |
| 82 | result = "negative " | |
| 83 | n = abs( n ) | |
| 84 | } | |
| 85 | ||
| 86 | # Do not spell out numbers greater than one hundred. | |
| 87 | if( n > 100 ) { | |
| 88 | # Comma-separated numbers. | |
| 89 | return( format( n, big.mark=",", trim=TRUE, scientific=FALSE ) ) | |
| 90 | } | |
| 91 | ||
| 92 | # Don't go beyond 100. | |
| 93 | if( n == 100 ) { | |
| 94 | if( ordinal ) { | |
| 95 | return( "one hundredth" ) | |
| 96 | } | |
| 97 | ||
| 98 | return( "one hundred" ) | |
| 99 | } | |
| 100 | ||
| 101 | # Samuel Langhorne Clemens noted English has too many exceptions. | |
| 102 | small = c( | |
| 103 | "one", "two", "three", "four", "five", | |
| 104 | "six", "seven", "eight", "nine", "ten", | |
| 105 | "eleven", "twelve", "thirteen", "fourteen", "fifteen", | |
| 106 | "sixteen", "seventeen", "eighteen", "nineteen" | |
| 107 | ) | |
| 108 | ||
| 109 | ord_small = c( | |
| 110 | "first", "second", "third", "fourth", "fifth", | |
| 111 | "sixth", "seventh", "eighth", "ninth", "tenth", | |
| 112 | "eleventh", "twelfth", "thirteenth", "fourteenth", "fifteenth", | |
| 113 | "sixteenth", "seventeenth", "eighteenth", "nineteenth", "twentieth" | |
| 114 | ) | |
| 115 | ||
| 116 | # After this, the number (n) is between 20 and 99. | |
| 117 | if( n < 20 ) { | |
| 118 | if( ordinal ) { | |
| 119 | return( .subset( ord_small, n %% 100 ) ) | |
| 120 | } | |
| 121 | ||
| 122 | return( .subset( small, n %% 100 ) ) | |
| 123 | } | |
| 124 | ||
| 125 | tens = c( "", | |
| 126 | "twenty", "thirty", "forty", "fifty", | |
| 127 | "sixty", "seventy", "eighty", "ninety" | |
| 128 | ) | |
| 129 | ||
| 130 | ord_tens = c( "", | |
| 131 | "twentieth", "thirtieth", "fortieth", "fiftieth", | |
| 132 | "sixtieth", "seventieth", "eightieth", "ninetieth" | |
| 133 | ) | |
| 134 | ||
| 135 | ones_index = n %% 10 | |
| 136 | n = n %/% 10 | |
| 137 | ||
| 138 | # No number in the ones column, so the number must be a multiple of ten. | |
| 139 | if( ones_index == 0 ) { | |
| 140 | if( ordinal ) { | |
| 141 | return( .subset( ord_tens, n ) ) | |
| 142 | } | |
| 143 | ||
| 144 | return( .subset( tens, n ) ) | |
| 145 | } | |
| 146 | ||
| 147 | # Find the value from the ones column. | |
| 148 | if( ordinal ) { | |
| 149 | unit_1 = .subset( ord_small, ones_index ) | |
| 150 | } | |
| 151 | else { | |
| 152 | unit_1 = .subset( small, ones_index ) | |
| 153 | } | |
| 154 | ||
| 155 | # Find the tens column. | |
| 156 | unit_10 = .subset( tens, n ) | |
| 157 | ||
| 158 | # Hyphenate the tens and the ones together. | |
| 159 | concat( unit_10, concat( "-", unit_1 ) ) | |
| 160 | } | |
| 161 | ||
| 162 | # Returns a human-readable string that provides the elapsed time between | |
| 163 | # two numbers in terms of years, months, and days. If any unit value is zero, | |
| 164 | # the unit is not included. The words (year, month, day) are pluralized | |
| 165 | # according to English grammar. The numbers are written out according to | |
| 166 | # Chicago Manual of Style. This applies the serial comma. | |
| 167 | # | |
| 168 | # Both numbers are offsets relative to the anchor date. | |
| 169 | # | |
| 170 | # If all unit values are zero, this returns s ("same day" by default). | |
| 171 | # | |
| 172 | # If the start date (began) is greater than end date (ended), the dates are | |
| 173 | # swapped before calculations are performed. This allows any two dates | |
| 174 | # to be compared and positive unit values are always returned. | |
| 175 | # | |
| 176 | elapsed <- function( began, ended, s = "same day" ) { | |
| 177 | began = when( anchor, began ) | |
| 178 | ended = when( anchor, ended ) | |
| 179 | ||
| 180 | # Swap the dates if the end date comes before the start date. | |
| 181 | if( as.integer( ended - began ) < 0 ) { | |
| 182 | tempd = began | |
| 183 | began = ended | |
| 184 | ended = tempd | |
| 185 | } | |
| 186 | ||
| 187 | # Calculate number of elapsed years. | |
| 188 | years = length( seq( from = began, to = ended, by = 'year' ) ) - 1 | |
| 189 | ||
| 190 | # Move the start date up by the number of elapsed years. | |
| 191 | if( years > 0 ) { | |
| 192 | began = seq( began, length = 2, by = concat( years, " years" ) )[2] | |
| 193 | years = pl.numeric( "year", years ) | |
| 194 | } | |
| 195 | else { | |
| 196 | # Zero years. | |
| 197 | years = "" | |
| 198 | } | |
| 199 | ||
| 200 | # Calculate number of elapsed months, excluding years. | |
| 201 | months = length( seq( from = began, to = ended, by = 'month' ) ) - 1 | |
| 202 | ||
| 203 | # Move the start date up by the number of elapsed months | |
| 204 | if( months > 0 ) { | |
| 205 | began = seq( began, length = 2, by = concat( months, " months" ) )[2] | |
| 206 | months = pl.numeric( "month", months ) | |
| 207 | } | |
| 208 | else { | |
| 209 | # Zero months | |
| 210 | months = "" | |
| 211 | } | |
| 212 | ||
| 213 | # Calculate number of elapsed days, excluding months and years. | |
| 214 | days = length( seq( from = began, to = ended, by = 'day' ) ) - 1 | |
| 215 | ||
| 216 | if( days > 0 ) { | |
| 217 | days = pl.numeric( "day", days ) | |
| 218 | } | |
| 219 | else { | |
| 220 | # Zero days | |
| 221 | days = "" | |
| 222 | } | |
| 223 | ||
| 224 | if( years <= 0 && months <= 0 && days <= 0 ) { | |
| 225 | return( s ) | |
| 226 | } | |
| 227 | ||
| 228 | # Put them all in a vector, then remove the empty values. | |
| 229 | s <- c( years, months, days ) | |
| 230 | s <- s[ s != "" ] | |
| 231 | ||
| 232 | r <- paste( s, collapse = ", " ) | |
| 233 | ||
| 234 | # If all three items are present, replace the last comma with ", and". | |
| 235 | if( length( s ) > 2 ) { | |
| 236 | return( gsub( "(.*),", "\\1, and", r ) ) | |
| 237 | } | |
| 238 | ||
| 239 | # Does nothing if no commas are present. | |
| 240 | gsub( "(.*),", "\\1 and", r ) | |
| 241 | } | |
| 242 | ||
| 243 | # Returns the number (n) in English followed by the plural or singular | |
| 244 | # form of the given string (s; resumably a noun), if applicable, according | |
| 245 | # to English grammar. That is, pl.numeric( "wolf", 5 ) will return | |
| 246 | # "five wolves". | |
| 247 | pl.numeric <- function( s, n ) { | |
| 248 | concat( cms( n ), concat( " ", pluralize( s, n ) ) ) | |
| 249 | } | |
| 250 | ||
| 251 | # Name of the season, starting with an capital letter. | |
| 252 | season <- function( n, format = "%Y-%m-%d" ) { | |
| 253 | WS <- as.Date("2016-12-15", "%Y-%m-%d") # Winter Solstice | |
| 254 | SE <- as.Date("2016-03-15", "%Y-%m-%d") # Spring Equinox | |
| 255 | SS <- as.Date("2016-06-15", "%Y-%m-%d") # Summer Solstice | |
| 256 | AE <- as.Date("2016-09-15", "%Y-%m-%d") # Autumn Equinox | |
| 257 | ||
| 258 | d <- when( anchor, n ) | |
| 259 | d <- as.Date( strftime( d, format="2016-%m-%d" ) ) | |
| 260 | ||
| 261 | ifelse( d >= WS | d < SE, "Winter", | |
| 262 | ifelse( d >= SE & d < SS, "Spring", | |
| 263 | ifelse( d >= SS & d < AE, "Summer", "Autumn" ) | |
| 264 | ) | |
| 265 | ) | |
| 266 | } | |
| 267 | ||
| 268 | # Converts the first letter in a string to lowercase | |
| 269 | lc <- function( s ) { | |
| 270 | concat( tolower( substr( s, 1, 1 ) ), substr( s, 2, nchar( s ) ) ) | |
| 271 | } | |
| 272 | ||
| 273 | # Converts the first letter in a string to uppercase | |
| 274 | uc <- function( s ) { | |
| 275 | concat( toupper( substr( s, 1, 1 ) ), substr( s, 2, nchar( s ) ) ) | |
| 276 | } | |
| 277 | ||
| 278 | # Returns the number of days between the given dates. | |
| 279 | days <- function( d1, d2, format = "%Y-%m-%d" ) { | |
| 280 | dates = c( d1, d2 ) | |
| 281 | dt = strptime( dates, format = format ) | |
| 282 | as.integer( difftime( dates[2], dates[1], units = "days" ) ) | |
| 283 | } | |
| 284 | ||
| 285 | # Returns the number of years elapsed. | |
| 286 | years <- function( began, ended ) { | |
| 287 | began = when( anchor, began ) | |
| 288 | ended = when( anchor, ended ) | |
| 289 | ||
| 290 | # Swap the dates if the end date comes before the start date. | |
| 291 | if( as.integer( ended - began ) < 0 ) { | |
| 292 | tempd = began | |
| 293 | began = ended | |
| 294 | ended = tempd | |
| 295 | } | |
| 296 | ||
| 297 | # Calculate number of elapsed years. | |
| 298 | length( seq( from = began, to = ended, by = 'year' ) ) - 1 | |
| 299 | } | |
| 300 | ||
| 301 | # Full name of the month, starting with a capital letter. | |
| 302 | month <- function( n ) { | |
| 303 | # Faster than month.name[ x( n ) ] | |
| 304 | .subset( month.name, x( n ) ) | |
| 305 | } | |
| 306 | ||
| 307 | money <- function( n ) { | |
| 308 | formatC( x( n ), format="d" ) | |
| 309 | } | |
| 1 | 310 |
| 1 | # ###################################################################### | |
| 2 | # | |
| 3 | # Copyright 2016, White Magic Software, Ltd. | |
| 4 | # | |
| 5 | # Permission is hereby granted, free of charge, to any person obtaining | |
| 6 | # a copy of this software and associated documentation files (the | |
| 7 | # "Software"), to deal in the Software without restriction, including | |
| 8 | # without limitation the rights to use, copy, modify, merge, publish, | |
| 9 | # distribute, sublicense, and/or sell copies of the Software, and to | |
| 10 | # permit persons to whom the Software is furnished to do so, subject to | |
| 11 | # the following conditions: | |
| 12 | # | |
| 13 | # The above copyright notice and this permission notice shall be | |
| 14 | # included in all copies or substantial portions of the Software. | |
| 15 | # | |
| 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, | |
| 17 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF | |
| 18 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND | |
| 19 | # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE | |
| 20 | # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION | |
| 21 | # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION | |
| 22 | # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. | |
| 23 | # | |
| 24 | # ###################################################################### | |
| 25 | ||
| 26 | # ###################################################################### | |
| 27 | # | |
| 28 | # Converts CSV to Markdown. | |
| 29 | # | |
| 30 | # ###################################################################### | |
| 31 | ||
| 32 | # Reads a CSV file and converts the contents to a Markdown table. The | |
| 33 | # file must be in the working directory as specified by setwd. | |
| 34 | # | |
| 35 | # @param f The filename to convert. | |
| 36 | # @param decimals Rounded decimal places (default 1). | |
| 37 | # @param totals Include total sums (default TRUE). | |
| 38 | # @param align Right-align numbers (default TRUE). | |
| 39 | csv2md <- function( f, decimals = 1, totals = T, align = T ) { | |
| 40 | # Read the CVS data from the file; ensure strings become characters. | |
| 41 | df <- read.table( f, sep=',', header=T, stringsAsFactors=F ) | |
| 42 | ||
| 43 | if( totals ) { | |
| 44 | # Determine what columns can be summed. | |
| 45 | number <- which( unlist( lapply( df, is.numeric ) ) ) | |
| 46 | ||
| 47 | # Use colSums when more than one summable column exists. | |
| 48 | if( length( number ) > 1 ) { | |
| 49 | f.sum <- colSums | |
| 50 | } | |
| 51 | else { | |
| 52 | f.sum <- sum | |
| 53 | } | |
| 54 | ||
| 55 | # Calculate the sum of all the summable columns and insert the | |
| 56 | # results back into the data frame. | |
| 57 | df[ (nrow( df ) + 1), number ] <- f.sum( df[, number], na.rm=TRUE ) | |
| 58 | ||
| 59 | # pluralize would be heavyweight here. | |
| 60 | if( length( number ) > 1 ) { | |
| 61 | t <- "**Totals**" | |
| 62 | } | |
| 63 | else { | |
| 64 | t <- "**Total**" | |
| 65 | } | |
| 66 | ||
| 67 | # Change the first column of the last line to "Total(s)". | |
| 68 | df[ nrow( df ), 1 ] <- t | |
| 69 | ||
| 70 | # Don't clutter the output with "NA" text. | |
| 71 | df[ is.na( df ) ] <- "" | |
| 72 | } | |
| 73 | ||
| 74 | if( align ) { | |
| 75 | is.char <- vapply( df, is.character, logical( 1 ) ) | |
| 76 | dashes <- paste( ifelse( is.char, ':---', '---:' ), collapse='|' ) | |
| 77 | } | |
| 78 | else { | |
| 79 | dashes <- paste( rep( '---', length( df ) ), collapse = '|') | |
| 80 | } | |
| 81 | ||
| 82 | # Create a Markdown version of the data frame. | |
| 83 | paste( | |
| 84 | paste( names( df ), collapse = '|'), '\n', | |
| 85 | dashes, '\n', | |
| 86 | paste( | |
| 87 | Reduce( function( x, y ) { | |
| 88 | paste( x, format( y, digits = decimals ), sep = '|' ) | |
| 89 | }, df | |
| 90 | ), | |
| 91 | collapse = '|\n', sep='' | |
| 92 | ) | |
| 93 | ) | |
| 94 | } | |
| 95 | ||
| 1 | 96 |
| 1 | # ###################################################################### | |
| 2 | # | |
| 3 | # Copyright 2016, White Magic Software, Ltd. | |
| 4 | # | |
| 5 | # Permission is hereby granted, free of charge, to any person obtaining | |
| 6 | # a copy of this software and associated documentation files (the | |
| 7 | # "Software"), to deal in the Software without restriction, including | |
| 8 | # without limitation the rights to use, copy, modify, merge, publish, | |
| 9 | # distribute, sublicense, and/or sell copies of the Software, and to | |
| 10 | # permit persons to whom the Software is furnished to do so, subject to | |
| 11 | # the following conditions: | |
| 12 | # | |
| 13 | # The above copyright notice and this permission notice shall be | |
| 14 | # included in all copies or substantial portions of the Software. | |
| 15 | # | |
| 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, | |
| 17 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF | |
| 18 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND | |
| 19 | # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE | |
| 20 | # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION | |
| 21 | # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION | |
| 22 | # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. | |
| 23 | # | |
| 24 | # ###################################################################### | |
| 25 | ||
| 26 | # ###################################################################### | |
| 27 | # | |
| 28 | # See Damian Conway's "An Algorithmic Approach to English Pluralization": | |
| 29 | # http://goo.gl/oRL4MP | |
| 30 | # See Oliver Glerke's Evo Inflector: https://github.com/atteo/evo-inflector/ | |
| 31 | # See Shevek's Pluralizer: https://github.com/shevek/linguistics/ | |
| 32 | # See also: http://www.freevectors.net/assets/files/plural.txt | |
| 33 | # | |
| 34 | # ###################################################################### | |
| 35 | ||
| 36 | pluralize <- function( s, n ) { | |
| 37 | result <- s | |
| 38 | ||
| 39 | # Partial implementation of Conway's algorithm for nouns. | |
| 40 | if( n != 1 ) { | |
| 41 | if( pl.noninflective( s ) || | |
| 42 | pl.suffix( "fish", s ) || | |
| 43 | pl.suffix( "ois", s ) || | |
| 44 | pl.suffix( "sheep", s ) || | |
| 45 | pl.suffix( "deer", s ) || | |
| 46 | pl.suffix( "pox", s ) || | |
| 47 | pl.suffix( "[A-Z].*ese", s ) || | |
| 48 | pl.suffix( "itis", s ) ) { | |
| 49 | # 1. Retain non-inflective user-mapped noun as is. | |
| 50 | # 2. Retain non-inflective plural as is. | |
| 51 | result <- s | |
| 52 | } | |
| 53 | else if( pl.is.irregular.pl( s ) ) { | |
| 54 | # 4. Change irregular plurals based on mapping. | |
| 55 | result <- pl.irregular.pl( s ) | |
| 56 | } | |
| 57 | else if( pl.is.irregular.es( s ) ) { | |
| 58 | # x. From Shevek's Pluralizer | |
| 59 | result <- pl.inflect( s, "", "es" ) | |
| 60 | } | |
| 61 | else if( pl.suffix( "man", s ) ) { | |
| 62 | # 5. For -man, change -an to -en | |
| 63 | result <- pl.inflect( s, "an", "en" ) | |
| 64 | } | |
| 65 | else if( pl.suffix( "[lm]ouse", s ) ) { | |
| 66 | # 5. For [lm]ouse, change -ouse to -ice | |
| 67 | result <- pl.inflect( s, "ouse", "ice" ) | |
| 68 | } | |
| 69 | else if( pl.suffix( "tooth", s ) ) { | |
| 70 | # 5. For -tooth, change -ooth to -eeth | |
| 71 | result <- pl.inflect( s, "ooth", "eeth" ) | |
| 72 | } | |
| 73 | else if( pl.suffix( "goose", s ) ) { | |
| 74 | # 5. For -goose, change -oose to -eese | |
| 75 | result <- pl.inflect( s, "oose", "eese" ) | |
| 76 | } | |
| 77 | else if( pl.suffix( "foot", s ) ) { | |
| 78 | # 5. For -foot, change -oot to -eet | |
| 79 | result <- pl.inflect( s, "oot", "eet" ) | |
| 80 | } | |
| 81 | else if( pl.suffix( "zoon", s ) ) { | |
| 82 | # 5. For -zoon, change -on to -a | |
| 83 | result <- pl.inflect( s, "on", "a" ) | |
| 84 | } | |
| 85 | else if( pl.suffix( "[csx]is", s ) ) { | |
| 86 | # 5. Change -cis, -sis, -xis to -es | |
| 87 | result <- pl.inflect( s, "is", "es" ) | |
| 88 | } | |
| 89 | else if( pl.suffix( "([cs]h|ss)", s ) ) { | |
| 90 | # 8. Change -ch, -sh, -ss to -es | |
| 91 | result <- pl.inflect( s, "", "es" ) | |
| 92 | } | |
| 93 | else if( pl.suffix( "([aeo]lf|[^d]eaf|arf)", s ) ) { | |
| 94 | # 9. Change -f to -ves | |
| 95 | result <- pl.inflect( s, "f", "ves" ) | |
| 96 | } | |
| 97 | else if( pl.suffix( "[nlw]ife", s ) ) { | |
| 98 | # 9. Change -fe to -ves | |
| 99 | result <- pl.inflect( s, "fe", "ves" ) | |
| 100 | } | |
| 101 | else if( pl.suffix( "([aeiou]y|[A-Z].*y)", s ) ) { | |
| 102 | # 10. Change -y to -ys. | |
| 103 | result <- pl.inflect( s, "", "s" ) | |
| 104 | } | |
| 105 | else if( pl.suffix( "y", s ) ) { | |
| 106 | # 10. Change -y to -ies. | |
| 107 | result <- pl.inflect( s, "y", "ies" ) | |
| 108 | } | |
| 109 | else { | |
| 110 | # 13. Default plural: add -s. | |
| 111 | result <- pl.inflect( s, "", "s" ) | |
| 112 | } | |
| 113 | } | |
| 114 | ||
| 115 | result | |
| 116 | } | |
| 117 | ||
| 118 | # Pluralize s if n is not equal to 1. | |
| 119 | pl <- function( s, n ) { | |
| 120 | pluralize( s, x( n ) ) | |
| 121 | } | |
| 122 | ||
| 123 | # Returns the given string (s) with its suffix replaced by r. | |
| 124 | pl.inflect <- function( s, suffix, r ) { | |
| 125 | gsub( paste( suffix, "$", sep="" ), r, s ) | |
| 126 | } | |
| 127 | ||
| 128 | # Answers whether the given string (s) has the given ending. | |
| 129 | pl.suffix <- function( ending, s ) { | |
| 130 | grepl( paste( ending, "$", sep="" ), s ) | |
| 131 | } | |
| 132 | ||
| 133 | # Answers whether the given string (s) is a noninflective noun. | |
| 134 | pl.noninflective <- function( s ) { | |
| 135 | v <- c( | |
| 136 | "aircraft", "Bhutanese", "bison", "bream", "breeches", "britches", | |
| 137 | "Burmese", "carp", "chassis", "Chinese", "clippers", "cod", "contretemps", | |
| 138 | "corps", "debris", "diabetes", "djinn", "eland", "elk", "flounder", | |
| 139 | "fracas", "gallows", "graffiti", "headquarters", "herpes", "high-jinks", | |
| 140 | "homework", "hovercraft", "innings", "jackanapes", "Japanese", | |
| 141 | "Lebanese", "mackerel", "means", "measles", "mews", "mumps", "news", | |
| 142 | "pincers", "pliers", "Portuguese", "proceedings", "rabies", "salmon", | |
| 143 | "scissors", "sea-bass", "Senegalese", "series", "shears", "Siamese", | |
| 144 | "Sinhalese", "spacecraft", "species", "swine", "trout", "tuna", | |
| 145 | "Vietnamese", "watercraft", "whiting", "wildebeest" | |
| 146 | ) | |
| 147 | ||
| 148 | is.element( s, v ) | |
| 149 | } | |
| 150 | ||
| 151 | # Answers whether the given string (s) is an irregular plural. | |
| 152 | pl.is.irregular.pl <- function( s ) { | |
| 153 | # Could be refactored with pl.irregular.pl... | |
| 154 | v <- c( | |
| 155 | "beef", "brother", "child", "cow", "ephemeris", "genie", "money", | |
| 156 | "mongoose", "mythos", "octopus", "ox", "soliloquy", "trilby" | |
| 157 | ) | |
| 158 | ||
| 159 | is.element( s, v ) | |
| 160 | } | |
| 161 | ||
| 162 | # Call to pluralize an irregular noun. Only call after confirming | |
| 163 | # the noun is irregular via pl.is.irregular.pl. | |
| 164 | pl.irregular.pl <- function( s ) { | |
| 165 | v <- list( | |
| 166 | "beef" = "beefs", | |
| 167 | "brother" = "brothers", | |
| 168 | "child" = "children", | |
| 169 | "cow" = "cows", | |
| 170 | "ephemeris" = "ephemerides", | |
| 171 | "genie" = "genies", | |
| 172 | "money" = "moneys", | |
| 173 | "mongoose" = "mongooses", | |
| 174 | "mythos" = "mythoi", | |
| 175 | "octopus" = "octopuses", | |
| 176 | "ox" = "oxen", | |
| 177 | "soliloquy" = "soliloquies", | |
| 178 | "trilby" = "trilbys" | |
| 179 | ) | |
| 180 | ||
| 181 | # Faster version of v[[ s ]] | |
| 182 | .subset2( v, s ) | |
| 183 | } | |
| 184 | ||
| 185 | # Answers whether the given string (s) pluralizes with -es. | |
| 186 | pl.is.irregular.es <- function( s ) { | |
| 187 | v <- c( | |
| 188 | "acropolis", "aegis", "alias", "asbestos", "bathos", "bias", "bronchitis", | |
| 189 | "bursitis", "caddis", "cannabis", "canvas", "chaos", "cosmos", "dais", | |
| 190 | "digitalis", "epidermis", "ethos", "eyas", "gas", "glottis", "hubris", | |
| 191 | "ibis", "lens", "mantis", "marquis", "metropolis", "pathos", "pelvis", | |
| 192 | "polis", "rhinoceros", "sassafrass", "trellis" | |
| 193 | ) | |
| 194 | ||
| 195 | is.element( s, v ) | |
| 196 | } | |
| 197 | ||
| 1 | 198 |
| 39 | 39 | |
| 40 | 40 | Main.menu.file=_File |
| 41 | Main.menu.file.new=New | |
| 42 | Main.menu.file.open=Open... | |
| 43 | Main.menu.file.close=Close | |
| 41 | Main.menu.file.new=_New | |
| 42 | Main.menu.file.open=_Open... | |
| 43 | Main.menu.file.close=_Close | |
| 44 | 44 | Main.menu.file.close_all=Close All |
| 45 | Main.menu.file.save=Save | |
| 46 | Main.menu.file.save_all=Save All | |
| 47 | Main.menu.file.exit=Exit | |
| 45 | Main.menu.file.save=_Save | |
| 46 | Main.menu.file.save_as=Save _As | |
| 47 | Main.menu.file.save_all=Save A_ll | |
| 48 | Main.menu.file.exit=E_xit | |
| 48 | 49 | |
| 49 | 50 | Main.menu.edit=_Edit |