| 1 | 1 | --- |
| 2 | River: "Door" | |
| 3 | Ocean: "Floor" | |
| 2 | application: | |
| 3 | title: "Scrivenvar" | |
| 4 | 4 |
| 2 | 2 | id 'application' |
| 3 | 3 | id 'org.openjfx.javafxplugin' version '0.0.8' |
| 4 | id 'com.palantir.git-version' version '0.12.3' | |
| 4 | 5 | } |
| 5 | 6 | |
| ... | ||
| 63 | 64 | |
| 64 | 65 | sourceCompatibility = JavaVersion.VERSION_11 |
| 65 | version = '1.5.0' | |
| 66 | 66 | applicationName = 'scrivenvar' |
| 67 | mainClassName = 'com.scrivenvar.Main' | |
| 68 | def launcherClassName = 'com.scrivenvar.Launcher' | |
| 67 | version gitVersion() | |
| 68 | mainClassName = "com.${applicationName}.Main" | |
| 69 | def launcherClassName = "com.${applicationName}.Launcher" | |
| 70 | ||
| 71 | def propertiesFile = new File("src/main/resources/com/$applicationName/app.properties") | |
| 72 | propertiesFile.write("application.version=${version}") | |
| 69 | 73 | |
| 70 | 74 | jar { |
| ... | ||
| 81 | 85 | } |
| 82 | 86 | |
| 83 | archiveFileName = 'scrivenvar.jar' | |
| 87 | archiveFileName = "${applicationName}.jar" | |
| 84 | 88 | |
| 85 | 89 | exclude 'META-INF/*.RSA', 'META-INF/*.SF', 'META-INF/*.DSA' |
| 55 | 55 | |
| 56 | 56 | // Bootstrapping... |
| 57 | public static final String SETTINGS_NAME = "/com/scrivenvar/settings" + | |
| 58 | ".properties"; | |
| 57 | public static final String SETTINGS_NAME = | |
| 58 | "/com/scrivenvar/settings.properties"; | |
| 59 | 59 | |
| 60 | 60 | public static final String APP_TITLE = get( "application.title" ); |
| 61 | 61 | public static final String APP_BUNDLE_NAME = get( "application.messages" ); |
| 62 | 62 | |
| 63 | 63 | // Prevent double events when updating files on Linux (save and timestamp). |
| 64 | 64 | public static final int APP_WATCHDOG_TIMEOUT = get( |
| 65 | "application.watchdog.timeout", | |
| 66 | 100 ); | |
| 65 | "application.watchdog.timeout", 100 ); | |
| 67 | 66 | |
| 68 | 67 | public static final String STYLESHEET_SCENE = get( "file.stylesheet.scene" ); |
| 69 | 68 | public static final String STYLESHEET_MARKDOWN = get( |
| 70 | 69 | "file.stylesheet.markdown" ); |
| 71 | public static final String STYLESHEET_PREVIEW = get( "file.stylesheet" + | |
| 72 | ".preview" ); | |
| 70 | public static final String STYLESHEET_PREVIEW = get( | |
| 71 | "file.stylesheet.preview" ); | |
| 73 | 72 | |
| 74 | 73 | public static final String FILE_LOGO_16 = get( "file.logo.16" ); |
| 38 | 38 | import javafx.beans.value.ChangeListener; |
| 39 | 39 | import javafx.beans.value.ObservableValue; |
| 40 | import javafx.scene.Node; | |
| 41 | import javafx.scene.Scene; | |
| 42 | import javafx.scene.control.Tab; | |
| 43 | import javafx.scene.control.Tooltip; | |
| 44 | import javafx.scene.text.Text; | |
| 45 | import javafx.stage.Window; | |
| 46 | import org.fxmisc.richtext.StyleClassedTextArea; | |
| 47 | import org.fxmisc.richtext.model.TwoDimensional.Position; | |
| 48 | import org.fxmisc.undo.UndoManager; | |
| 49 | import org.mozilla.universalchardet.UniversalDetector; | |
| 50 | ||
| 51 | import java.io.File; | |
| 52 | import java.io.IOException; | |
| 53 | import java.nio.charset.Charset; | |
| 54 | import java.nio.file.Files; | |
| 55 | import java.nio.file.Path; | |
| 56 | ||
| 57 | import static com.scrivenvar.Messages.get; | |
| 58 | import static java.nio.charset.StandardCharsets.UTF_8; | |
| 59 | import static java.util.Locale.ENGLISH; | |
| 60 | import static org.fxmisc.richtext.model.TwoDimensional.Bias.Forward; | |
| 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 mNotifier = Services.load( Notifier.class ); | |
| 70 | private final EditorPane mEditorPane = new MarkdownEditorPane(); | |
| 71 | ||
| 72 | private final ReadOnlyBooleanWrapper mModified = new ReadOnlyBooleanWrapper(); | |
| 73 | private final BooleanProperty canUndo = new SimpleBooleanProperty(); | |
| 74 | private final BooleanProperty canRedo = new SimpleBooleanProperty(); | |
| 75 | ||
| 76 | /** | |
| 77 | * Character encoding used by the file (or default encoding if none found). | |
| 78 | */ | |
| 79 | private Charset mEncoding = UTF_8; | |
| 80 | ||
| 81 | /** | |
| 82 | * File to load into the editor. | |
| 83 | */ | |
| 84 | private Path mPath; | |
| 85 | ||
| 86 | public FileEditorTab( final Path path ) { | |
| 87 | setPath( path ); | |
| 88 | ||
| 89 | mModified.addListener( ( observable, oldPath, newPath ) -> updateTab() ); | |
| 90 | ||
| 91 | setOnSelectionChanged( e -> { | |
| 92 | if( isSelected() ) { | |
| 93 | Platform.runLater( this::activated ); | |
| 94 | } | |
| 95 | } ); | |
| 96 | } | |
| 97 | ||
| 98 | private void updateTab() { | |
| 99 | setText( getTabTitle() ); | |
| 100 | setGraphic( getModifiedMark() ); | |
| 101 | setTooltip( getTabTooltip() ); | |
| 102 | } | |
| 103 | ||
| 104 | /** | |
| 105 | * Returns the base filename (without the directory names). | |
| 106 | * | |
| 107 | * @return The untitled text if the path hasn't been set. | |
| 108 | */ | |
| 109 | private String getTabTitle() { | |
| 110 | return getPath().getFileName().toString(); | |
| 111 | } | |
| 112 | ||
| 113 | /** | |
| 114 | * Returns the full filename represented by the path. | |
| 115 | * | |
| 116 | * @return The untitled text if the path hasn't been set. | |
| 117 | */ | |
| 118 | private Tooltip getTabTooltip() { | |
| 119 | final Path filePath = getPath(); | |
| 120 | return new Tooltip( filePath == null ? "" : filePath.toString() ); | |
| 121 | } | |
| 122 | ||
| 123 | /** | |
| 124 | * Returns a marker to indicate whether the file has been modified. | |
| 125 | * | |
| 126 | * @return "*" when the file has changed; otherwise null. | |
| 127 | */ | |
| 128 | private Text getModifiedMark() { | |
| 129 | return isModified() ? new Text( "*" ) : null; | |
| 130 | } | |
| 131 | ||
| 132 | /** | |
| 133 | * Called when the user switches tab. | |
| 134 | */ | |
| 135 | private void activated() { | |
| 136 | // Tab is closed or no longer active. | |
| 137 | if( getTabPane() == null || !isSelected() ) { | |
| 138 | return; | |
| 139 | } | |
| 140 | ||
| 141 | // Switch to the tab without loading if the contents are already in memory. | |
| 142 | if( getContent() != null ) { | |
| 143 | getEditorPane().requestFocus(); | |
| 144 | return; | |
| 145 | } | |
| 146 | ||
| 147 | // Load the text and update the preview before the undo manager. | |
| 148 | load(); | |
| 149 | ||
| 150 | // Track undo requests -- can only be called *after* load. | |
| 151 | initUndoManager(); | |
| 152 | initLayout(); | |
| 153 | initFocus(); | |
| 154 | } | |
| 155 | ||
| 156 | private void initLayout() { | |
| 157 | setContent( getScrollPane() ); | |
| 158 | } | |
| 159 | ||
| 160 | private Node getScrollPane() { | |
| 161 | return getEditorPane().getScrollPane(); | |
| 162 | } | |
| 163 | ||
| 164 | private void initFocus() { | |
| 165 | getEditorPane().requestFocus(); | |
| 166 | } | |
| 167 | ||
| 168 | private void initUndoManager() { | |
| 169 | final UndoManager<?> undoManager = getUndoManager(); | |
| 170 | undoManager.forgetHistory(); | |
| 171 | ||
| 172 | // Bind the editor undo manager to the properties. | |
| 173 | mModified.bind( Bindings.not( undoManager.atMarkedPositionProperty() ) ); | |
| 174 | canUndo.bind( undoManager.undoAvailableProperty() ); | |
| 175 | canRedo.bind( undoManager.redoAvailableProperty() ); | |
| 176 | } | |
| 177 | ||
| 178 | /** | |
| 179 | * Searches from the caret position forward for the given string. | |
| 180 | * | |
| 181 | * @param needle The text string to match. | |
| 182 | */ | |
| 183 | public void searchNext( final String needle ) { | |
| 184 | final String haystack = getEditorText(); | |
| 185 | int index = haystack.indexOf( needle, getCaretPosition() ); | |
| 186 | ||
| 187 | // Wrap around. | |
| 188 | if( index == -1 ) { | |
| 189 | index = haystack.indexOf( needle ); | |
| 190 | } | |
| 191 | ||
| 192 | if( index >= 0 ) { | |
| 193 | setCaretPosition( index ); | |
| 194 | getEditor().selectRange( index, index + needle.length() ); | |
| 195 | } | |
| 196 | } | |
| 197 | ||
| 198 | /** | |
| 199 | * Returns the index into the text where the caret blinks happily away. | |
| 200 | * | |
| 201 | * @return A number from 0 to the editor's document text length. | |
| 202 | */ | |
| 203 | public int getCaretPosition() { | |
| 204 | return getEditor().getCaretPosition(); | |
| 205 | } | |
| 206 | ||
| 207 | /** | |
| 208 | * Moves the caret to a given offset. | |
| 209 | * | |
| 210 | * @param offset The new caret offset. | |
| 211 | */ | |
| 212 | private void setCaretPosition( final int offset ) { | |
| 213 | getEditor().moveTo( offset ); | |
| 214 | getEditor().requestFollowCaret(); | |
| 215 | } | |
| 216 | ||
| 217 | /** | |
| 218 | * Returns the caret's current row and column position. | |
| 219 | * | |
| 220 | * @return The caret's offset into the document. | |
| 221 | */ | |
| 222 | public Position getCaretOffset() { | |
| 223 | return getEditor().offsetToPosition( getCaretPosition(), Forward ); | |
| 224 | } | |
| 225 | ||
| 226 | /** | |
| 227 | * Allows observers to synchronize caret position changes. | |
| 228 | * | |
| 229 | * @return An observable caret property value. | |
| 230 | */ | |
| 231 | public final ObservableValue<Integer> caretPositionProperty() { | |
| 232 | return getEditor().caretPositionProperty(); | |
| 233 | } | |
| 234 | ||
| 235 | /** | |
| 236 | * Returns the text area associated with this tab. | |
| 237 | * | |
| 238 | * @return A text editor. | |
| 239 | */ | |
| 240 | private StyleClassedTextArea getEditor() { | |
| 241 | return getEditorPane().getEditor(); | |
| 242 | } | |
| 243 | ||
| 244 | /** | |
| 245 | * Returns true if the given path exactly matches this tab's path. | |
| 246 | * | |
| 247 | * @param check The path to compare against. | |
| 248 | * @return true The paths are the same. | |
| 249 | */ | |
| 250 | public boolean isPath( final Path check ) { | |
| 251 | final Path filePath = getPath(); | |
| 252 | ||
| 253 | return filePath != null && filePath.equals( check ); | |
| 254 | } | |
| 255 | ||
| 256 | /** | |
| 257 | * Reads the entire file contents from the path associated with this tab. | |
| 258 | */ | |
| 259 | private void load() { | |
| 260 | final Path path = getPath(); | |
| 261 | final File file = path.toFile(); | |
| 262 | ||
| 263 | try { | |
| 264 | if( file.exists() ) { | |
| 265 | if( file.canWrite() && file.canRead() ) { | |
| 266 | final EditorPane pane = getEditorPane(); | |
| 267 | pane.setText( asString( Files.readAllBytes( path ) ) ); | |
| 268 | pane.scrollToTop(); | |
| 269 | } | |
| 270 | else { | |
| 271 | final String msg = get( | |
| 272 | "FileEditor.loadFailed.message", | |
| 273 | file.toString(), | |
| 274 | get( "FileEditor.loadFailed.reason.permissions" ) | |
| 275 | ); | |
| 276 | getNotifier().notify( msg ); | |
| 277 | } | |
| 278 | } | |
| 279 | } catch( final Exception ex ) { | |
| 280 | getNotifier().notify( ex ); | |
| 281 | } | |
| 282 | } | |
| 283 | ||
| 284 | /** | |
| 285 | * Saves the entire file contents from the path associated with this tab. | |
| 286 | * | |
| 287 | * @return true The file has been saved. | |
| 288 | */ | |
| 289 | public boolean save() { | |
| 290 | try { | |
| 291 | final EditorPane editor = getEditorPane(); | |
| 292 | Files.write( getPath(), asBytes( editor.getText() ) ); | |
| 293 | editor.getUndoManager().mark(); | |
| 294 | return true; | |
| 295 | } catch( final Exception ex ) { | |
| 296 | return alert( | |
| 297 | "FileEditor.saveFailed.title", | |
| 298 | "FileEditor.saveFailed.message", | |
| 299 | ex | |
| 300 | ); | |
| 301 | } | |
| 302 | } | |
| 303 | ||
| 304 | /** | |
| 305 | * Creates an alert dialog and waits for it to close. | |
| 306 | * | |
| 307 | * @param titleKey Resource bundle key for the alert dialog title. | |
| 308 | * @param messageKey Resource bundle key for the alert dialog message. | |
| 309 | * @param e The unexpected happening. | |
| 310 | * @return false | |
| 311 | */ | |
| 312 | @SuppressWarnings("SameParameterValue") | |
| 313 | private boolean alert( | |
| 314 | final String titleKey, final String messageKey, final Exception e ) { | |
| 315 | final Notifier service = getNotifier(); | |
| 316 | final Path filePath = getPath(); | |
| 317 | ||
| 318 | final Notification message = service.createNotification( | |
| 319 | get( titleKey ), | |
| 320 | get( messageKey ), | |
| 321 | filePath == null ? "" : filePath, | |
| 322 | e.getMessage() | |
| 323 | ); | |
| 324 | ||
| 325 | try { | |
| 326 | service.createError( getWindow(), message ).showAndWait(); | |
| 327 | } catch( final Exception ex ) { | |
| 328 | getNotifier().notify( ex ); | |
| 329 | } | |
| 330 | ||
| 331 | return false; | |
| 332 | } | |
| 333 | ||
| 334 | private Window getWindow() { | |
| 335 | final Scene scene = getEditorPane().getScene(); | |
| 336 | ||
| 337 | if( scene == null ) { | |
| 338 | throw new UnsupportedOperationException( "No scene window available" ); | |
| 339 | } | |
| 340 | ||
| 341 | return scene.getWindow(); | |
| 342 | } | |
| 343 | ||
| 344 | /** | |
| 345 | * Returns a best guess at the file encoding. If the encoding could not be | |
| 346 | * detected, this will return the default charset for the JVM. | |
| 347 | * | |
| 348 | * @param bytes The bytes to perform character encoding detection. | |
| 349 | * @return The character encoding. | |
| 350 | */ | |
| 351 | private Charset detectEncoding( final byte[] bytes ) { | |
| 352 | final UniversalDetector detector = new UniversalDetector( null ); | |
| 353 | detector.handleData( bytes, 0, bytes.length ); | |
| 354 | detector.dataEnd(); | |
| 355 | ||
| 356 | final String charset = detector.getDetectedCharset(); | |
| 357 | final Charset charEncoding = charset == null | |
| 358 | ? Charset.defaultCharset() | |
| 359 | : Charset.forName( charset.toUpperCase( ENGLISH ) ); | |
| 360 | ||
| 361 | detector.reset(); | |
| 362 | ||
| 363 | return charEncoding; | |
| 364 | } | |
| 365 | ||
| 366 | /** | |
| 367 | * Converts the given string to an array of bytes using the encoding that was | |
| 368 | * originally detected (if any) and associated with this file. | |
| 369 | * | |
| 370 | * @param text The text to convert into the original file encoding. | |
| 371 | * @return A series of bytes ready for writing to a file. | |
| 372 | */ | |
| 373 | private byte[] asBytes( final String text ) { | |
| 374 | return text.getBytes( getEncoding() ); | |
| 375 | } | |
| 376 | ||
| 377 | /** | |
| 378 | * Converts the given bytes into a Java String. This will call setEncoding | |
| 379 | * with the encoding detected by the CharsetDetector. | |
| 380 | * | |
| 381 | * @param text The text of unknown character encoding. | |
| 382 | * @return The text, in its auto-detected encoding, as a String. | |
| 383 | */ | |
| 384 | private String asString( final byte[] text ) { | |
| 385 | setEncoding( detectEncoding( text ) ); | |
| 386 | return new String( text, getEncoding() ); | |
| 387 | } | |
| 388 | ||
| 389 | /** | |
| 390 | * Returns the path to the file being edited in this tab. | |
| 391 | * | |
| 392 | * @return A non-null instance. | |
| 393 | */ | |
| 394 | public Path getPath() { | |
| 395 | return mPath; | |
| 396 | } | |
| 397 | ||
| 398 | /** | |
| 399 | * Sets the path to a file for editing and then updates the tab with the | |
| 400 | * file contents. | |
| 401 | * | |
| 402 | * @param path A non-null instance. | |
| 403 | */ | |
| 404 | public void setPath( final Path path ) { | |
| 405 | assert path != null; | |
| 406 | ||
| 407 | mPath = path; | |
| 408 | ||
| 409 | updateTab(); | |
| 410 | } | |
| 411 | ||
| 412 | public boolean isModified() { | |
| 413 | return mModified.get(); | |
| 414 | } | |
| 415 | ||
| 416 | ReadOnlyBooleanProperty modifiedProperty() { | |
| 417 | return mModified.getReadOnlyProperty(); | |
| 418 | } | |
| 419 | ||
| 420 | BooleanProperty canUndoProperty() { | |
| 421 | return this.canUndo; | |
| 422 | } | |
| 423 | ||
| 424 | BooleanProperty canRedoProperty() { | |
| 425 | return this.canRedo; | |
| 426 | } | |
| 427 | ||
| 428 | private UndoManager<?> getUndoManager() { | |
| 429 | return getEditorPane().getUndoManager(); | |
| 430 | } | |
| 431 | ||
| 432 | /** | |
| 433 | * Forwards to the editor pane's listeners for text change events. | |
| 434 | * | |
| 435 | * @param listener The listener to notify when the text changes. | |
| 436 | */ | |
| 437 | public void addTextChangeListener( final ChangeListener<String> listener ) { | |
| 438 | getEditorPane().addTextChangeListener( listener ); | |
| 439 | } | |
| 440 | ||
| 441 | /** | |
| 442 | * Forwards to the editor pane's listeners for caret paragraph change events. | |
| 443 | * | |
| 444 | * @param listener The listener to notify when the caret changes paragraphs. | |
| 445 | */ | |
| 446 | public void addCaretParagraphListener( | |
| 447 | final ChangeListener<Integer> listener ) { | |
| 448 | getEditorPane().addCaretParagraphListener( listener ); | |
| 40 | import javafx.event.Event; | |
| 41 | import javafx.event.EventHandler; | |
| 42 | import javafx.event.EventType; | |
| 43 | import javafx.scene.Node; | |
| 44 | import javafx.scene.Scene; | |
| 45 | import javafx.scene.control.Tab; | |
| 46 | import javafx.scene.control.Tooltip; | |
| 47 | import javafx.scene.text.Text; | |
| 48 | import javafx.stage.Window; | |
| 49 | import org.fxmisc.richtext.StyleClassedTextArea; | |
| 50 | import org.fxmisc.richtext.model.TwoDimensional.Position; | |
| 51 | import org.fxmisc.undo.UndoManager; | |
| 52 | import org.mozilla.universalchardet.UniversalDetector; | |
| 53 | ||
| 54 | import java.io.File; | |
| 55 | import java.nio.charset.Charset; | |
| 56 | import java.nio.file.Files; | |
| 57 | import java.nio.file.Path; | |
| 58 | ||
| 59 | import static com.scrivenvar.Messages.get; | |
| 60 | import static java.nio.charset.StandardCharsets.UTF_8; | |
| 61 | import static java.util.Locale.ENGLISH; | |
| 62 | import static org.fxmisc.richtext.model.TwoDimensional.Bias.Forward; | |
| 63 | ||
| 64 | /** | |
| 65 | * Editor for a single file. | |
| 66 | * | |
| 67 | * @author Karl Tauber and White Magic Software, Ltd. | |
| 68 | */ | |
| 69 | public final class FileEditorTab extends Tab { | |
| 70 | ||
| 71 | private final Notifier mNotifier = Services.load( Notifier.class ); | |
| 72 | private final EditorPane mEditorPane = new MarkdownEditorPane(); | |
| 73 | ||
| 74 | private final ReadOnlyBooleanWrapper mModified = new ReadOnlyBooleanWrapper(); | |
| 75 | private final BooleanProperty canUndo = new SimpleBooleanProperty(); | |
| 76 | private final BooleanProperty canRedo = new SimpleBooleanProperty(); | |
| 77 | ||
| 78 | /** | |
| 79 | * Character encoding used by the file (or default encoding if none found). | |
| 80 | */ | |
| 81 | private Charset mEncoding = UTF_8; | |
| 82 | ||
| 83 | /** | |
| 84 | * File to load into the editor. | |
| 85 | */ | |
| 86 | private Path mPath; | |
| 87 | ||
| 88 | public FileEditorTab( final Path path ) { | |
| 89 | setPath( path ); | |
| 90 | ||
| 91 | mModified.addListener( ( observable, oldPath, newPath ) -> updateTab() ); | |
| 92 | ||
| 93 | setOnSelectionChanged( e -> { | |
| 94 | if( isSelected() ) { | |
| 95 | Platform.runLater( this::activated ); | |
| 96 | } | |
| 97 | } ); | |
| 98 | } | |
| 99 | ||
| 100 | private void updateTab() { | |
| 101 | setText( getTabTitle() ); | |
| 102 | setGraphic( getModifiedMark() ); | |
| 103 | setTooltip( getTabTooltip() ); | |
| 104 | } | |
| 105 | ||
| 106 | /** | |
| 107 | * Returns the base filename (without the directory names). | |
| 108 | * | |
| 109 | * @return The untitled text if the path hasn't been set. | |
| 110 | */ | |
| 111 | private String getTabTitle() { | |
| 112 | return getPath().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 | undoManager.forgetHistory(); | |
| 173 | ||
| 174 | // Bind the editor undo manager to the properties. | |
| 175 | mModified.bind( Bindings.not( undoManager.atMarkedPositionProperty() ) ); | |
| 176 | canUndo.bind( undoManager.undoAvailableProperty() ); | |
| 177 | canRedo.bind( undoManager.redoAvailableProperty() ); | |
| 178 | } | |
| 179 | ||
| 180 | /** | |
| 181 | * Searches from the caret position forward for the given string. | |
| 182 | * | |
| 183 | * @param needle The text string to match. | |
| 184 | */ | |
| 185 | public void searchNext( final String needle ) { | |
| 186 | final String haystack = getEditorText(); | |
| 187 | int index = haystack.indexOf( needle, getCaretPosition() ); | |
| 188 | ||
| 189 | // Wrap around. | |
| 190 | if( index == -1 ) { | |
| 191 | index = haystack.indexOf( needle ); | |
| 192 | } | |
| 193 | ||
| 194 | if( index >= 0 ) { | |
| 195 | setCaretPosition( index ); | |
| 196 | getEditor().selectRange( index, index + needle.length() ); | |
| 197 | } | |
| 198 | } | |
| 199 | ||
| 200 | /** | |
| 201 | * Returns the index into the text where the caret blinks happily away. | |
| 202 | * | |
| 203 | * @return A number from 0 to the editor's document text length. | |
| 204 | */ | |
| 205 | public int getCaretPosition() { | |
| 206 | return getEditor().getCaretPosition(); | |
| 207 | } | |
| 208 | ||
| 209 | /** | |
| 210 | * Moves the caret to a given offset. | |
| 211 | * | |
| 212 | * @param offset The new caret offset. | |
| 213 | */ | |
| 214 | private void setCaretPosition( final int offset ) { | |
| 215 | getEditor().moveTo( offset ); | |
| 216 | getEditor().requestFollowCaret(); | |
| 217 | } | |
| 218 | ||
| 219 | /** | |
| 220 | * Returns the caret's current row and column position. | |
| 221 | * | |
| 222 | * @return The caret's offset into the document. | |
| 223 | */ | |
| 224 | public Position getCaretOffset() { | |
| 225 | return getEditor().offsetToPosition( getCaretPosition(), Forward ); | |
| 226 | } | |
| 227 | ||
| 228 | /** | |
| 229 | * Allows observers to synchronize caret position changes. | |
| 230 | * | |
| 231 | * @return An observable caret property value. | |
| 232 | */ | |
| 233 | public final ObservableValue<Integer> caretPositionProperty() { | |
| 234 | return getEditor().caretPositionProperty(); | |
| 235 | } | |
| 236 | ||
| 237 | /** | |
| 238 | * Returns the text area associated with this tab. | |
| 239 | * | |
| 240 | * @return A text editor. | |
| 241 | */ | |
| 242 | private StyleClassedTextArea getEditor() { | |
| 243 | return getEditorPane().getEditor(); | |
| 244 | } | |
| 245 | ||
| 246 | /** | |
| 247 | * Returns true if the given path exactly matches this tab's path. | |
| 248 | * | |
| 249 | * @param check The path to compare against. | |
| 250 | * @return true The paths are the same. | |
| 251 | */ | |
| 252 | public boolean isPath( final Path check ) { | |
| 253 | final Path filePath = getPath(); | |
| 254 | ||
| 255 | return filePath != null && filePath.equals( check ); | |
| 256 | } | |
| 257 | ||
| 258 | /** | |
| 259 | * Reads the entire file contents from the path associated with this tab. | |
| 260 | */ | |
| 261 | private void load() { | |
| 262 | final Path path = getPath(); | |
| 263 | final File file = path.toFile(); | |
| 264 | ||
| 265 | try { | |
| 266 | if( file.exists() ) { | |
| 267 | if( file.canWrite() && file.canRead() ) { | |
| 268 | final EditorPane pane = getEditorPane(); | |
| 269 | pane.setText( asString( Files.readAllBytes( path ) ) ); | |
| 270 | pane.scrollToTop(); | |
| 271 | } | |
| 272 | else { | |
| 273 | final String msg = get( | |
| 274 | "FileEditor.loadFailed.message", | |
| 275 | file.toString(), | |
| 276 | get( "FileEditor.loadFailed.reason.permissions" ) | |
| 277 | ); | |
| 278 | getNotifier().notify( msg ); | |
| 279 | } | |
| 280 | } | |
| 281 | } catch( final Exception ex ) { | |
| 282 | getNotifier().notify( ex ); | |
| 283 | } | |
| 284 | } | |
| 285 | ||
| 286 | /** | |
| 287 | * Saves the entire file contents from the path associated with this tab. | |
| 288 | * | |
| 289 | * @return true The file has been saved. | |
| 290 | */ | |
| 291 | public boolean save() { | |
| 292 | try { | |
| 293 | final EditorPane editor = getEditorPane(); | |
| 294 | Files.write( getPath(), asBytes( editor.getText() ) ); | |
| 295 | editor.getUndoManager().mark(); | |
| 296 | return true; | |
| 297 | } catch( final Exception ex ) { | |
| 298 | return alert( | |
| 299 | "FileEditor.saveFailed.title", | |
| 300 | "FileEditor.saveFailed.message", | |
| 301 | ex | |
| 302 | ); | |
| 303 | } | |
| 304 | } | |
| 305 | ||
| 306 | /** | |
| 307 | * Creates an alert dialog and waits for it to close. | |
| 308 | * | |
| 309 | * @param titleKey Resource bundle key for the alert dialog title. | |
| 310 | * @param messageKey Resource bundle key for the alert dialog message. | |
| 311 | * @param e The unexpected happening. | |
| 312 | * @return false | |
| 313 | */ | |
| 314 | @SuppressWarnings("SameParameterValue") | |
| 315 | private boolean alert( | |
| 316 | final String titleKey, final String messageKey, final Exception e ) { | |
| 317 | final Notifier service = getNotifier(); | |
| 318 | final Path filePath = getPath(); | |
| 319 | ||
| 320 | final Notification message = service.createNotification( | |
| 321 | get( titleKey ), | |
| 322 | get( messageKey ), | |
| 323 | filePath == null ? "" : filePath, | |
| 324 | e.getMessage() | |
| 325 | ); | |
| 326 | ||
| 327 | try { | |
| 328 | service.createError( getWindow(), message ).showAndWait(); | |
| 329 | } catch( final Exception ex ) { | |
| 330 | getNotifier().notify( ex ); | |
| 331 | } | |
| 332 | ||
| 333 | return false; | |
| 334 | } | |
| 335 | ||
| 336 | private Window getWindow() { | |
| 337 | final Scene scene = getEditorPane().getScene(); | |
| 338 | ||
| 339 | if( scene == null ) { | |
| 340 | throw new UnsupportedOperationException( "No scene window available" ); | |
| 341 | } | |
| 342 | ||
| 343 | return scene.getWindow(); | |
| 344 | } | |
| 345 | ||
| 346 | /** | |
| 347 | * Returns a best guess at the file encoding. If the encoding could not be | |
| 348 | * detected, this will return the default charset for the JVM. | |
| 349 | * | |
| 350 | * @param bytes The bytes to perform character encoding detection. | |
| 351 | * @return The character encoding. | |
| 352 | */ | |
| 353 | private Charset detectEncoding( final byte[] bytes ) { | |
| 354 | final UniversalDetector detector = new UniversalDetector( null ); | |
| 355 | detector.handleData( bytes, 0, bytes.length ); | |
| 356 | detector.dataEnd(); | |
| 357 | ||
| 358 | final String charset = detector.getDetectedCharset(); | |
| 359 | final Charset charEncoding = charset == null | |
| 360 | ? Charset.defaultCharset() | |
| 361 | : Charset.forName( charset.toUpperCase( ENGLISH ) ); | |
| 362 | ||
| 363 | detector.reset(); | |
| 364 | ||
| 365 | return charEncoding; | |
| 366 | } | |
| 367 | ||
| 368 | /** | |
| 369 | * Converts the given string to an array of bytes using the encoding that was | |
| 370 | * originally detected (if any) and associated with this file. | |
| 371 | * | |
| 372 | * @param text The text to convert into the original file encoding. | |
| 373 | * @return A series of bytes ready for writing to a file. | |
| 374 | */ | |
| 375 | private byte[] asBytes( final String text ) { | |
| 376 | return text.getBytes( getEncoding() ); | |
| 377 | } | |
| 378 | ||
| 379 | /** | |
| 380 | * Converts the given bytes into a Java String. This will call setEncoding | |
| 381 | * with the encoding detected by the CharsetDetector. | |
| 382 | * | |
| 383 | * @param text The text of unknown character encoding. | |
| 384 | * @return The text, in its auto-detected encoding, as a String. | |
| 385 | */ | |
| 386 | private String asString( final byte[] text ) { | |
| 387 | setEncoding( detectEncoding( text ) ); | |
| 388 | return new String( text, getEncoding() ); | |
| 389 | } | |
| 390 | ||
| 391 | /** | |
| 392 | * Returns the path to the file being edited in this tab. | |
| 393 | * | |
| 394 | * @return A non-null instance. | |
| 395 | */ | |
| 396 | public Path getPath() { | |
| 397 | return mPath; | |
| 398 | } | |
| 399 | ||
| 400 | /** | |
| 401 | * Sets the path to a file for editing and then updates the tab with the | |
| 402 | * file contents. | |
| 403 | * | |
| 404 | * @param path A non-null instance. | |
| 405 | */ | |
| 406 | public void setPath( final Path path ) { | |
| 407 | assert path != null; | |
| 408 | ||
| 409 | mPath = path; | |
| 410 | ||
| 411 | updateTab(); | |
| 412 | } | |
| 413 | ||
| 414 | public boolean isModified() { | |
| 415 | return mModified.get(); | |
| 416 | } | |
| 417 | ||
| 418 | ReadOnlyBooleanProperty modifiedProperty() { | |
| 419 | return mModified.getReadOnlyProperty(); | |
| 420 | } | |
| 421 | ||
| 422 | BooleanProperty canUndoProperty() { | |
| 423 | return this.canUndo; | |
| 424 | } | |
| 425 | ||
| 426 | BooleanProperty canRedoProperty() { | |
| 427 | return this.canRedo; | |
| 428 | } | |
| 429 | ||
| 430 | private UndoManager<?> getUndoManager() { | |
| 431 | return getEditorPane().getUndoManager(); | |
| 432 | } | |
| 433 | ||
| 434 | /** | |
| 435 | * Forwards to the editor pane's listeners for text change events. | |
| 436 | * | |
| 437 | * @param listener The listener to notify when the text changes. | |
| 438 | */ | |
| 439 | public void addTextChangeListener( final ChangeListener<String> listener ) { | |
| 440 | getEditorPane().addTextChangeListener( listener ); | |
| 441 | } | |
| 442 | ||
| 443 | /** | |
| 444 | * Forwards to the editor pane's listeners for caret paragraph change events. | |
| 445 | * | |
| 446 | * @param listener The listener to notify when the caret changes paragraphs. | |
| 447 | */ | |
| 448 | public void addCaretParagraphListener( | |
| 449 | final ChangeListener<Integer> listener ) { | |
| 450 | getEditorPane().addCaretParagraphListener( listener ); | |
| 451 | } | |
| 452 | ||
| 453 | public <T extends Event> void addEventFilter( | |
| 454 | final EventType<T> eventType, | |
| 455 | final EventHandler<? super T> eventFilter ) { | |
| 456 | getEditorPane().getEditor().addEventFilter( eventType, eventFilter ); | |
| 449 | 457 | } |
| 450 | 458 |
| 51 | 51 | import javafx.stage.FileChooser.ExtensionFilter; |
| 52 | 52 | import javafx.stage.Window; |
| 53 | import org.fxmisc.richtext.StyledTextArea; | |
| 54 | 53 | |
| 55 | 54 | import java.io.File; |
| ... | ||
| 74 | 73 | public final class FileEditorTabPane extends TabPane { |
| 75 | 74 | |
| 76 | private final static String FILTER_EXTENSION_TITLES = "Dialog.file.choose" + | |
| 77 | ".filter"; | |
| 75 | private final static String FILTER_EXTENSION_TITLES = | |
| 76 | "Dialog.file.choose.filter"; | |
| 78 | 77 | |
| 79 | 78 | private final Options mOptions = Services.load( Options.class ); |
| ... | ||
| 149 | 148 | // a notification is kicked off. |
| 150 | 149 | getSelectionModel().selectedItemProperty().addListener( listener ); |
| 151 | } | |
| 152 | ||
| 153 | /** | |
| 154 | * Allows clients to manipulate the editor content directly. | |
| 155 | * | |
| 156 | * @return The text area for the active file editor. | |
| 157 | */ | |
| 158 | public StyledTextArea getEditor() { | |
| 159 | return getActiveFileEditor().getEditorPane().getEditor(); | |
| 160 | 150 | } |
| 161 | 151 | |
| 28 | 28 | package com.scrivenvar; |
| 29 | 29 | |
| 30 | import java.io.IOException; | |
| 31 | import java.io.InputStream; | |
| 32 | import java.util.Calendar; | |
| 33 | import java.util.Properties; | |
| 34 | ||
| 35 | import static java.lang.String.format; | |
| 36 | ||
| 30 | 37 | /** |
| 31 | 38 | * Launches the application using the {@link Main} class. |
| ... | ||
| 42 | 49 | * @param args Command-line arguments. |
| 43 | 50 | */ |
| 44 | public static void main( final String[] args ) { | |
| 51 | public static void main( final String[] args ) throws IOException { | |
| 52 | showAppInfo(); | |
| 45 | 53 | Main.main( args ); |
| 54 | } | |
| 55 | ||
| 56 | private static void showAppInfo() throws IOException { | |
| 57 | out( format( "%s version %s%n", getTitle(), getVersion() ) ); | |
| 58 | out( format( "Copyright %s by White Magic Software, Ltd.%n", getYear() ) ); | |
| 59 | out( "Portions copyright 2020 Karl Tauber.\n" ); | |
| 60 | } | |
| 61 | ||
| 62 | private static void out( final String s ) { | |
| 63 | System.out.print( s ); | |
| 64 | } | |
| 65 | ||
| 66 | private static String getTitle() throws IOException { | |
| 67 | final Properties properties = loadProperties( "messages.properties" ); | |
| 68 | return properties.getProperty( "Main.title" ); | |
| 69 | } | |
| 70 | ||
| 71 | private static String getVersion() throws IOException { | |
| 72 | final Properties properties = loadProperties( "app.properties" ); | |
| 73 | return properties.getProperty( "application.version" ); | |
| 74 | } | |
| 75 | ||
| 76 | private static String getYear() { | |
| 77 | return Integer.toString( Calendar.getInstance().get( Calendar.YEAR ) ); | |
| 78 | } | |
| 79 | ||
| 80 | @SuppressWarnings("SameParameterValue") | |
| 81 | private static Properties loadProperties( final String resource ) | |
| 82 | throws IOException { | |
| 83 | final Properties properties = new Properties(); | |
| 84 | properties.load( getResourceAsStream( getResourceName( resource ) ) ); | |
| 85 | return properties; | |
| 86 | } | |
| 87 | ||
| 88 | private static String getResourceName( final String resource ) { | |
| 89 | return format( "%s/%s", getPackagePath(), resource ); | |
| 90 | } | |
| 91 | ||
| 92 | private static String getPackagePath() { | |
| 93 | return Launcher.class.getPackageName().replace( '.', '/' ); | |
| 94 | } | |
| 95 | ||
| 96 | private static InputStream getResourceAsStream( final String resource ) { | |
| 97 | return Launcher.class.getClassLoader().getResourceAsStream( resource ); | |
| 46 | 98 | } |
| 47 | 99 | } |
| 85 | 85 | import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.*; |
| 86 | 86 | import static javafx.event.Event.fireEvent; |
| 87 | import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST; | |
| 88 | ||
| 89 | /** | |
| 90 | * Main window containing a tab pane in the center for file editors. | |
| 91 | * | |
| 92 | * @author Karl Tauber and White Magic Software, Ltd. | |
| 93 | */ | |
| 94 | public class MainWindow implements Observer { | |
| 95 | ||
| 96 | private final Options mOptions = Services.load( Options.class ); | |
| 97 | private final Snitch mSnitch = Services.load( Snitch.class ); | |
| 98 | private final Settings mSettings = Services.load( Settings.class ); | |
| 99 | private final Notifier mNotifier = Services.load( Notifier.class ); | |
| 100 | ||
| 101 | private final Scene mScene; | |
| 102 | private final StatusBar mStatusBar; | |
| 103 | private final Text mLineNumberText; | |
| 104 | private final TextField mFindTextField; | |
| 105 | ||
| 106 | private DefinitionSource mDefinitionSource = createDefaultDefinitionSource(); | |
| 107 | private final DefinitionPane mDefinitionPane = new DefinitionPane(); | |
| 108 | private final HTMLPreviewPane mPreviewPane = new HTMLPreviewPane(); | |
| 109 | private FileEditorTabPane fileEditorPane; | |
| 110 | ||
| 111 | /** | |
| 112 | * Prevents re-instantiation of processing classes. | |
| 113 | */ | |
| 114 | private final Map<FileEditorTab, Processor<String>> mProcessors = | |
| 115 | new HashMap<>(); | |
| 116 | ||
| 117 | private final Map<String, String> mResolvedMap = | |
| 118 | new HashMap<>( DEFAULT_MAP_SIZE ); | |
| 119 | ||
| 120 | /** | |
| 121 | * Listens on the definition pane for double-click events. | |
| 122 | */ | |
| 123 | private VariableNameInjector variableNameInjector; | |
| 124 | ||
| 125 | /** | |
| 126 | * Called when the definition data is changed. | |
| 127 | */ | |
| 128 | final EventHandler<TreeItem.TreeModificationEvent<Event>> mHandler = | |
| 129 | event -> { | |
| 130 | exportDefinitions( getDefinitionPath() ); | |
| 131 | interpolateResolvedMap(); | |
| 132 | refreshActiveTab(); | |
| 133 | }; | |
| 134 | ||
| 135 | public MainWindow() { | |
| 136 | mStatusBar = createStatusBar(); | |
| 137 | mLineNumberText = createLineNumberText(); | |
| 138 | mFindTextField = createFindTextField(); | |
| 139 | mScene = createScene(); | |
| 140 | ||
| 141 | initLayout(); | |
| 142 | initFindInput(); | |
| 143 | initSnitch(); | |
| 144 | initDefinitionListener(); | |
| 145 | initTabAddedListener(); | |
| 146 | initTabChangedListener(); | |
| 147 | initPreferences(); | |
| 148 | } | |
| 149 | ||
| 150 | /** | |
| 151 | * Watch for changes to external files. In particular, this awaits | |
| 152 | * modifications to any XSL files associated with XML files being edited. When | |
| 153 | * an XSL file is modified (external to the application), the snitch's ears | |
| 154 | * perk up and the file is reloaded. This keeps the XSL transformation up to | |
| 155 | * date with what's on the file system. | |
| 156 | */ | |
| 157 | private void initSnitch() { | |
| 158 | getSnitch().addObserver( this ); | |
| 159 | } | |
| 160 | ||
| 161 | /** | |
| 162 | * Initialize the find input text field to listen on F3, ENTER, and ESCAPE key | |
| 163 | * presses. | |
| 164 | */ | |
| 165 | private void initFindInput() { | |
| 166 | final TextField input = getFindTextField(); | |
| 167 | ||
| 168 | input.setOnKeyPressed( ( KeyEvent event ) -> { | |
| 169 | switch( event.getCode() ) { | |
| 170 | case F3: | |
| 171 | case ENTER: | |
| 172 | findNext(); | |
| 173 | break; | |
| 174 | case F: | |
| 175 | if( !event.isControlDown() ) { | |
| 176 | break; | |
| 177 | } | |
| 178 | case ESCAPE: | |
| 179 | getStatusBar().setGraphic( null ); | |
| 180 | getActiveFileEditor().getEditorPane().requestFocus(); | |
| 181 | break; | |
| 182 | } | |
| 183 | } ); | |
| 184 | ||
| 185 | // Remove when the input field loses focus. | |
| 186 | input.focusedProperty().addListener( | |
| 187 | ( | |
| 188 | final ObservableValue<? extends Boolean> focused, | |
| 189 | final Boolean oFocus, | |
| 190 | final Boolean nFocus ) -> { | |
| 191 | if( !nFocus ) { | |
| 192 | getStatusBar().setGraphic( null ); | |
| 193 | } | |
| 194 | } | |
| 195 | ); | |
| 196 | } | |
| 197 | ||
| 198 | /** | |
| 199 | * Listen for {@link FileEditorTabPane} to receive open definition file event. | |
| 200 | */ | |
| 201 | private void initDefinitionListener() { | |
| 202 | getFileEditorPane().onOpenDefinitionFileProperty().addListener( | |
| 203 | ( final ObservableValue<? extends Path> file, | |
| 204 | final Path oldPath, final Path newPath ) -> { | |
| 205 | // Indirectly refresh the resolved map. | |
| 206 | resetProcessors(); | |
| 207 | ||
| 208 | openDefinitions( newPath ); | |
| 209 | ||
| 210 | // Will create new processors and therefore a new resolved map. | |
| 211 | refreshActiveTab(); | |
| 212 | } | |
| 213 | ); | |
| 214 | } | |
| 215 | ||
| 216 | /** | |
| 217 | * When tabs are added, hook the various change listeners onto the new tab so | |
| 218 | * that the preview pane refreshes as necessary. | |
| 219 | */ | |
| 220 | private void initTabAddedListener() { | |
| 221 | final FileEditorTabPane editorPane = getFileEditorPane(); | |
| 222 | ||
| 223 | // Make sure the text processor kicks off when new files are opened. | |
| 224 | final ObservableList<Tab> tabs = editorPane.getTabs(); | |
| 225 | ||
| 226 | // Update the preview pane on tab changes. | |
| 227 | tabs.addListener( | |
| 228 | ( final Change<? extends Tab> change ) -> { | |
| 229 | while( change.next() ) { | |
| 230 | if( change.wasAdded() ) { | |
| 231 | // Multiple tabs can be added simultaneously. | |
| 232 | for( final Tab newTab : change.getAddedSubList() ) { | |
| 233 | final FileEditorTab tab = (FileEditorTab) newTab; | |
| 234 | ||
| 235 | initTextChangeListener( tab ); | |
| 236 | initCaretParagraphListener( tab ); | |
| 237 | initKeyboardEventListeners( tab ); | |
| 238 | // initSyntaxListener( tab ); | |
| 239 | } | |
| 240 | } | |
| 241 | } | |
| 242 | } | |
| 243 | ); | |
| 244 | } | |
| 245 | ||
| 246 | /** | |
| 247 | * Reloads the preferences from the previous session. | |
| 248 | */ | |
| 249 | private void initPreferences() { | |
| 250 | restoreDefinitionPane(); | |
| 251 | getFileEditorPane().restorePreferences(); | |
| 252 | } | |
| 253 | ||
| 254 | /** | |
| 255 | * Listen for new tab selection events. | |
| 256 | */ | |
| 257 | private void initTabChangedListener() { | |
| 258 | final FileEditorTabPane editorPane = getFileEditorPane(); | |
| 259 | ||
| 260 | // Update the preview pane changing tabs. | |
| 261 | editorPane.addTabSelectionListener( | |
| 262 | ( ObservableValue<? extends Tab> tabPane, | |
| 263 | final Tab oldTab, final Tab newTab ) -> { | |
| 264 | updateVariableNameInjector(); | |
| 265 | ||
| 266 | // If there was no old tab, then this is a first time load, which | |
| 267 | // can be ignored. | |
| 268 | if( oldTab != null ) { | |
| 269 | if( newTab == null ) { | |
| 270 | closeRemainingTab(); | |
| 271 | } | |
| 272 | else { | |
| 273 | // Update the preview with the edited text. | |
| 274 | refreshSelectedTab( (FileEditorTab) newTab ); | |
| 275 | } | |
| 276 | } | |
| 277 | } | |
| 278 | ); | |
| 279 | } | |
| 280 | ||
| 281 | /** | |
| 282 | * Ensure that the keyboard events are received when a new tab is added | |
| 283 | * to the user interface. | |
| 284 | * | |
| 285 | * @param tab The tab that can trigger keyboard events, such as control+space. | |
| 286 | */ | |
| 287 | private void initKeyboardEventListeners( final FileEditorTab tab ) { | |
| 288 | final VariableNameInjector vin = getVariableNameInjector(); | |
| 289 | vin.initKeyboardEventListeners( tab ); | |
| 290 | } | |
| 291 | ||
| 292 | private void initTextChangeListener( final FileEditorTab tab ) { | |
| 293 | tab.addTextChangeListener( | |
| 294 | ( ObservableValue<? extends String> editor, | |
| 295 | final String oldValue, final String newValue ) -> | |
| 296 | refreshSelectedTab( tab ) | |
| 297 | ); | |
| 298 | } | |
| 299 | ||
| 300 | private void initCaretParagraphListener( final FileEditorTab tab ) { | |
| 301 | tab.addCaretParagraphListener( | |
| 302 | ( ObservableValue<? extends Integer> editor, | |
| 303 | final Integer oldValue, final Integer newValue ) -> | |
| 304 | refreshSelectedTab( tab ) | |
| 305 | ); | |
| 306 | } | |
| 307 | ||
| 308 | private void updateVariableNameInjector() { | |
| 309 | getVariableNameInjector().setFileEditorTab( getActiveFileEditor() ); | |
| 310 | } | |
| 311 | ||
| 312 | private void setVariableNameInjector( final VariableNameInjector injector ) { | |
| 313 | this.variableNameInjector = injector; | |
| 314 | } | |
| 315 | ||
| 316 | private synchronized VariableNameInjector getVariableNameInjector() { | |
| 317 | if( this.variableNameInjector == null ) { | |
| 318 | final VariableNameInjector vin = createVariableNameInjector(); | |
| 319 | setVariableNameInjector( vin ); | |
| 320 | } | |
| 321 | ||
| 322 | return this.variableNameInjector; | |
| 323 | } | |
| 324 | ||
| 325 | private VariableNameInjector createVariableNameInjector() { | |
| 326 | final FileEditorTab tab = getActiveFileEditor(); | |
| 327 | final DefinitionPane pane = getDefinitionPane(); | |
| 328 | ||
| 329 | return new VariableNameInjector( tab, pane ); | |
| 330 | } | |
| 331 | ||
| 332 | /** | |
| 333 | * Called whenever the preview pane becomes out of sync with the file editor | |
| 334 | * tab. This can be called when the text changes, the caret paragraph changes, | |
| 335 | * or the file tab changes. | |
| 336 | * | |
| 337 | * @param tab The file editor tab that has been changed in some fashion. | |
| 338 | */ | |
| 339 | private void refreshSelectedTab( final FileEditorTab tab ) { | |
| 340 | if( tab == null ) { | |
| 341 | return; | |
| 342 | } | |
| 343 | ||
| 344 | getPreviewPane().setPath( tab.getPath() ); | |
| 345 | ||
| 346 | // TODO: https://github.com/DaveJarvis/scrivenvar/issues/29 | |
| 347 | final Position p = tab.getCaretOffset(); | |
| 348 | getLineNumberText().setText( | |
| 349 | get( STATUS_BAR_LINE, | |
| 350 | p.getMajor() + 1, | |
| 351 | p.getMinor() + 1, | |
| 352 | tab.getCaretPosition() + 1 | |
| 353 | ) | |
| 354 | ); | |
| 355 | ||
| 356 | Processor<String> processor = getProcessors().get( tab ); | |
| 357 | ||
| 358 | if( processor == null ) { | |
| 359 | processor = createProcessor( tab ); | |
| 360 | getProcessors().put( tab, processor ); | |
| 361 | } | |
| 362 | ||
| 363 | try { | |
| 364 | processor.processChain( tab.getEditorText() ); | |
| 365 | } catch( final Exception ex ) { | |
| 366 | error( ex ); | |
| 367 | } | |
| 368 | } | |
| 369 | ||
| 370 | private void refreshActiveTab() { | |
| 371 | refreshSelectedTab( getActiveFileEditor() ); | |
| 372 | } | |
| 373 | ||
| 374 | /** | |
| 375 | * Used to find text in the active file editor window. | |
| 376 | */ | |
| 377 | private void find() { | |
| 378 | final TextField input = getFindTextField(); | |
| 379 | getStatusBar().setGraphic( input ); | |
| 380 | input.requestFocus(); | |
| 381 | } | |
| 382 | ||
| 383 | public void findNext() { | |
| 384 | getActiveFileEditor().searchNext( getFindTextField().getText() ); | |
| 385 | } | |
| 386 | ||
| 387 | /** | |
| 388 | * Returns the variable map of interpolated definitions. | |
| 389 | * | |
| 390 | * @return A map to help dereference variables. | |
| 391 | */ | |
| 392 | private Map<String, String> getResolvedMap() { | |
| 393 | return mResolvedMap; | |
| 394 | } | |
| 395 | ||
| 396 | private void interpolateResolvedMap() { | |
| 397 | final Map<String, String> treeMap = getDefinitionPane().toMap(); | |
| 398 | final Map<String, String> map = new HashMap<>( treeMap ); | |
| 399 | MapInterpolator.interpolate( map ); | |
| 400 | ||
| 401 | getResolvedMap().clear(); | |
| 402 | getResolvedMap().putAll( map ); | |
| 403 | } | |
| 404 | ||
| 405 | /** | |
| 406 | * Called when a definition source is opened. | |
| 407 | * | |
| 408 | * @param path Path to the definition source that was opened. | |
| 409 | */ | |
| 410 | private void openDefinitions( final Path path ) { | |
| 411 | try { | |
| 412 | final DefinitionSource ds = createDefinitionSource( path ); | |
| 413 | setDefinitionSource( ds ); | |
| 414 | storeDefinitionSourceFilename( path ); | |
| 415 | ||
| 416 | final DefinitionPane pane = getDefinitionPane(); | |
| 417 | pane.update( ds ); | |
| 418 | pane.addTreeChangeHandler( mHandler ); | |
| 87 | import static javafx.scene.input.KeyCode.ENTER; | |
| 88 | import static javafx.scene.input.KeyCode.TAB; | |
| 89 | import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST; | |
| 90 | ||
| 91 | /** | |
| 92 | * Main window containing a tab pane in the center for file editors. | |
| 93 | * | |
| 94 | * @author Karl Tauber and White Magic Software, Ltd. | |
| 95 | */ | |
| 96 | public class MainWindow implements Observer { | |
| 97 | ||
| 98 | private final Options mOptions = Services.load( Options.class ); | |
| 99 | private final Snitch mSnitch = Services.load( Snitch.class ); | |
| 100 | private final Settings mSettings = Services.load( Settings.class ); | |
| 101 | private final Notifier mNotifier = Services.load( Notifier.class ); | |
| 102 | ||
| 103 | private final Scene mScene; | |
| 104 | private final StatusBar mStatusBar; | |
| 105 | private final Text mLineNumberText; | |
| 106 | private final TextField mFindTextField; | |
| 107 | ||
| 108 | private DefinitionSource mDefinitionSource = createDefaultDefinitionSource(); | |
| 109 | private final DefinitionPane mDefinitionPane = new DefinitionPane(); | |
| 110 | private final HTMLPreviewPane mPreviewPane = new HTMLPreviewPane(); | |
| 111 | private FileEditorTabPane fileEditorPane; | |
| 112 | ||
| 113 | /** | |
| 114 | * Prevents re-instantiation of processing classes. | |
| 115 | */ | |
| 116 | private final Map<FileEditorTab, Processor<String>> mProcessors = | |
| 117 | new HashMap<>(); | |
| 118 | ||
| 119 | private final Map<String, String> mResolvedMap = | |
| 120 | new HashMap<>( DEFAULT_MAP_SIZE ); | |
| 121 | ||
| 122 | /** | |
| 123 | * Listens on the definition pane for double-click events. | |
| 124 | */ | |
| 125 | private VariableNameInjector variableNameInjector; | |
| 126 | ||
| 127 | /** | |
| 128 | * Called when the definition data is changed. | |
| 129 | */ | |
| 130 | final EventHandler<TreeItem.TreeModificationEvent<Event>> mTreeHandler = | |
| 131 | event -> { | |
| 132 | exportDefinitions( getDefinitionPath() ); | |
| 133 | interpolateResolvedMap(); | |
| 134 | refreshActiveTab(); | |
| 135 | }; | |
| 136 | ||
| 137 | final EventHandler<? super KeyEvent> mDefinitionKeyHandler = | |
| 138 | event -> { | |
| 139 | if( event.getCode() == ENTER ) { | |
| 140 | getVariableNameInjector().injectSelectedItem(); | |
| 141 | } | |
| 142 | }; | |
| 143 | ||
| 144 | final EventHandler<? super KeyEvent> mEditorKeyHandler = | |
| 145 | (EventHandler<KeyEvent>) event -> { | |
| 146 | if( event.getCode() == TAB ) { | |
| 147 | getDefinitionPane().requestFocus(); | |
| 148 | event.consume(); | |
| 149 | } | |
| 150 | }; | |
| 151 | ||
| 152 | public MainWindow() { | |
| 153 | mStatusBar = createStatusBar(); | |
| 154 | mLineNumberText = createLineNumberText(); | |
| 155 | mFindTextField = createFindTextField(); | |
| 156 | mScene = createScene(); | |
| 157 | ||
| 158 | initLayout(); | |
| 159 | initFindInput(); | |
| 160 | initSnitch(); | |
| 161 | initDefinitionListener(); | |
| 162 | initTabAddedListener(); | |
| 163 | initTabChangedListener(); | |
| 164 | initPreferences(); | |
| 165 | } | |
| 166 | ||
| 167 | /** | |
| 168 | * Watch for changes to external files. In particular, this awaits | |
| 169 | * modifications to any XSL files associated with XML files being edited. When | |
| 170 | * an XSL file is modified (external to the application), the snitch's ears | |
| 171 | * perk up and the file is reloaded. This keeps the XSL transformation up to | |
| 172 | * date with what's on the file system. | |
| 173 | */ | |
| 174 | private void initSnitch() { | |
| 175 | getSnitch().addObserver( this ); | |
| 176 | } | |
| 177 | ||
| 178 | /** | |
| 179 | * Initialize the find input text field to listen on F3, ENTER, and ESCAPE key | |
| 180 | * presses. | |
| 181 | */ | |
| 182 | private void initFindInput() { | |
| 183 | final TextField input = getFindTextField(); | |
| 184 | ||
| 185 | input.setOnKeyPressed( ( KeyEvent event ) -> { | |
| 186 | switch( event.getCode() ) { | |
| 187 | case F3: | |
| 188 | case ENTER: | |
| 189 | findNext(); | |
| 190 | break; | |
| 191 | case F: | |
| 192 | if( !event.isControlDown() ) { | |
| 193 | break; | |
| 194 | } | |
| 195 | case ESCAPE: | |
| 196 | getStatusBar().setGraphic( null ); | |
| 197 | getActiveFileEditor().getEditorPane().requestFocus(); | |
| 198 | break; | |
| 199 | } | |
| 200 | } ); | |
| 201 | ||
| 202 | // Remove when the input field loses focus. | |
| 203 | input.focusedProperty().addListener( | |
| 204 | ( | |
| 205 | final ObservableValue<? extends Boolean> focused, | |
| 206 | final Boolean oFocus, | |
| 207 | final Boolean nFocus ) -> { | |
| 208 | if( !nFocus ) { | |
| 209 | getStatusBar().setGraphic( null ); | |
| 210 | } | |
| 211 | } | |
| 212 | ); | |
| 213 | } | |
| 214 | ||
| 215 | /** | |
| 216 | * Listen for {@link FileEditorTabPane} to receive open definition file event. | |
| 217 | */ | |
| 218 | private void initDefinitionListener() { | |
| 219 | getFileEditorPane().onOpenDefinitionFileProperty().addListener( | |
| 220 | ( final ObservableValue<? extends Path> file, | |
| 221 | final Path oldPath, final Path newPath ) -> { | |
| 222 | // Indirectly refresh the resolved map. | |
| 223 | resetProcessors(); | |
| 224 | ||
| 225 | openDefinitions( newPath ); | |
| 226 | ||
| 227 | // Will create new processors and therefore a new resolved map. | |
| 228 | refreshActiveTab(); | |
| 229 | } | |
| 230 | ); | |
| 231 | } | |
| 232 | ||
| 233 | /** | |
| 234 | * When tabs are added, hook the various change listeners onto the new tab so | |
| 235 | * that the preview pane refreshes as necessary. | |
| 236 | */ | |
| 237 | private void initTabAddedListener() { | |
| 238 | final FileEditorTabPane editorPane = getFileEditorPane(); | |
| 239 | ||
| 240 | // Make sure the text processor kicks off when new files are opened. | |
| 241 | final ObservableList<Tab> tabs = editorPane.getTabs(); | |
| 242 | ||
| 243 | // Update the preview pane on tab changes. | |
| 244 | tabs.addListener( | |
| 245 | ( final Change<? extends Tab> change ) -> { | |
| 246 | while( change.next() ) { | |
| 247 | if( change.wasAdded() ) { | |
| 248 | // Multiple tabs can be added simultaneously. | |
| 249 | for( final Tab newTab : change.getAddedSubList() ) { | |
| 250 | final FileEditorTab tab = (FileEditorTab) newTab; | |
| 251 | ||
| 252 | initTextChangeListener( tab ); | |
| 253 | initCaretParagraphListener( tab ); | |
| 254 | initKeyboardEventListeners( tab ); | |
| 255 | // initSyntaxListener( tab ); | |
| 256 | } | |
| 257 | } | |
| 258 | } | |
| 259 | } | |
| 260 | ); | |
| 261 | } | |
| 262 | ||
| 263 | /** | |
| 264 | * Reloads the preferences from the previous session. | |
| 265 | */ | |
| 266 | private void initPreferences() { | |
| 267 | restoreDefinitionPane(); | |
| 268 | getFileEditorPane().restorePreferences(); | |
| 269 | } | |
| 270 | ||
| 271 | /** | |
| 272 | * Listen for new tab selection events. | |
| 273 | */ | |
| 274 | private void initTabChangedListener() { | |
| 275 | final FileEditorTabPane editorPane = getFileEditorPane(); | |
| 276 | ||
| 277 | // Update the preview pane changing tabs. | |
| 278 | editorPane.addTabSelectionListener( | |
| 279 | ( ObservableValue<? extends Tab> tabPane, | |
| 280 | final Tab oldTab, final Tab newTab ) -> { | |
| 281 | updateVariableNameInjector(); | |
| 282 | ||
| 283 | // If there was no old tab, then this is a first time load, which | |
| 284 | // can be ignored. | |
| 285 | if( oldTab != null ) { | |
| 286 | if( newTab == null ) { | |
| 287 | closeRemainingTab(); | |
| 288 | } | |
| 289 | else { | |
| 290 | // Update the preview with the edited text. | |
| 291 | refreshSelectedTab( (FileEditorTab) newTab ); | |
| 292 | } | |
| 293 | } | |
| 294 | } | |
| 295 | ); | |
| 296 | } | |
| 297 | ||
| 298 | /** | |
| 299 | * Ensure that the keyboard events are received when a new tab is added | |
| 300 | * to the user interface. | |
| 301 | * | |
| 302 | * @param tab The tab that can trigger keyboard events, such as control+space. | |
| 303 | */ | |
| 304 | private void initKeyboardEventListeners( final FileEditorTab tab ) { | |
| 305 | final VariableNameInjector vin = getVariableNameInjector(); | |
| 306 | vin.initKeyboardEventListeners( tab ); | |
| 307 | ||
| 308 | tab.addEventFilter( KeyEvent.KEY_PRESSED, mEditorKeyHandler ); | |
| 309 | } | |
| 310 | ||
| 311 | private void initTextChangeListener( final FileEditorTab tab ) { | |
| 312 | tab.addTextChangeListener( | |
| 313 | ( ObservableValue<? extends String> editor, | |
| 314 | final String oldValue, final String newValue ) -> | |
| 315 | refreshSelectedTab( tab ) | |
| 316 | ); | |
| 317 | } | |
| 318 | ||
| 319 | private void initCaretParagraphListener( final FileEditorTab tab ) { | |
| 320 | tab.addCaretParagraphListener( | |
| 321 | ( ObservableValue<? extends Integer> editor, | |
| 322 | final Integer oldValue, final Integer newValue ) -> | |
| 323 | refreshSelectedTab( tab ) | |
| 324 | ); | |
| 325 | } | |
| 326 | ||
| 327 | private void updateVariableNameInjector() { | |
| 328 | getVariableNameInjector().setFileEditorTab( getActiveFileEditor() ); | |
| 329 | } | |
| 330 | ||
| 331 | private void setVariableNameInjector( final VariableNameInjector injector ) { | |
| 332 | this.variableNameInjector = injector; | |
| 333 | } | |
| 334 | ||
| 335 | private synchronized VariableNameInjector getVariableNameInjector() { | |
| 336 | if( this.variableNameInjector == null ) { | |
| 337 | final VariableNameInjector vin = createVariableNameInjector(); | |
| 338 | setVariableNameInjector( vin ); | |
| 339 | } | |
| 340 | ||
| 341 | return this.variableNameInjector; | |
| 342 | } | |
| 343 | ||
| 344 | private VariableNameInjector createVariableNameInjector() { | |
| 345 | final FileEditorTab tab = getActiveFileEditor(); | |
| 346 | final DefinitionPane pane = getDefinitionPane(); | |
| 347 | ||
| 348 | return new VariableNameInjector( tab, pane ); | |
| 349 | } | |
| 350 | ||
| 351 | /** | |
| 352 | * Called whenever the preview pane becomes out of sync with the file editor | |
| 353 | * tab. This can be called when the text changes, the caret paragraph changes, | |
| 354 | * or the file tab changes. | |
| 355 | * | |
| 356 | * @param tab The file editor tab that has been changed in some fashion. | |
| 357 | */ | |
| 358 | private void refreshSelectedTab( final FileEditorTab tab ) { | |
| 359 | if( tab == null ) { | |
| 360 | return; | |
| 361 | } | |
| 362 | ||
| 363 | getPreviewPane().setPath( tab.getPath() ); | |
| 364 | ||
| 365 | // TODO: https://github.com/DaveJarvis/scrivenvar/issues/29 | |
| 366 | final Position p = tab.getCaretOffset(); | |
| 367 | getLineNumberText().setText( | |
| 368 | get( STATUS_BAR_LINE, | |
| 369 | p.getMajor() + 1, | |
| 370 | p.getMinor() + 1, | |
| 371 | tab.getCaretPosition() + 1 | |
| 372 | ) | |
| 373 | ); | |
| 374 | ||
| 375 | Processor<String> processor = getProcessors().get( tab ); | |
| 376 | ||
| 377 | if( processor == null ) { | |
| 378 | processor = createProcessor( tab ); | |
| 379 | getProcessors().put( tab, processor ); | |
| 380 | } | |
| 381 | ||
| 382 | try { | |
| 383 | processor.processChain( tab.getEditorText() ); | |
| 384 | } catch( final Exception ex ) { | |
| 385 | error( ex ); | |
| 386 | } | |
| 387 | } | |
| 388 | ||
| 389 | private void refreshActiveTab() { | |
| 390 | refreshSelectedTab( getActiveFileEditor() ); | |
| 391 | } | |
| 392 | ||
| 393 | /** | |
| 394 | * Used to find text in the active file editor window. | |
| 395 | */ | |
| 396 | private void find() { | |
| 397 | final TextField input = getFindTextField(); | |
| 398 | getStatusBar().setGraphic( input ); | |
| 399 | input.requestFocus(); | |
| 400 | } | |
| 401 | ||
| 402 | public void findNext() { | |
| 403 | getActiveFileEditor().searchNext( getFindTextField().getText() ); | |
| 404 | } | |
| 405 | ||
| 406 | /** | |
| 407 | * Returns the variable map of interpolated definitions. | |
| 408 | * | |
| 409 | * @return A map to help dereference variables. | |
| 410 | */ | |
| 411 | private Map<String, String> getResolvedMap() { | |
| 412 | return mResolvedMap; | |
| 413 | } | |
| 414 | ||
| 415 | private void interpolateResolvedMap() { | |
| 416 | final Map<String, String> treeMap = getDefinitionPane().toMap(); | |
| 417 | final Map<String, String> map = new HashMap<>( treeMap ); | |
| 418 | MapInterpolator.interpolate( map ); | |
| 419 | ||
| 420 | getResolvedMap().clear(); | |
| 421 | getResolvedMap().putAll( map ); | |
| 422 | } | |
| 423 | ||
| 424 | /** | |
| 425 | * Called when a definition source is opened. | |
| 426 | * | |
| 427 | * @param path Path to the definition source that was opened. | |
| 428 | */ | |
| 429 | private void openDefinitions( final Path path ) { | |
| 430 | try { | |
| 431 | final DefinitionSource ds = createDefinitionSource( path ); | |
| 432 | setDefinitionSource( ds ); | |
| 433 | storeDefinitionSourceFilename( path ); | |
| 434 | ||
| 435 | final DefinitionPane pane = getDefinitionPane(); | |
| 436 | pane.update( ds ); | |
| 437 | pane.addTreeChangeHandler( mTreeHandler ); | |
| 438 | pane.addKeyEventHandler( mDefinitionKeyHandler ); | |
| 419 | 439 | |
| 420 | 440 | interpolateResolvedMap(); |
| 28 | 28 | package com.scrivenvar.decorators; |
| 29 | 29 | |
| 30 | import java.util.regex.Pattern; | |
| 31 | ||
| 32 | 30 | /** |
| 33 | 31 | * Brackets variable names with dollar symbols. |
| 34 | 32 | * |
| 35 | 33 | * @author White Magic Software, Ltd. |
| 36 | 34 | */ |
| 37 | 35 | public class YamlVariableDecorator implements VariableDecorator { |
| 38 | ||
| 39 | /** | |
| 40 | * Matches variables delimited by dollar symbols. The outer group is necessary | |
| 41 | * for substring replacement of delimited references. | |
| 42 | */ | |
| 43 | public final static String REGEX = "(\\$(.*?)\\$)"; | |
| 44 | ||
| 45 | /** | |
| 46 | * Compiled regular expression for matching delimited references. | |
| 47 | */ | |
| 48 | public final static Pattern REGEX_PATTERN = Pattern.compile( REGEX ); | |
| 49 | 36 | |
| 50 | 37 | /** |
| 38 | 38 | import javafx.util.StringConverter; |
| 39 | 39 | |
| 40 | import java.util.LinkedList; | |
| 41 | import java.util.List; | |
| 42 | import java.util.Map; | |
| 43 | ||
| 44 | import static com.scrivenvar.Messages.get; | |
| 45 | import static javafx.scene.input.KeyEvent.KEY_PRESSED; | |
| 46 | ||
| 47 | /** | |
| 48 | * Provides the user interface that holdsa {@link TreeView}, which | |
| 49 | * allows users to interact with key/value pairs loaded from the | |
| 50 | * {@link DocumentParser} and adapted using a {@link TreeAdapter}. | |
| 51 | * | |
| 52 | * @author White Magic Software, Ltd. | |
| 53 | */ | |
| 54 | public final class DefinitionPane extends AbstractPane { | |
| 55 | ||
| 56 | /** | |
| 57 | * Trimmed off the end of a word to match a variable name. | |
| 58 | */ | |
| 59 | private final static String TERMINALS = ":;,.!?-/\\¡¿"; | |
| 60 | ||
| 61 | /** | |
| 62 | * Contains a view of the definitions. | |
| 63 | */ | |
| 64 | private final TreeView<String> mTreeView = new TreeView<>(); | |
| 65 | ||
| 66 | /** | |
| 67 | * Constructs a definition pane with a given tree view root. | |
| 68 | */ | |
| 69 | public DefinitionPane() { | |
| 70 | final var treeView = getTreeView(); | |
| 71 | treeView.setEditable( true ); | |
| 72 | treeView.setCellFactory( cell -> createTreeCell() ); | |
| 73 | treeView.setContextMenu( createContextMenu() ); | |
| 74 | treeView.addEventFilter( KEY_PRESSED, this::keyEventFilter ); | |
| 75 | treeView.setShowRoot( false ); | |
| 76 | getSelectionModel().setSelectionMode( SelectionMode.MULTIPLE ); | |
| 77 | } | |
| 78 | ||
| 79 | /** | |
| 80 | * Changes the root of the {@link TreeView} to the root of the | |
| 81 | * {@link TreeView} from the {@link DefinitionSource}. | |
| 82 | * | |
| 83 | * @param definitionSource Container for the hierarchy of key/value pairs | |
| 84 | * to replace the existing hierarchy. | |
| 85 | */ | |
| 86 | public void update( final DefinitionSource definitionSource ) { | |
| 87 | assert definitionSource != null; | |
| 88 | ||
| 89 | final TreeAdapter treeAdapter = definitionSource.getTreeAdapter(); | |
| 90 | final TreeItem<String> root = treeAdapter.adapt( | |
| 91 | get( "Pane.definition.node.root.title" ) | |
| 92 | ); | |
| 93 | ||
| 94 | getTreeView().setRoot( root ); | |
| 95 | } | |
| 96 | ||
| 97 | public Map<String, String> toMap() { | |
| 98 | return TreeItemAdapter.toMap( getTreeView().getRoot() ); | |
| 99 | } | |
| 100 | ||
| 101 | /** | |
| 102 | * Informs the caller of whenever any {@link TreeItem} in the {@link TreeView} | |
| 103 | * is modified. The modifications include: item value changes, item additions, | |
| 104 | * and item removals. | |
| 105 | * <p> | |
| 106 | * Safe to call multiple times; if a handler is already registered, the | |
| 107 | * old handler is used. | |
| 108 | * </p> | |
| 109 | * | |
| 110 | * @param handler The handler to call whenever any {@link TreeItem} changes. | |
| 111 | */ | |
| 112 | public void addTreeChangeHandler( | |
| 113 | final EventHandler<TreeItem.TreeModificationEvent<Event>> handler ) { | |
| 114 | final TreeItem<String> root = getTreeView().getRoot(); | |
| 115 | root.addEventHandler( TreeItem.valueChangedEvent(), handler ); | |
| 116 | root.addEventHandler( TreeItem.childrenModificationEvent(), handler ); | |
| 117 | } | |
| 118 | ||
| 119 | /** | |
| 120 | * Answers whether the {@link TreeItem}s in the {@link TreeView} are suitably | |
| 121 | * well-formed for export. A tree is considered well-formed if the following | |
| 122 | * conditions are met: | |
| 123 | * | |
| 124 | * <ul> | |
| 125 | * <li>The root node contains at least one child node having a leaf.</li> | |
| 126 | * <li>There are no leaf nodes with sibling leaf nodes.</li> | |
| 127 | * </ul> | |
| 128 | * | |
| 129 | * @return {@code null} if the document is well-formed, otherwise the | |
| 130 | * problematic child {@link TreeItem}. | |
| 131 | */ | |
| 132 | public TreeItem<String> isTreeWellFormed() { | |
| 133 | final var root = getTreeView().getRoot(); | |
| 134 | ||
| 135 | for( final var child : root.getChildren() ) { | |
| 136 | final var problemChild = isWellFormed( child ); | |
| 137 | ||
| 138 | if( child.isLeaf() || problemChild != null ) { | |
| 139 | return problemChild; | |
| 140 | } | |
| 141 | } | |
| 142 | ||
| 143 | return null; | |
| 144 | } | |
| 145 | ||
| 146 | /** | |
| 147 | * Determines whether the document is well-formed by ensuring that | |
| 148 | * child branches do not contain multiple leaves. | |
| 149 | * | |
| 150 | * @param item The sub-tree to check for well-formedness. | |
| 151 | * @return {@code null} when the tree is well-formed, otherwise the | |
| 152 | * problematic {@link TreeItem}. | |
| 153 | */ | |
| 154 | private TreeItem<String> isWellFormed( final TreeItem<String> item ) { | |
| 155 | int childLeafs = 0; | |
| 156 | int childBranches = 0; | |
| 157 | ||
| 158 | for( final TreeItem<String> child : item.getChildren() ) { | |
| 159 | if( child.isLeaf() ) { | |
| 160 | childLeafs++; | |
| 161 | } | |
| 162 | else { | |
| 163 | childBranches++; | |
| 164 | } | |
| 165 | ||
| 166 | final var problemChild = isWellFormed( child ); | |
| 167 | ||
| 168 | if( problemChild != null ) { | |
| 169 | return problemChild; | |
| 170 | } | |
| 171 | } | |
| 172 | ||
| 173 | return ((childBranches > 0 && childLeafs == 0) || | |
| 174 | (childBranches == 0 && childLeafs <= 1)) ? null : item; | |
| 175 | } | |
| 176 | ||
| 177 | /** | |
| 178 | * Returns the leaf that matches the given value. If the value is terminally | |
| 179 | * punctuated, the punctuation is removed if no match was found. | |
| 180 | * | |
| 181 | * @param value The value to find, never null. | |
| 182 | * @param findMode Defines how to match words. | |
| 183 | * @return The leaf that contains the given value, or null if neither the | |
| 184 | * original value nor the terminally-trimmed value was found. | |
| 185 | */ | |
| 186 | public VariableTreeItem<String> findLeaf( | |
| 187 | final String value, final FindMode findMode ) { | |
| 188 | final VariableTreeItem<String> root = getTreeRoot(); | |
| 189 | final VariableTreeItem<String> leaf = root.findLeaf( value, findMode ); | |
| 190 | ||
| 191 | return leaf == null | |
| 192 | ? root.findLeaf( rtrimTerminalPunctuation( value ) ) | |
| 193 | : leaf; | |
| 194 | } | |
| 195 | ||
| 196 | /** | |
| 197 | * Removes punctuation from the end of a string. | |
| 198 | * | |
| 199 | * @param s The string to trim, never null. | |
| 200 | * @return The string trimmed of all terminal characters from the end | |
| 201 | */ | |
| 202 | private String rtrimTerminalPunctuation( final String s ) { | |
| 203 | assert s != null; | |
| 204 | int index = s.length() - 1; | |
| 205 | ||
| 206 | while( index > 0 && (TERMINALS.indexOf( s.charAt( index ) ) >= 0) ) { | |
| 207 | index--; | |
| 208 | } | |
| 209 | ||
| 210 | return s.substring( 0, index ); | |
| 211 | } | |
| 212 | ||
| 213 | /** | |
| 214 | * Expands the node to the root, recursively. | |
| 215 | * | |
| 216 | * @param <T> The type of tree item to expand (usually String). | |
| 217 | * @param node The node to expand. | |
| 218 | */ | |
| 219 | public <T> void expand( final TreeItem<T> node ) { | |
| 220 | if( node != null ) { | |
| 221 | expand( node.getParent() ); | |
| 222 | ||
| 223 | if( !node.isLeaf() ) { | |
| 224 | node.setExpanded( true ); | |
| 225 | } | |
| 226 | } | |
| 227 | } | |
| 228 | ||
| 229 | public void select( final TreeItem<String> item ) { | |
| 230 | getSelectionModel().clearSelection(); | |
| 231 | getSelectionModel().select( getTreeView().getRow( item ) ); | |
| 232 | } | |
| 233 | ||
| 234 | /** | |
| 235 | * Collapses the tree, recursively. | |
| 236 | */ | |
| 237 | public void collapse() { | |
| 238 | collapse( getTreeRoot().getChildren() ); | |
| 239 | } | |
| 240 | ||
| 241 | /** | |
| 242 | * Collapses the tree, recursively. | |
| 243 | * | |
| 244 | * @param <T> The type of tree item to expand (usually String). | |
| 245 | * @param nodes The nodes to collapse. | |
| 246 | */ | |
| 247 | private <T> void collapse( final ObservableList<TreeItem<T>> nodes ) { | |
| 248 | for( final TreeItem<T> node : nodes ) { | |
| 249 | node.setExpanded( false ); | |
| 250 | collapse( node.getChildren() ); | |
| 251 | } | |
| 252 | } | |
| 253 | ||
| 254 | /** | |
| 255 | * @return {@code true} when the user is editing a {@link TreeItem}. | |
| 256 | */ | |
| 257 | private boolean isEditingTreeItem() { | |
| 258 | return getTreeView().editingItemProperty().getValue() != null; | |
| 259 | } | |
| 260 | ||
| 261 | /** | |
| 262 | * Changes to edit mode for the selected item. | |
| 263 | */ | |
| 264 | private void editSelectedItem() { | |
| 265 | getTreeView().edit( getSelectedItem() ); | |
| 266 | } | |
| 267 | ||
| 268 | /** | |
| 269 | * Removes all selected items from the {@link TreeView}. | |
| 270 | */ | |
| 271 | private void deleteSelectedItems() { | |
| 272 | for( final TreeItem<String> item : getSelectedItems() ) { | |
| 273 | final TreeItem<String> parent = item.getParent(); | |
| 274 | ||
| 275 | if( parent != null ) { | |
| 276 | parent.getChildren().remove( item ); | |
| 277 | } | |
| 278 | } | |
| 279 | } | |
| 280 | ||
| 281 | /** | |
| 282 | * Deletes the selected item. | |
| 283 | */ | |
| 284 | private void deleteSelectedItem() { | |
| 285 | final TreeItem<String> c = getSelectedItem(); | |
| 286 | getSiblings( c ).remove( c ); | |
| 287 | } | |
| 288 | ||
| 289 | /** | |
| 290 | * Adds a new item under the selected item (or root if nothing is selected). | |
| 291 | * There are a few conditions to consider: when adding to the root, | |
| 292 | * when adding to a leaf, and when adding to a non-leaf. Items added to the | |
| 293 | * root must contain two items: a key and a value. | |
| 294 | */ | |
| 295 | private void addItem() { | |
| 296 | final TreeItem<String> value = createTreeItem(); | |
| 297 | getSelectedItem().getChildren().add( value ); | |
| 298 | expand( value ); | |
| 299 | select( value ); | |
| 300 | } | |
| 301 | ||
| 302 | private ContextMenu createContextMenu() { | |
| 303 | final ContextMenu menu = new ContextMenu(); | |
| 304 | final ObservableList<MenuItem> items = menu.getItems(); | |
| 305 | ||
| 306 | addMenuItem( items, "Definition.menu.create" ) | |
| 307 | .setOnAction( e -> addItem() ); | |
| 308 | ||
| 309 | addMenuItem( items, "Definition.menu.rename" ) | |
| 310 | .setOnAction( e -> editSelectedItem() ); | |
| 311 | ||
| 312 | addMenuItem( items, "Definition.menu.remove" ) | |
| 313 | .setOnAction( e -> deleteSelectedItem() ); | |
| 314 | ||
| 315 | return menu; | |
| 316 | } | |
| 317 | ||
| 318 | /** | |
| 319 | * Executes hot-keys for edits to the definition tree. | |
| 320 | * | |
| 321 | * @param event Contains the key code of the key that was pressed. | |
| 322 | */ | |
| 323 | private void keyEventFilter( final KeyEvent event ) { | |
| 324 | if( !isEditingTreeItem() ) { | |
| 325 | switch( event.getCode() ) { | |
| 326 | case ENTER: | |
| 327 | expand( getSelectedItem() ); | |
| 328 | event.consume(); | |
| 329 | ||
| 330 | break; | |
| 331 | ||
| 332 | case DELETE: | |
| 333 | deleteSelectedItems(); | |
| 334 | break; | |
| 335 | ||
| 336 | case INSERT: | |
| 337 | addItem(); | |
| 338 | break; | |
| 339 | ||
| 340 | case R: | |
| 341 | if( event.isControlDown() ) { | |
| 342 | editSelectedItem(); | |
| 343 | } | |
| 344 | ||
| 345 | break; | |
| 346 | } | |
| 347 | } | |
| 348 | } | |
| 349 | ||
| 350 | /** | |
| 351 | * Adds a menu item to a list of menu items. | |
| 352 | * | |
| 353 | * @param items The list of menu items to append to. | |
| 354 | * @param labelKey The resource bundle key name for the menu item's label. | |
| 355 | * @return The menu item added to the list of menu items. | |
| 356 | */ | |
| 357 | private MenuItem addMenuItem( | |
| 358 | final List<MenuItem> items, final String labelKey ) { | |
| 359 | final MenuItem menuItem = createMenuItem( labelKey ); | |
| 360 | items.add( menuItem ); | |
| 361 | return menuItem; | |
| 362 | } | |
| 363 | ||
| 364 | private MenuItem createMenuItem( final String labelKey ) { | |
| 365 | return new MenuItem( get( labelKey ) ); | |
| 366 | } | |
| 367 | ||
| 368 | private VariableTreeItem<String> createTreeItem() { | |
| 369 | return new VariableTreeItem<>( get( "Definition.menu.add.default" ) ); | |
| 370 | } | |
| 371 | ||
| 372 | private TreeCell<String> createTreeCell() { | |
| 373 | return new TextFieldTreeCell<>( | |
| 374 | createStringConverter() ) { | |
| 375 | @Override | |
| 376 | public void commitEdit( final String newValue ) { | |
| 377 | super.commitEdit( newValue ); | |
| 378 | requestFocus(); | |
| 379 | select( getTreeItem() ); | |
| 380 | } | |
| 381 | }; | |
| 382 | } | |
| 383 | ||
| 384 | private StringConverter<String> createStringConverter() { | |
| 385 | return new StringConverter<>() { | |
| 386 | @Override | |
| 387 | public String toString( final String object ) { | |
| 388 | return object == null ? "" : object; | |
| 389 | } | |
| 390 | ||
| 391 | @Override | |
| 392 | public String fromString( final String string ) { | |
| 393 | return string == null ? "" : string; | |
| 394 | } | |
| 395 | }; | |
| 396 | } | |
| 397 | ||
| 398 | /** | |
| 399 | * Returns the tree view that contains the definition hierarchy. | |
| 400 | * | |
| 401 | * @return A non-null instance. | |
| 402 | */ | |
| 403 | public TreeView<String> getTreeView() { | |
| 404 | return mTreeView; | |
| 405 | } | |
| 406 | ||
| 407 | /** | |
| 408 | * Returns the root node to the tree view. | |
| 409 | * | |
| 410 | * @return getTreeView() | |
| 411 | */ | |
| 412 | public Node getNode() { | |
| 413 | return getTreeView(); | |
| 414 | } | |
| 415 | ||
| 416 | /** | |
| 417 | * Returns the root of the tree. | |
| 418 | * | |
| 419 | * @return The first node added to the definition tree. | |
| 420 | */ | |
| 421 | private VariableTreeItem<String> getTreeRoot() { | |
| 422 | final TreeItem<String> root = getTreeView().getRoot(); | |
| 423 | ||
| 424 | return root instanceof VariableTreeItem ? | |
| 425 | (VariableTreeItem<String>) root : new VariableTreeItem<>( "root" ); | |
| 426 | } | |
| 427 | ||
| 428 | private ObservableList<TreeItem<String>> getSiblings( | |
| 429 | final TreeItem<String> item ) { | |
| 430 | final TreeItem<String> root = getTreeView().getRoot(); | |
| 431 | final TreeItem<String> parent = | |
| 432 | (item == null || item == root) ? root : item.getParent(); | |
| 433 | ||
| 434 | return parent.getChildren(); | |
| 435 | } | |
| 436 | ||
| 437 | private MultipleSelectionModel<TreeItem<String>> getSelectionModel() { | |
| 438 | return getTreeView().getSelectionModel(); | |
| 439 | } | |
| 440 | ||
| 441 | /** | |
| 442 | * Returns a copy of all the selected items. | |
| 443 | * | |
| 444 | * @return A list, possibly empty, containing all selected items in the | |
| 445 | * {@link TreeView}. | |
| 446 | */ | |
| 447 | private List<TreeItem<String>> getSelectedItems() { | |
| 448 | return new LinkedList<>( getSelectionModel().getSelectedItems() ); | |
| 449 | } | |
| 450 | ||
| 451 | private TreeItem<String> getSelectedItem() { | |
| 452 | final TreeItem<String> item = getSelectionModel().getSelectedItem(); | |
| 453 | return item == null ? getTreeView().getRoot() : item; | |
| 40 | import java.util.*; | |
| 41 | ||
| 42 | import static com.scrivenvar.Messages.get; | |
| 43 | import static javafx.scene.input.KeyEvent.KEY_PRESSED; | |
| 44 | ||
| 45 | /** | |
| 46 | * Provides the user interface that holdsa {@link TreeView}, which | |
| 47 | * allows users to interact with key/value pairs loaded from the | |
| 48 | * {@link DocumentParser} and adapted using a {@link TreeAdapter}. | |
| 49 | * | |
| 50 | * @author White Magic Software, Ltd. | |
| 51 | */ | |
| 52 | public final class DefinitionPane extends AbstractPane { | |
| 53 | ||
| 54 | /** | |
| 55 | * Trimmed off the end of a word to match a variable name. | |
| 56 | */ | |
| 57 | private final static String TERMINALS = ":;,.!?-/\\¡¿"; | |
| 58 | ||
| 59 | /** | |
| 60 | * Contains a view of the definitions. | |
| 61 | */ | |
| 62 | private final TreeView<String> mTreeView = new TreeView<>(); | |
| 63 | ||
| 64 | /** | |
| 65 | * Handlers for key press events. | |
| 66 | */ | |
| 67 | private final Set<EventHandler<? super KeyEvent>> mKeyEventHandlers | |
| 68 | = new HashSet<>(); | |
| 69 | ||
| 70 | /** | |
| 71 | * Constructs a definition pane with a given tree view root. | |
| 72 | */ | |
| 73 | public DefinitionPane() { | |
| 74 | final var treeView = getTreeView(); | |
| 75 | treeView.setEditable( true ); | |
| 76 | treeView.setCellFactory( cell -> createTreeCell() ); | |
| 77 | treeView.setContextMenu( createContextMenu() ); | |
| 78 | treeView.addEventFilter( KEY_PRESSED, this::keyEventFilter ); | |
| 79 | treeView.setShowRoot( false ); | |
| 80 | getSelectionModel().setSelectionMode( SelectionMode.MULTIPLE ); | |
| 81 | } | |
| 82 | ||
| 83 | /** | |
| 84 | * Changes the root of the {@link TreeView} to the root of the | |
| 85 | * {@link TreeView} from the {@link DefinitionSource}. | |
| 86 | * | |
| 87 | * @param definitionSource Container for the hierarchy of key/value pairs | |
| 88 | * to replace the existing hierarchy. | |
| 89 | */ | |
| 90 | public void update( final DefinitionSource definitionSource ) { | |
| 91 | assert definitionSource != null; | |
| 92 | ||
| 93 | final TreeAdapter treeAdapter = definitionSource.getTreeAdapter(); | |
| 94 | final TreeItem<String> root = treeAdapter.adapt( | |
| 95 | get( "Pane.definition.node.root.title" ) | |
| 96 | ); | |
| 97 | ||
| 98 | getTreeView().setRoot( root ); | |
| 99 | } | |
| 100 | ||
| 101 | public Map<String, String> toMap() { | |
| 102 | return TreeItemAdapter.toMap( getTreeView().getRoot() ); | |
| 103 | } | |
| 104 | ||
| 105 | /** | |
| 106 | * Informs the caller of whenever any {@link TreeItem} in the {@link TreeView} | |
| 107 | * is modified. The modifications include: item value changes, item additions, | |
| 108 | * and item removals. | |
| 109 | * <p> | |
| 110 | * Safe to call multiple times; if a handler is already registered, the | |
| 111 | * old handler is used. | |
| 112 | * </p> | |
| 113 | * | |
| 114 | * @param handler The handler to call whenever any {@link TreeItem} changes. | |
| 115 | */ | |
| 116 | public void addTreeChangeHandler( | |
| 117 | final EventHandler<TreeItem.TreeModificationEvent<Event>> handler ) { | |
| 118 | final TreeItem<String> root = getTreeView().getRoot(); | |
| 119 | root.addEventHandler( TreeItem.valueChangedEvent(), handler ); | |
| 120 | root.addEventHandler( TreeItem.childrenModificationEvent(), handler ); | |
| 121 | } | |
| 122 | ||
| 123 | public void addKeyEventHandler( | |
| 124 | final EventHandler<? super KeyEvent> handler ) { | |
| 125 | getKeyEventHandlers().add( handler ); | |
| 126 | } | |
| 127 | ||
| 128 | /** | |
| 129 | * Answers whether the {@link TreeItem}s in the {@link TreeView} are suitably | |
| 130 | * well-formed for export. A tree is considered well-formed if the following | |
| 131 | * conditions are met: | |
| 132 | * | |
| 133 | * <ul> | |
| 134 | * <li>The root node contains at least one child node having a leaf.</li> | |
| 135 | * <li>There are no leaf nodes with sibling leaf nodes.</li> | |
| 136 | * </ul> | |
| 137 | * | |
| 138 | * @return {@code null} if the document is well-formed, otherwise the | |
| 139 | * problematic child {@link TreeItem}. | |
| 140 | */ | |
| 141 | public TreeItem<String> isTreeWellFormed() { | |
| 142 | final var root = getTreeView().getRoot(); | |
| 143 | ||
| 144 | for( final var child : root.getChildren() ) { | |
| 145 | final var problemChild = isWellFormed( child ); | |
| 146 | ||
| 147 | if( child.isLeaf() || problemChild != null ) { | |
| 148 | return problemChild; | |
| 149 | } | |
| 150 | } | |
| 151 | ||
| 152 | return null; | |
| 153 | } | |
| 154 | ||
| 155 | /** | |
| 156 | * Determines whether the document is well-formed by ensuring that | |
| 157 | * child branches do not contain multiple leaves. | |
| 158 | * | |
| 159 | * @param item The sub-tree to check for well-formedness. | |
| 160 | * @return {@code null} when the tree is well-formed, otherwise the | |
| 161 | * problematic {@link TreeItem}. | |
| 162 | */ | |
| 163 | private TreeItem<String> isWellFormed( final TreeItem<String> item ) { | |
| 164 | int childLeafs = 0; | |
| 165 | int childBranches = 0; | |
| 166 | ||
| 167 | for( final TreeItem<String> child : item.getChildren() ) { | |
| 168 | if( child.isLeaf() ) { | |
| 169 | childLeafs++; | |
| 170 | } | |
| 171 | else { | |
| 172 | childBranches++; | |
| 173 | } | |
| 174 | ||
| 175 | final var problemChild = isWellFormed( child ); | |
| 176 | ||
| 177 | if( problemChild != null ) { | |
| 178 | return problemChild; | |
| 179 | } | |
| 180 | } | |
| 181 | ||
| 182 | return ((childBranches > 0 && childLeafs == 0) || | |
| 183 | (childBranches == 0 && childLeafs <= 1)) ? null : item; | |
| 184 | } | |
| 185 | ||
| 186 | /** | |
| 187 | * Returns the leaf that matches the given value. If the value is terminally | |
| 188 | * punctuated, the punctuation is removed if no match was found. | |
| 189 | * | |
| 190 | * @param value The value to find, never null. | |
| 191 | * @param findMode Defines how to match words. | |
| 192 | * @return The leaf that contains the given value, or null if neither the | |
| 193 | * original value nor the terminally-trimmed value was found. | |
| 194 | */ | |
| 195 | public VariableTreeItem<String> findLeaf( | |
| 196 | final String value, final FindMode findMode ) { | |
| 197 | final VariableTreeItem<String> root = getTreeRoot(); | |
| 198 | final VariableTreeItem<String> leaf = root.findLeaf( value, findMode ); | |
| 199 | ||
| 200 | return leaf == null | |
| 201 | ? root.findLeaf( rtrimTerminalPunctuation( value ) ) | |
| 202 | : leaf; | |
| 203 | } | |
| 204 | ||
| 205 | /** | |
| 206 | * Removes punctuation from the end of a string. | |
| 207 | * | |
| 208 | * @param s The string to trim, never null. | |
| 209 | * @return The string trimmed of all terminal characters from the end | |
| 210 | */ | |
| 211 | private String rtrimTerminalPunctuation( final String s ) { | |
| 212 | assert s != null; | |
| 213 | int index = s.length() - 1; | |
| 214 | ||
| 215 | while( index > 0 && (TERMINALS.indexOf( s.charAt( index ) ) >= 0) ) { | |
| 216 | index--; | |
| 217 | } | |
| 218 | ||
| 219 | return s.substring( 0, index ); | |
| 220 | } | |
| 221 | ||
| 222 | /** | |
| 223 | * Expands the node to the root, recursively. | |
| 224 | * | |
| 225 | * @param <T> The type of tree item to expand (usually String). | |
| 226 | * @param node The node to expand. | |
| 227 | */ | |
| 228 | public <T> void expand( final TreeItem<T> node ) { | |
| 229 | if( node != null ) { | |
| 230 | expand( node.getParent() ); | |
| 231 | ||
| 232 | if( !node.isLeaf() ) { | |
| 233 | node.setExpanded( true ); | |
| 234 | } | |
| 235 | } | |
| 236 | } | |
| 237 | ||
| 238 | public void select( final TreeItem<String> item ) { | |
| 239 | getSelectionModel().clearSelection(); | |
| 240 | getSelectionModel().select( getTreeView().getRow( item ) ); | |
| 241 | } | |
| 242 | ||
| 243 | /** | |
| 244 | * Collapses the tree, recursively. | |
| 245 | */ | |
| 246 | public void collapse() { | |
| 247 | collapse( getTreeRoot().getChildren() ); | |
| 248 | } | |
| 249 | ||
| 250 | /** | |
| 251 | * Collapses the tree, recursively. | |
| 252 | * | |
| 253 | * @param <T> The type of tree item to expand (usually String). | |
| 254 | * @param nodes The nodes to collapse. | |
| 255 | */ | |
| 256 | private <T> void collapse( final ObservableList<TreeItem<T>> nodes ) { | |
| 257 | for( final TreeItem<T> node : nodes ) { | |
| 258 | node.setExpanded( false ); | |
| 259 | collapse( node.getChildren() ); | |
| 260 | } | |
| 261 | } | |
| 262 | ||
| 263 | /** | |
| 264 | * @return {@code true} when the user is editing a {@link TreeItem}. | |
| 265 | */ | |
| 266 | private boolean isEditingTreeItem() { | |
| 267 | return getTreeView().editingItemProperty().getValue() != null; | |
| 268 | } | |
| 269 | ||
| 270 | /** | |
| 271 | * Changes to edit mode for the selected item. | |
| 272 | */ | |
| 273 | private void editSelectedItem() { | |
| 274 | getTreeView().edit( getSelectedItem() ); | |
| 275 | } | |
| 276 | ||
| 277 | /** | |
| 278 | * Removes all selected items from the {@link TreeView}. | |
| 279 | */ | |
| 280 | private void deleteSelectedItems() { | |
| 281 | for( final TreeItem<String> item : getSelectedItems() ) { | |
| 282 | final TreeItem<String> parent = item.getParent(); | |
| 283 | ||
| 284 | if( parent != null ) { | |
| 285 | parent.getChildren().remove( item ); | |
| 286 | } | |
| 287 | } | |
| 288 | } | |
| 289 | ||
| 290 | /** | |
| 291 | * Deletes the selected item. | |
| 292 | */ | |
| 293 | private void deleteSelectedItem() { | |
| 294 | final TreeItem<String> c = getSelectedItem(); | |
| 295 | getSiblings( c ).remove( c ); | |
| 296 | } | |
| 297 | ||
| 298 | /** | |
| 299 | * Adds a new item under the selected item (or root if nothing is selected). | |
| 300 | * There are a few conditions to consider: when adding to the root, | |
| 301 | * when adding to a leaf, and when adding to a non-leaf. Items added to the | |
| 302 | * root must contain two items: a key and a value. | |
| 303 | */ | |
| 304 | private void addItem() { | |
| 305 | final TreeItem<String> value = createTreeItem(); | |
| 306 | getSelectedItem().getChildren().add( value ); | |
| 307 | expand( value ); | |
| 308 | select( value ); | |
| 309 | } | |
| 310 | ||
| 311 | private ContextMenu createContextMenu() { | |
| 312 | final ContextMenu menu = new ContextMenu(); | |
| 313 | final ObservableList<MenuItem> items = menu.getItems(); | |
| 314 | ||
| 315 | addMenuItem( items, "Definition.menu.create" ) | |
| 316 | .setOnAction( e -> addItem() ); | |
| 317 | ||
| 318 | addMenuItem( items, "Definition.menu.rename" ) | |
| 319 | .setOnAction( e -> editSelectedItem() ); | |
| 320 | ||
| 321 | addMenuItem( items, "Definition.menu.remove" ) | |
| 322 | .setOnAction( e -> deleteSelectedItem() ); | |
| 323 | ||
| 324 | return menu; | |
| 325 | } | |
| 326 | ||
| 327 | /** | |
| 328 | * Executes hot-keys for edits to the definition tree. | |
| 329 | * | |
| 330 | * @param event Contains the key code of the key that was pressed. | |
| 331 | */ | |
| 332 | private void keyEventFilter( final KeyEvent event ) { | |
| 333 | if( !isEditingTreeItem() ) { | |
| 334 | switch( event.getCode() ) { | |
| 335 | case ENTER: | |
| 336 | expand( getSelectedItem() ); | |
| 337 | event.consume(); | |
| 338 | break; | |
| 339 | ||
| 340 | case DELETE: | |
| 341 | deleteSelectedItems(); | |
| 342 | break; | |
| 343 | ||
| 344 | case INSERT: | |
| 345 | addItem(); | |
| 346 | break; | |
| 347 | ||
| 348 | case R: | |
| 349 | if( event.isControlDown() ) { | |
| 350 | editSelectedItem(); | |
| 351 | } | |
| 352 | ||
| 353 | break; | |
| 354 | } | |
| 355 | ||
| 356 | for( final var handler : getKeyEventHandlers() ) { | |
| 357 | handler.handle( event ); | |
| 358 | } | |
| 359 | } | |
| 360 | } | |
| 361 | ||
| 362 | /** | |
| 363 | * Adds a menu item to a list of menu items. | |
| 364 | * | |
| 365 | * @param items The list of menu items to append to. | |
| 366 | * @param labelKey The resource bundle key name for the menu item's label. | |
| 367 | * @return The menu item added to the list of menu items. | |
| 368 | */ | |
| 369 | private MenuItem addMenuItem( | |
| 370 | final List<MenuItem> items, final String labelKey ) { | |
| 371 | final MenuItem menuItem = createMenuItem( labelKey ); | |
| 372 | items.add( menuItem ); | |
| 373 | return menuItem; | |
| 374 | } | |
| 375 | ||
| 376 | private MenuItem createMenuItem( final String labelKey ) { | |
| 377 | return new MenuItem( get( labelKey ) ); | |
| 378 | } | |
| 379 | ||
| 380 | private VariableTreeItem<String> createTreeItem() { | |
| 381 | return new VariableTreeItem<>( get( "Definition.menu.add.default" ) ); | |
| 382 | } | |
| 383 | ||
| 384 | private TreeCell<String> createTreeCell() { | |
| 385 | return new TextFieldTreeCell<>( | |
| 386 | createStringConverter() ) { | |
| 387 | @Override | |
| 388 | public void commitEdit( final String newValue ) { | |
| 389 | super.commitEdit( newValue ); | |
| 390 | select( getTreeItem() ); | |
| 391 | requestFocus(); | |
| 392 | } | |
| 393 | }; | |
| 394 | } | |
| 395 | ||
| 396 | @Override | |
| 397 | public void requestFocus() { | |
| 398 | super.requestFocus(); | |
| 399 | getTreeView().requestFocus(); | |
| 400 | } | |
| 401 | ||
| 402 | private StringConverter<String> createStringConverter() { | |
| 403 | return new StringConverter<>() { | |
| 404 | @Override | |
| 405 | public String toString( final String object ) { | |
| 406 | return object == null ? "" : object; | |
| 407 | } | |
| 408 | ||
| 409 | @Override | |
| 410 | public String fromString( final String string ) { | |
| 411 | return string == null ? "" : string; | |
| 412 | } | |
| 413 | }; | |
| 414 | } | |
| 415 | ||
| 416 | /** | |
| 417 | * Returns the tree view that contains the definition hierarchy. | |
| 418 | * | |
| 419 | * @return A non-null instance. | |
| 420 | */ | |
| 421 | public TreeView<String> getTreeView() { | |
| 422 | return mTreeView; | |
| 423 | } | |
| 424 | ||
| 425 | /** | |
| 426 | * Returns the root node to the tree view. | |
| 427 | * | |
| 428 | * @return getTreeView() | |
| 429 | */ | |
| 430 | public Node getNode() { | |
| 431 | return getTreeView(); | |
| 432 | } | |
| 433 | ||
| 434 | /** | |
| 435 | * Returns the root of the tree. | |
| 436 | * | |
| 437 | * @return The first node added to the definition tree. | |
| 438 | */ | |
| 439 | private VariableTreeItem<String> getTreeRoot() { | |
| 440 | final TreeItem<String> root = getTreeView().getRoot(); | |
| 441 | ||
| 442 | return root instanceof VariableTreeItem ? | |
| 443 | (VariableTreeItem<String>) root : new VariableTreeItem<>( "root" ); | |
| 444 | } | |
| 445 | ||
| 446 | private ObservableList<TreeItem<String>> getSiblings( | |
| 447 | final TreeItem<String> item ) { | |
| 448 | final TreeItem<String> root = getTreeView().getRoot(); | |
| 449 | final TreeItem<String> parent = | |
| 450 | (item == null || item == root) ? root : item.getParent(); | |
| 451 | ||
| 452 | return parent.getChildren(); | |
| 453 | } | |
| 454 | ||
| 455 | private MultipleSelectionModel<TreeItem<String>> getSelectionModel() { | |
| 456 | return getTreeView().getSelectionModel(); | |
| 457 | } | |
| 458 | ||
| 459 | /** | |
| 460 | * Returns a copy of all the selected items. | |
| 461 | * | |
| 462 | * @return A list, possibly empty, containing all selected items in the | |
| 463 | * {@link TreeView}. | |
| 464 | */ | |
| 465 | private List<TreeItem<String>> getSelectedItems() { | |
| 466 | return new LinkedList<>( getSelectionModel().getSelectedItems() ); | |
| 467 | } | |
| 468 | ||
| 469 | public TreeItem<String> getSelectedItem() { | |
| 470 | final TreeItem<String> item = getSelectionModel().getSelectedItem(); | |
| 471 | return item == null ? getTreeView().getRoot() : item; | |
| 472 | } | |
| 473 | ||
| 474 | private Set<EventHandler<? super KeyEvent>> getKeyEventHandlers() { | |
| 475 | return mKeyEventHandlers; | |
| 454 | 476 | } |
| 455 | 477 | } |
| 33 | 33 | */ |
| 34 | 34 | public enum FindMode { |
| 35 | EXACT, | |
| 35 | 36 | CONTAINS, |
| 36 | 37 | STARTS_WITH, |
| 28 | 28 | package com.scrivenvar.definition; |
| 29 | 29 | |
| 30 | import com.scrivenvar.decorators.YamlVariableDecorator; | |
| 31 | ||
| 32 | 30 | import java.util.Map; |
| 33 | 31 | import java.util.regex.Matcher; |
| 34 | ||
| 35 | import static com.scrivenvar.decorators.YamlVariableDecorator.REGEX_PATTERN; | |
| 32 | import java.util.regex.Pattern; | |
| 36 | 33 | |
| 37 | 34 | /** |
| 38 | 35 | * Responsible for performing string interpolation on key/value pairs stored |
| 39 | 36 | * in a map. The values in the map can use a delimited syntax to refer to |
| 40 | 37 | * keys in the map. |
| 41 | 38 | * |
| 42 | 39 | * @author White Magic Software, Ltd. |
| 43 | 40 | */ |
| 44 | 41 | public class MapInterpolator { |
| 42 | ||
| 43 | /** | |
| 44 | * Matches variables delimited by dollar symbols. | |
| 45 | */ | |
| 46 | private final static String REGEX = "(\\$.*?\\$)"; | |
| 47 | ||
| 48 | /** | |
| 49 | * Compiled regular expression for matching delimited references. | |
| 50 | */ | |
| 51 | private final static Pattern REGEX_PATTERN = Pattern.compile( REGEX ); | |
| 52 | ||
| 45 | 53 | private final static int GROUP_DELIMITED = 1; |
| 46 | 54 | |
| ... | ||
| 54 | 62 | * Performs string interpolation on the values in the given map. This will |
| 55 | 63 | * change any value in the map that contains a variable that matches |
| 56 | * {@link YamlVariableDecorator#REGEX_PATTERN}. | |
| 64 | * {@link #REGEX_PATTERN}. | |
| 57 | 65 | * |
| 58 | 66 | * @param map Contains values that represent references to keys. |
| ... | ||
| 77 | 85 | while( matcher.find() ) { |
| 78 | 86 | final String keyName = matcher.group( GROUP_DELIMITED ); |
| 79 | ||
| 80 | 87 | final String keyValue = resolve( |
| 81 | 88 | map, map.getOrDefault( keyName, keyName ) |
| 63 | 63 | public class TreeItemAdapter { |
| 64 | 64 | /** |
| 65 | * Separates YAML variable nodes (e.g., the dots in {@code $root.node.var$]). | |
| 65 | * Separates YAML variable nodes (e.g., the dots in {@code $root.node.var$}). | |
| 66 | 66 | */ |
| 67 | 67 | public static final String SEPARATOR = "."; |
| 33 | 33 | import java.util.Stack; |
| 34 | 34 | |
| 35 | import static com.scrivenvar.definition.FindMode.CONTAINS; | |
| 36 | import static com.scrivenvar.definition.FindMode.STARTS_WITH; | |
| 35 | import static com.scrivenvar.definition.FindMode.*; | |
| 37 | 36 | import static java.text.Normalizer.Form.NFD; |
| 38 | 37 | |
| ... | ||
| 87 | 86 | node = stack.pop(); |
| 88 | 87 | |
| 89 | if( findMode == CONTAINS && node.valueContains( text ) ) { | |
| 88 | if( findMode == EXACT && node.valueEquals( text ) ) { | |
| 89 | found = true; | |
| 90 | } | |
| 91 | else if( findMode == CONTAINS && node.valueContains( text ) ) { | |
| 90 | 92 | found = true; |
| 91 | 93 | } |
| ... | ||
| 137 | 139 | private boolean valueContains( final String s ) { |
| 138 | 140 | return isLeaf() && getDiacriticlessValue().contains( s ); |
| 141 | } | |
| 142 | ||
| 143 | /** | |
| 144 | * Returns true if this node is a leaf and its value equals the given text. | |
| 145 | * | |
| 146 | * @param s The text to compare against the node value. | |
| 147 | * @return true Node is a leaf and its value equals the given value. | |
| 148 | */ | |
| 149 | private boolean valueEquals( final String s ) { | |
| 150 | return isLeaf() && getValue().equals( s ); | |
| 139 | 151 | } |
| 140 | 152 | |
| 90 | 90 | |
| 91 | 91 | /** |
| 92 | * Inserts the variable | |
| 93 | */ | |
| 94 | public void injectSelectedItem() { | |
| 95 | final TreeItem<String> item = getDefinitionPane().getSelectedItem(); | |
| 96 | ||
| 97 | if( item.isLeaf() ) { | |
| 98 | // This avoids a direct typecast. | |
| 99 | final VariableTreeItem<String> leaf = getDefinitionPane().findLeaf( | |
| 100 | item.getValue(), FindMode.EXACT ); | |
| 101 | final StyledTextArea<?, ?> editor = getEditor(); | |
| 102 | ||
| 103 | editor.insertText( editor.getCaretPosition(), decorate( leaf ) ); | |
| 104 | } | |
| 105 | } | |
| 106 | ||
| 107 | /** | |
| 92 | 108 | * Traps Control+SPACE to auto-insert definition key names. |
| 93 | 109 | */ |
| ... | ||
| 112 | 128 | |
| 113 | 129 | if( leaf != null ) { |
| 114 | replaceText( | |
| 115 | boundaries[ 0 ], | |
| 116 | boundaries[ 1 ], | |
| 117 | decorate( leaf.toPath() ) | |
| 118 | ); | |
| 119 | ||
| 130 | replaceText( boundaries[ 0 ], boundaries[ 1 ], decorate( leaf ) ); | |
| 120 | 131 | expand( leaf ); |
| 121 | 132 | } |
| ... | ||
| 142 | 153 | |
| 143 | 154 | /** |
| 144 | * Injects a variable using the syntax specific to the type of document | |
| 155 | * Decorates a {@link TreeItem} using the syntax specific to the type of | |
| 156 | * document being edited. | |
| 157 | * | |
| 158 | * @param leaf The path to the leaf (the definition key) to be decorated. | |
| 159 | */ | |
| 160 | private String decorate( final VariableTreeItem<String> leaf ) { | |
| 161 | return decorate( leaf.toPath() ); | |
| 162 | } | |
| 163 | ||
| 164 | /** | |
| 165 | * Decorates a variable using the syntax specific to the type of document | |
| 145 | 166 | * being edited. |
| 146 | 167 | * |
| 147 | 168 | * @param variable The variable to decorate in dot-notation without any |
| 148 | * start or end tokens present. | |
| 169 | * start or end sigils present. | |
| 149 | 170 | */ |
| 150 | 171 | private String decorate( final String variable ) { |
| ... | ||
| 196 | 217 | assert word != null; |
| 197 | 218 | |
| 198 | VariableTreeItem<String> leaf; | |
| 219 | VariableTreeItem<String> leaf = findLeafExact( word ); | |
| 199 | 220 | |
| 200 | leaf = findLeafStartsWith( word ); | |
| 221 | leaf = leaf == null ? findLeafStartsWith( word ) : leaf; | |
| 201 | 222 | leaf = leaf == null ? findLeafContains( word ) : leaf; |
| 202 | 223 | leaf = leaf == null ? findLeafLevenshtein( word ) : leaf; |
| 203 | 224 | |
| 204 | 225 | return leaf; |
| 226 | } | |
| 227 | ||
| 228 | private VariableTreeItem<String> findLeafExact( final String text ) { | |
| 229 | return findLeaf( text, EXACT ); | |
| 205 | 230 | } |
| 206 | 231 | |
| 1 | app.properties | |
| 1 | 2 |
| 42 | 42 | Main.menu.insert.header_3=Header 3 |
| 43 | 43 | Main.menu.insert.header_3.prompt=header 3 |
| 44 | Main.menu.insert.header_4=Header 4 | |
| 45 | Main.menu.insert.header_4.prompt=header 4 | |
| 46 | Main.menu.insert.header_5=Header 5 | |
| 47 | Main.menu.insert.header_5.prompt=header 5 | |
| 48 | Main.menu.insert.header_6=Header 6 | |
| 49 | Main.menu.insert.header_6.prompt=header 6 | |
| 50 | 44 | Main.menu.insert.unordered_list=Unordered List |
| 51 | 45 | Main.menu.insert.ordered_list=Ordered List |