| 1 | 1 | # Change Log |
| 2 | 2 | |
| 3 | ## 0.7 | |
| 4 | ||
| 5 | - Load YAML variables from files | |
| 6 | - Added cursor to the preview pane | |
| 7 | - Reconfigured constants to use settings | |
| 8 | - Organized MainWindow code by similar method calls | |
| 9 | - Added single entry point for refreshing file editor tab | |
| 10 | ||
| 3 | 11 | ## 0.6 |
| 4 | 12 | |
| 5 | - Bug fixes synchronized scrolling | |
| 13 | - Revised synchronized scrolling with preview panel | |
| 6 | 14 | - Added universal character encoding detection |
| 7 | 15 | - Removed options panel |
| ... | ||
| 14 | 22 | - Added `Ctrl+Space` hot key for quick variable injection |
| 15 | 23 | - Replaced commonmark-java with flexmark |
| 16 | - Added generic CARETPOSITION into document to scroll preview pane | |
| 24 | - Insert `CARETPOSITION` into document for preview pane scroll position reference | |
| 17 | 25 | |
| 18 | 26 | ## 0.4 |
| ... | ||
| 30 | 38 | |
| 31 | 39 | ## 0.2 |
| 40 | ||
| 32 | 41 | - RichTextFX (and dependencies) updated to version 0.6.10 (fixes bugs) |
| 33 | 42 | - pegdown Markdown parser updated to version 1.6 |
| 34 | 43 | - Added five new pegdown 1.6 extension flags to Markdown Options tab |
| 35 | 44 | - Minor improvements |
| 36 | 45 | |
| 37 | 46 | ## 0.1 |
| 38 | 47 | |
| 39 | 48 | - Initial release |
| 40 | ||
| 9 | 9 | * Vladimir Schneider: [flexmark](https://website.com) |
| 10 | 10 | * Jens Deters: [FontAwesomeFX](https://bitbucket.org/Jerady/fontawesomefx) |
| 11 | * Apache Tika Team: [Apache Tika](https://tika.apache.org/) | |
| 11 | * Shy Shalom, Kohei Taketa: [juniversalchardet](https://github.com/takscape/juniversalchardet) | |
| 12 | 12 |
| 1 | /* | |
| 2 | * Copyright 2016 White Magic Software, Ltd. | |
| 3 | * | |
| 4 | * All rights reserved. | |
| 5 | * | |
| 6 | * Redistribution and use in source and binary forms, with or without | |
| 7 | * modification, are permitted provided that the following conditions are met: | |
| 8 | * | |
| 9 | * o Redistributions of source code must retain the above copyright | |
| 10 | * notice, this list of conditions and the following disclaimer. | |
| 11 | * | |
| 12 | * o Redistributions in binary form must reproduce the above copyright | |
| 13 | * notice, this list of conditions and the following disclaimer in the | |
| 14 | * documentation and/or other materials provided with the distribution. | |
| 15 | * | |
| 16 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | |
| 17 | * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | |
| 18 | * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | |
| 19 | * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT | |
| 20 | * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | |
| 21 | * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT | |
| 22 | * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | |
| 23 | * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | |
| 24 | * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | |
| 25 | * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | |
| 26 | * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | |
| 27 | */ | |
| 28 | package com.scrivenvar; | |
| 29 | ||
| 30 | import com.scrivenvar.Services; | |
| 31 | import com.scrivenvar.service.Options; | |
| 32 | import java.util.prefs.Preferences; | |
| 33 | import org.tbee.javafx.scene.layout.fxml.MigPane; | |
| 34 | ||
| 35 | /** | |
| 36 | * Provides options to all subclasses. | |
| 37 | * | |
| 38 | * @author White Magic Software, Ltd. | |
| 39 | */ | |
| 40 | public abstract class AbstractPane extends MigPane { | |
| 41 | ||
| 42 | private final Options options = Services.load( Options.class ); | |
| 43 | ||
| 44 | protected Options getOptions() { | |
| 45 | return this.options; | |
| 46 | } | |
| 47 | ||
| 48 | protected Preferences getState() { | |
| 49 | return getOptions().getState(); | |
| 50 | } | |
| 51 | } | |
| 1 | 52 |
| 28 | 28 | package com.scrivenvar; |
| 29 | 29 | |
| 30 | import com.scrivenvar.service.Settings; | |
| 31 | ||
| 30 | 32 | /** |
| 31 | 33 | * @author White Magic Software, Ltd. |
| 32 | 34 | */ |
| 33 | 35 | public class Constants { |
| 36 | ||
| 37 | private static final Settings SETTINGS = Services.load( Settings.class ); | |
| 34 | 38 | |
| 35 | 39 | /** |
| 36 | 40 | * Prevent instantiation. |
| 37 | 41 | */ |
| 38 | 42 | private Constants() { |
| 39 | 43 | } |
| 40 | ||
| 41 | public static final String BUNDLE_NAME = "com.scrivenvar.messages"; | |
| 44 | ||
| 45 | private static String get( final String key ) { | |
| 46 | return SETTINGS.getSetting( key, "" ); | |
| 47 | } | |
| 48 | ||
| 49 | // Bootstrapping... | |
| 42 | 50 | public static final String SETTINGS_NAME = "/com/scrivenvar/settings.properties"; |
| 43 | 51 | |
| 44 | public static final String STYLESHEET_PREVIEW = "com/scrivenvar/scene.css"; | |
| 45 | public static final String STYLESHEET_EDITOR = "com/scrivenvar/editor/Markdown.css"; | |
| 52 | public static final String APP_BUNDLE_NAME = get( "application.messages" ); | |
| 46 | 53 | |
| 47 | public static final String LOGO_32 = "com/scrivenvar/logo32.png"; | |
| 48 | public static final String LOGO_16 = "com/scrivenvar/logo16.png"; | |
| 49 | public static final String LOGO_128 = "com/scrivenvar/logo128.png"; | |
| 50 | public static final String LOGO_256 = "com/scrivenvar/logo256.png"; | |
| 51 | public static final String LOGO_512 = "com/scrivenvar/logo512.png"; | |
| 52 | ||
| 53 | /** | |
| 54 | * Separates YAML variable nodes (e.g., the dots in <code>$root.node.var$</code>). | |
| 55 | */ | |
| 56 | public static final String SEPARATOR = "."; | |
| 57 | ||
| 58 | public static final String CARET_POSITION = "CARETPOSITION"; | |
| 59 | public static final String MD_CARET_POSITION = "${" + CARET_POSITION + "}"; | |
| 60 | public static final String XML_CARET_POSITION = "<![CDATA[" + MD_CARET_POSITION + "]]>"; | |
| 54 | public static final String STYLESHEET_SCENE = get( "file.stylesheet.scene" ); | |
| 55 | public static final String STYLESHEET_MARKDOWN = get( "file.stylesheet.markdown" ); | |
| 56 | public static final String STYLESHEET_PREVIEW = get( "file.stylesheet.preview" ); | |
| 57 | ||
| 58 | public static final String FILE_LOGO_16 = get( "file.logo.16" ); | |
| 59 | public static final String FILE_LOGO_32 = get( "file.logo.32" ); | |
| 60 | public static final String FILE_LOGO_128 = get( "file.logo.128" ); | |
| 61 | public static final String FILE_LOGO_256 = get( "file.logo.256" ); | |
| 62 | public static final String FILE_LOGO_512 = get( "file.logo.512" ); | |
| 63 | ||
| 64 | public static final String CARET_POSITION_BASE = get( "caret.token.base" ); | |
| 65 | public static final String CARET_POSITION_MD = get( "caret.token.markdown" ); | |
| 66 | public static final String CARET_POSITION_XML = get( "caret.token.xml" ); | |
| 67 | public static final String CARET_POSITION_HTML = get( "caret.token.html" ); | |
| 68 | ||
| 69 | public static final String PREFS_ROOT = get( "preferences.root" ); | |
| 70 | public static final String PREFS_ROOT_STATE = get( "preferences.root.state" ); | |
| 71 | public static final String PREFS_ROOT_OPTIONS = get( "preferences.root.options" ); | |
| 61 | 72 | } |
| 62 | 73 |
| 26 | 26 | package com.scrivenvar; |
| 27 | 27 | |
| 28 | import com.scrivenvar.editor.EditorPane; | |
| 29 | import com.scrivenvar.editor.MarkdownEditorPane; | |
| 30 | import com.scrivenvar.service.Options; | |
| 31 | import com.scrivenvar.service.events.AlertMessage; | |
| 32 | import com.scrivenvar.service.events.AlertService; | |
| 33 | import java.nio.charset.Charset; | |
| 34 | import java.nio.file.Files; | |
| 35 | import java.nio.file.Path; | |
| 36 | import static java.util.Locale.ENGLISH; | |
| 37 | import java.util.function.Consumer; | |
| 38 | import javafx.application.Platform; | |
| 39 | import javafx.beans.binding.Bindings; | |
| 40 | import javafx.beans.property.BooleanProperty; | |
| 41 | import javafx.beans.property.ReadOnlyBooleanProperty; | |
| 42 | import javafx.beans.property.ReadOnlyBooleanWrapper; | |
| 43 | import javafx.beans.property.SimpleBooleanProperty; | |
| 44 | import javafx.beans.value.ChangeListener; | |
| 45 | import javafx.event.Event; | |
| 46 | import javafx.scene.Node; | |
| 47 | import javafx.scene.control.Tab; | |
| 48 | import javafx.scene.control.Tooltip; | |
| 49 | import javafx.scene.input.InputEvent; | |
| 50 | import javafx.scene.text.Text; | |
| 51 | import org.fxmisc.undo.UndoManager; | |
| 52 | import org.fxmisc.wellbehaved.event.EventPattern; | |
| 53 | import org.fxmisc.wellbehaved.event.InputMap; | |
| 54 | import org.mozilla.universalchardet.UniversalDetector; | |
| 55 | ||
| 56 | /** | |
| 57 | * Editor for a single file. | |
| 58 | * | |
| 59 | * @author Karl Tauber and White Magic Software, Ltd. | |
| 60 | */ | |
| 61 | public final class FileEditorTab extends Tab { | |
| 62 | ||
| 63 | private final Options options = Services.load( Options.class ); | |
| 64 | private final AlertService alertService = Services.load( AlertService.class ); | |
| 65 | ||
| 66 | private EditorPane editorPane; | |
| 67 | ||
| 68 | /** | |
| 69 | * Character encoding used by the file (or default encoding if none found). | |
| 70 | */ | |
| 71 | private Charset encoding; | |
| 72 | ||
| 73 | private final ReadOnlyBooleanWrapper modified = new ReadOnlyBooleanWrapper(); | |
| 74 | private final BooleanProperty canUndo = new SimpleBooleanProperty(); | |
| 75 | private final BooleanProperty canRedo = new SimpleBooleanProperty(); | |
| 76 | private Path path; | |
| 77 | ||
| 78 | FileEditorTab( final Path path ) { | |
| 79 | setPath( path ); | |
| 80 | setUserData( this ); | |
| 81 | ||
| 82 | this.modified.addListener( (observable, oldPath, newPath) -> updateTab() ); | |
| 83 | updateTab(); | |
| 84 | ||
| 85 | setOnSelectionChanged( e -> { | |
| 86 | if( isSelected() ) { | |
| 87 | Platform.runLater( () -> activated() ); | |
| 88 | } | |
| 89 | } ); | |
| 90 | } | |
| 91 | ||
| 92 | private void updateTab() { | |
| 93 | setText( getTabTitle() ); | |
| 94 | setGraphic( getModifiedMark() ); | |
| 95 | setTooltip( getTabTooltip() ); | |
| 96 | } | |
| 97 | ||
| 98 | /** | |
| 99 | * Returns the base filename (without the directory names). | |
| 100 | * | |
| 101 | * @return The untitled text if the path hasn't been set. | |
| 102 | */ | |
| 103 | private String getTabTitle() { | |
| 104 | final Path filePath = getPath(); | |
| 105 | ||
| 106 | return (filePath == null) | |
| 107 | ? Messages.get( "FileEditor.untitled" ) | |
| 108 | : filePath.getFileName().toString(); | |
| 109 | } | |
| 110 | ||
| 111 | /** | |
| 112 | * Returns the full filename represented by the path. | |
| 113 | * | |
| 114 | * @return The untitled text if the path hasn't been set. | |
| 115 | */ | |
| 116 | private Tooltip getTabTooltip() { | |
| 117 | final Path filePath = getPath(); | |
| 118 | ||
| 119 | return (filePath == null) | |
| 120 | ? null | |
| 121 | : new Tooltip( filePath.toString() ); | |
| 122 | } | |
| 123 | ||
| 124 | /** | |
| 125 | * Returns a marker to indicate whether the file has been modified. | |
| 126 | * | |
| 127 | * @return "*" when the file has changed; otherwise null. | |
| 128 | */ | |
| 129 | private Text getModifiedMark() { | |
| 130 | return isModified() ? new Text( "*" ) : null; | |
| 131 | } | |
| 132 | ||
| 133 | /** | |
| 134 | * Called when the user switches tab. | |
| 135 | */ | |
| 136 | private void activated() { | |
| 137 | // Tab is closed or no longer active. | |
| 138 | if( getTabPane() == null || !isSelected() ) { | |
| 139 | return; | |
| 140 | } | |
| 141 | ||
| 142 | // Switch to the tab without loading if the contents are already in memory. | |
| 143 | if( getContent() != null ) { | |
| 144 | getEditorPane().requestFocus(); | |
| 145 | return; | |
| 146 | } | |
| 147 | ||
| 148 | // Load the text and update the preview before the undo manager. | |
| 149 | load(); | |
| 150 | ||
| 151 | // Track undo requests (*must* be called after load). | |
| 152 | initUndoManager(); | |
| 153 | initLayout(); | |
| 154 | initFocus(); | |
| 155 | } | |
| 156 | ||
| 157 | private void initLayout() { | |
| 158 | setContent( getScrollPane() ); | |
| 159 | } | |
| 160 | ||
| 161 | private Node getScrollPane() { | |
| 162 | return getEditorPane().getScrollPane(); | |
| 163 | } | |
| 164 | ||
| 165 | private void initFocus() { | |
| 166 | getEditorPane().requestFocus(); | |
| 167 | } | |
| 168 | ||
| 169 | private void initUndoManager() { | |
| 170 | final UndoManager undoManager = getUndoManager(); | |
| 171 | ||
| 172 | // Clear undo history after first load. | |
| 173 | undoManager.forgetHistory(); | |
| 174 | ||
| 175 | // Bind the editor undo manager to the properties. | |
| 176 | modified.bind( Bindings.not( undoManager.atMarkedPositionProperty() ) ); | |
| 177 | canUndo.bind( undoManager.undoAvailableProperty() ); | |
| 178 | canRedo.bind( undoManager.redoAvailableProperty() ); | |
| 179 | } | |
| 180 | ||
| 181 | /** | |
| 182 | * Returns the index into the text where the caret blinks happily away. | |
| 183 | * | |
| 184 | * @return A number from 0 to the editor's document text length. | |
| 185 | */ | |
| 186 | public int getCaretPosition() { | |
| 187 | return getEditorPane().getEditor().getCaretPosition(); | |
| 188 | } | |
| 189 | ||
| 190 | /** | |
| 191 | * Returns true if the given path exactly matches this tab's path. | |
| 192 | * | |
| 193 | * @param check The path to compare against. | |
| 194 | * | |
| 195 | * @return true The paths are the same. | |
| 196 | */ | |
| 197 | public boolean isPath( final Path check ) { | |
| 198 | final Path filePath = getPath(); | |
| 199 | ||
| 200 | return filePath == null ? false : filePath.equals( check ); | |
| 201 | } | |
| 202 | ||
| 203 | /** | |
| 204 | * Reads the entire file contents from the path associated with this tab. | |
| 205 | */ | |
| 206 | private void load() { | |
| 207 | final Path filePath = getPath(); | |
| 208 | ||
| 209 | if( filePath != null ) { | |
| 210 | try { | |
| 211 | getEditorPane().setText( asString( Files.readAllBytes( filePath ) ) ); | |
| 212 | } catch( Exception ex ) { | |
| 213 | alert( | |
| 214 | "FileEditor.loadFailed.title", "FileEditor.loadFailed.message", ex | |
| 215 | ); | |
| 216 | } | |
| 217 | } | |
| 218 | } | |
| 219 | ||
| 220 | /** | |
| 221 | * Saves the entire file contents from the path associated with this tab. | |
| 222 | * | |
| 223 | * @return true The file has been saved. | |
| 224 | */ | |
| 225 | public boolean save() { | |
| 226 | try { | |
| 227 | Files.write( getPath(), asBytes( getEditorPane().getText() ) ); | |
| 228 | getEditorPane().getUndoManager().mark(); | |
| 229 | return true; | |
| 230 | } catch( Exception ex ) { | |
| 231 | return alert( | |
| 232 | "FileEditor.saveFailed.title", "FileEditor.saveFailed.message", ex | |
| 233 | ); | |
| 234 | } | |
| 235 | } | |
| 236 | ||
| 237 | /** | |
| 238 | * Creates an alert dialog and waits for it to close. | |
| 239 | * | |
| 240 | * @param titleKey Resource bundle key for the alert dialog title. | |
| 241 | * @param messageKey Resource bundle key for the alert dialog message. | |
| 242 | * @param e The unexpected happening. | |
| 243 | * | |
| 244 | * @return false | |
| 245 | */ | |
| 246 | private boolean alert( | |
| 247 | final String titleKey, final String messageKey, final Exception e ) { | |
| 248 | final AlertService service = getAlertService(); | |
| 249 | ||
| 250 | final AlertMessage message = service.createAlertMessage( | |
| 251 | Messages.get( titleKey ), | |
| 252 | Messages.get( messageKey ), | |
| 253 | getPath(), | |
| 254 | e.getMessage() | |
| 255 | ); | |
| 256 | ||
| 257 | service.createAlertError( message ).showAndWait(); | |
| 258 | return false; | |
| 259 | } | |
| 260 | ||
| 261 | /** | |
| 262 | * Returns a best guess at the file encoding. If the encoding could not be | |
| 263 | * detected, this will return the default charset for the JVM. | |
| 264 | * | |
| 265 | * @param bytes The bytes to perform character encoding detection. | |
| 266 | * | |
| 267 | * @return The character encoding. | |
| 268 | */ | |
| 269 | private Charset detectEncoding( final byte[] bytes ) { | |
| 270 | final UniversalDetector detector = new UniversalDetector( null ); | |
| 271 | detector.handleData( bytes, 0, bytes.length ); | |
| 272 | detector.dataEnd(); | |
| 273 | ||
| 274 | final String charset = detector.getDetectedCharset(); | |
| 275 | final Charset charEncoding = charset == null | |
| 276 | ? Charset.defaultCharset() | |
| 277 | : Charset.forName( charset.toUpperCase( ENGLISH ) ); | |
| 278 | ||
| 279 | detector.reset(); | |
| 280 | ||
| 281 | return charEncoding; | |
| 282 | } | |
| 283 | ||
| 284 | /** | |
| 285 | * Converts the given string to an array of bytes using the encoding that was | |
| 286 | * originally detected (if any) and associated with this file. | |
| 287 | * | |
| 288 | * @param text The text to convert into the original file encoding. | |
| 289 | * | |
| 290 | * @return A series of bytes ready for writing to a file. | |
| 291 | */ | |
| 292 | private byte[] asBytes( final String text ) { | |
| 293 | return text.getBytes( getEncoding() ); | |
| 294 | } | |
| 295 | ||
| 296 | /** | |
| 297 | * Converts the given bytes into a Java String. This will call setEncoding | |
| 298 | * with the encoding detected by the CharsetDetector. | |
| 299 | * | |
| 300 | * @param text The text of unknown character encoding. | |
| 301 | * | |
| 302 | * @return The text, in its auto-detected encoding, as a String. | |
| 303 | */ | |
| 304 | private String asString( final byte[] text ) { | |
| 305 | setEncoding( detectEncoding( text ) ); | |
| 306 | return new String( text, getEncoding() ); | |
| 307 | } | |
| 308 | ||
| 309 | Path getPath() { | |
| 310 | return this.path; | |
| 311 | } | |
| 312 | ||
| 313 | void setPath( final Path path ) { | |
| 314 | this.path = path; | |
| 315 | } | |
| 316 | ||
| 317 | public boolean isModified() { | |
| 318 | return this.modified.get(); | |
| 319 | } | |
| 320 | ||
| 321 | ReadOnlyBooleanProperty modifiedProperty() { | |
| 322 | return this.modified.getReadOnlyProperty(); | |
| 323 | } | |
| 324 | ||
| 325 | BooleanProperty canUndoProperty() { | |
| 326 | return this.canUndo; | |
| 327 | } | |
| 328 | ||
| 329 | BooleanProperty canRedoProperty() { | |
| 330 | return this.canRedo; | |
| 331 | } | |
| 332 | ||
| 333 | private UndoManager getUndoManager() { | |
| 334 | return getEditorPane().getUndoManager(); | |
| 335 | } | |
| 336 | ||
| 337 | /** | |
| 338 | * Forwards the request to the editor pane. | |
| 339 | * | |
| 340 | * @param <T> The type of event listener to add. | |
| 341 | * @param <U> The type of consumer to add. | |
| 342 | * @param event The event that should trigger updates to the listener. | |
| 343 | * @param consumer The listener to receive update events. | |
| 344 | */ | |
| 345 | public <T extends Event, U extends T> void addEventListener( | |
| 346 | final EventPattern<? super T, ? extends U> event, | |
| 347 | final Consumer<? super U> consumer ) { | |
| 348 | getEditorPane().addEventListener( event, consumer ); | |
| 349 | } | |
| 350 | ||
| 351 | /** | |
| 352 | * Forwards to the editor pane's listeners for keyboard events. | |
| 353 | * | |
| 354 | * @param map The new input map to replace the existing keyboard listener. | |
| 355 | */ | |
| 356 | public void addEventListener( final InputMap<InputEvent> map ) { | |
| 357 | getEditorPane().addEventListener( map ); | |
| 358 | } | |
| 359 | ||
| 360 | /** | |
| 361 | * Forwards to the editor pane's listeners for keyboard events. | |
| 362 | * | |
| 363 | * @param map The existing input map to remove from the keyboard listeners. | |
| 364 | */ | |
| 365 | public void removeEventListener( final InputMap<InputEvent> map ) { | |
| 366 | getEditorPane().removeEventListener( map ); | |
| 367 | } | |
| 368 | ||
| 369 | /** | |
| 370 | * Forwards to the editor pane's listeners for text change events. | |
| 371 | * | |
| 372 | * @param listener The listener to notify when the text changes. | |
| 373 | */ | |
| 374 | public void addTextChangeListener( final ChangeListener<String> listener ) { | |
| 375 | getEditorPane().addTextChangeListener( listener ); | |
| 376 | } | |
| 377 | ||
| 378 | /** | |
| 379 | * Forwards to the editor pane's listeners for paragraph change events. | |
| 380 | * | |
| 381 | * @param listener The listener to notify when the caret changes paragraphs. | |
| 382 | */ | |
| 383 | public void addCaretParagraphListener( final ChangeListener<Integer> listener){ | |
| 384 | getEditorPane().addCaretParagraphListener( listener ); | |
| 385 | } | |
| 386 | ||
| 387 | /** | |
| 388 | * Delegates the request to the editor pane. | |
| 389 | * | |
| 390 | * @return The text to process. | |
| 391 | */ | |
| 392 | public String getEditorText() { | |
| 393 | return getEditorPane().getText(); | |
| 394 | } | |
| 395 | ||
| 396 | /** | |
| 397 | * Returns the editor pane, or creates one if it doesn't yet exist. | |
| 398 | * | |
| 399 | * @return The editor pane, never null. | |
| 400 | */ | |
| 401 | protected EditorPane getEditorPane() { | |
| 402 | if( this.editorPane == null ) { | |
| 403 | this.editorPane = new MarkdownEditorPane(); | |
| 404 | } | |
| 405 | ||
| 406 | return this.editorPane; | |
| 407 | } | |
| 408 | ||
| 409 | private AlertService getAlertService() { | |
| 410 | return this.alertService; | |
| 411 | } | |
| 412 | ||
| 413 | private Options getOptions() { | |
| 414 | return this.options; | |
| 415 | } | |
| 416 | ||
| 417 | private Charset getEncoding() { | |
| 418 | return this.encoding; | |
| 419 | } | |
| 420 | ||
| 421 | private void setEncoding( final Charset encoding ) { | |
| 422 | this.encoding = encoding; | |
| 28 | import com.scrivenvar.editors.EditorPane; | |
| 29 | import com.scrivenvar.editors.markdown.MarkdownEditorPane; | |
| 30 | import com.scrivenvar.service.Options; | |
| 31 | import com.scrivenvar.service.events.AlertMessage; | |
| 32 | import com.scrivenvar.service.events.AlertService; | |
| 33 | import java.nio.charset.Charset; | |
| 34 | import java.nio.file.Files; | |
| 35 | import java.nio.file.Path; | |
| 36 | import static java.util.Locale.ENGLISH; | |
| 37 | import java.util.function.Consumer; | |
| 38 | import javafx.application.Platform; | |
| 39 | import javafx.beans.binding.Bindings; | |
| 40 | import javafx.beans.property.BooleanProperty; | |
| 41 | import javafx.beans.property.ReadOnlyBooleanProperty; | |
| 42 | import javafx.beans.property.ReadOnlyBooleanWrapper; | |
| 43 | import javafx.beans.property.SimpleBooleanProperty; | |
| 44 | import javafx.beans.value.ChangeListener; | |
| 45 | import javafx.event.Event; | |
| 46 | import javafx.scene.Node; | |
| 47 | import javafx.scene.control.Tab; | |
| 48 | import javafx.scene.control.Tooltip; | |
| 49 | import javafx.scene.input.InputEvent; | |
| 50 | import javafx.scene.text.Text; | |
| 51 | import org.fxmisc.undo.UndoManager; | |
| 52 | import org.fxmisc.wellbehaved.event.EventPattern; | |
| 53 | import org.fxmisc.wellbehaved.event.InputMap; | |
| 54 | import org.mozilla.universalchardet.UniversalDetector; | |
| 55 | ||
| 56 | /** | |
| 57 | * Editor for a single file. | |
| 58 | * | |
| 59 | * @author Karl Tauber and White Magic Software, Ltd. | |
| 60 | */ | |
| 61 | public final class FileEditorTab extends Tab { | |
| 62 | ||
| 63 | private final Options options = Services.load( Options.class ); | |
| 64 | private final AlertService alertService = Services.load( AlertService.class ); | |
| 65 | ||
| 66 | private EditorPane editorPane; | |
| 67 | ||
| 68 | /** | |
| 69 | * Character encoding used by the file (or default encoding if none found). | |
| 70 | */ | |
| 71 | private Charset encoding; | |
| 72 | ||
| 73 | private final ReadOnlyBooleanWrapper modified = new ReadOnlyBooleanWrapper(); | |
| 74 | private final BooleanProperty canUndo = new SimpleBooleanProperty(); | |
| 75 | private final BooleanProperty canRedo = new SimpleBooleanProperty(); | |
| 76 | private Path path; | |
| 77 | ||
| 78 | FileEditorTab( final Path path ) { | |
| 79 | setPath( path ); | |
| 80 | ||
| 81 | this.modified.addListener( (observable, oldPath, newPath) -> updateTab() ); | |
| 82 | updateTab(); | |
| 83 | ||
| 84 | setOnSelectionChanged( e -> { | |
| 85 | if( isSelected() ) { | |
| 86 | Platform.runLater( () -> activated() ); | |
| 87 | } | |
| 88 | } ); | |
| 89 | } | |
| 90 | ||
| 91 | private void updateTab() { | |
| 92 | setText( getTabTitle() ); | |
| 93 | setGraphic( getModifiedMark() ); | |
| 94 | setTooltip( getTabTooltip() ); | |
| 95 | } | |
| 96 | ||
| 97 | /** | |
| 98 | * Returns the base filename (without the directory names). | |
| 99 | * | |
| 100 | * @return The untitled text if the path hasn't been set. | |
| 101 | */ | |
| 102 | private String getTabTitle() { | |
| 103 | final Path filePath = getPath(); | |
| 104 | ||
| 105 | return (filePath == null) | |
| 106 | ? Messages.get( "FileEditor.untitled" ) | |
| 107 | : filePath.getFileName().toString(); | |
| 108 | } | |
| 109 | ||
| 110 | /** | |
| 111 | * Returns the full filename represented by the path. | |
| 112 | * | |
| 113 | * @return The untitled text if the path hasn't been set. | |
| 114 | */ | |
| 115 | private Tooltip getTabTooltip() { | |
| 116 | final Path filePath = getPath(); | |
| 117 | ||
| 118 | return (filePath == null) | |
| 119 | ? null | |
| 120 | : new Tooltip( 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 | ||
| 171 | // Clear undo history after first load. | |
| 172 | undoManager.forgetHistory(); | |
| 173 | ||
| 174 | // Bind the editor undo manager to the properties. | |
| 175 | modified.bind( Bindings.not( undoManager.atMarkedPositionProperty() ) ); | |
| 176 | canUndo.bind( undoManager.undoAvailableProperty() ); | |
| 177 | canRedo.bind( undoManager.redoAvailableProperty() ); | |
| 178 | } | |
| 179 | ||
| 180 | /** | |
| 181 | * Returns the index into the text where the caret blinks happily away. | |
| 182 | * | |
| 183 | * @return A number from 0 to the editor's document text length. | |
| 184 | */ | |
| 185 | public int getCaretPosition() { | |
| 186 | return getEditorPane().getEditor().getCaretPosition(); | |
| 187 | } | |
| 188 | ||
| 189 | /** | |
| 190 | * Returns true if the given path exactly matches this tab's path. | |
| 191 | * | |
| 192 | * @param check The path to compare against. | |
| 193 | * | |
| 194 | * @return true The paths are the same. | |
| 195 | */ | |
| 196 | public boolean isPath( final Path check ) { | |
| 197 | final Path filePath = getPath(); | |
| 198 | ||
| 199 | return filePath == null ? false : filePath.equals( check ); | |
| 200 | } | |
| 201 | ||
| 202 | /** | |
| 203 | * Reads the entire file contents from the path associated with this tab. | |
| 204 | */ | |
| 205 | private void load() { | |
| 206 | final Path filePath = getPath(); | |
| 207 | ||
| 208 | if( filePath != null ) { | |
| 209 | try { | |
| 210 | getEditorPane().setText( asString( Files.readAllBytes( filePath ) ) ); | |
| 211 | } catch( Exception ex ) { | |
| 212 | alert( | |
| 213 | "FileEditor.loadFailed.title", "FileEditor.loadFailed.message", ex | |
| 214 | ); | |
| 215 | } | |
| 216 | } | |
| 217 | } | |
| 218 | ||
| 219 | /** | |
| 220 | * Saves the entire file contents from the path associated with this tab. | |
| 221 | * | |
| 222 | * @return true The file has been saved. | |
| 223 | */ | |
| 224 | public boolean save() { | |
| 225 | try { | |
| 226 | Files.write( getPath(), asBytes( getEditorPane().getText() ) ); | |
| 227 | getEditorPane().getUndoManager().mark(); | |
| 228 | return true; | |
| 229 | } catch( Exception ex ) { | |
| 230 | return alert( | |
| 231 | "FileEditor.saveFailed.title", "FileEditor.saveFailed.message", ex | |
| 232 | ); | |
| 233 | } | |
| 234 | } | |
| 235 | ||
| 236 | /** | |
| 237 | * Creates an alert dialog and waits for it to close. | |
| 238 | * | |
| 239 | * @param titleKey Resource bundle key for the alert dialog title. | |
| 240 | * @param messageKey Resource bundle key for the alert dialog message. | |
| 241 | * @param e The unexpected happening. | |
| 242 | * | |
| 243 | * @return false | |
| 244 | */ | |
| 245 | private boolean alert( | |
| 246 | final String titleKey, final String messageKey, final Exception e ) { | |
| 247 | final AlertService service = getAlertService(); | |
| 248 | ||
| 249 | final AlertMessage message = service.createAlertMessage( | |
| 250 | Messages.get( titleKey ), | |
| 251 | Messages.get( messageKey ), | |
| 252 | getPath(), | |
| 253 | e.getMessage() | |
| 254 | ); | |
| 255 | ||
| 256 | service.createAlertError( message ).showAndWait(); | |
| 257 | return false; | |
| 258 | } | |
| 259 | ||
| 260 | /** | |
| 261 | * Returns a best guess at the file encoding. If the encoding could not be | |
| 262 | * detected, this will return the default charset for the JVM. | |
| 263 | * | |
| 264 | * @param bytes The bytes to perform character encoding detection. | |
| 265 | * | |
| 266 | * @return The character encoding. | |
| 267 | */ | |
| 268 | private Charset detectEncoding( final byte[] bytes ) { | |
| 269 | final UniversalDetector detector = new UniversalDetector( null ); | |
| 270 | detector.handleData( bytes, 0, bytes.length ); | |
| 271 | detector.dataEnd(); | |
| 272 | ||
| 273 | final String charset = detector.getDetectedCharset(); | |
| 274 | final Charset charEncoding = charset == null | |
| 275 | ? Charset.defaultCharset() | |
| 276 | : Charset.forName( charset.toUpperCase( ENGLISH ) ); | |
| 277 | ||
| 278 | detector.reset(); | |
| 279 | ||
| 280 | return charEncoding; | |
| 281 | } | |
| 282 | ||
| 283 | /** | |
| 284 | * Converts the given string to an array of bytes using the encoding that was | |
| 285 | * originally detected (if any) and associated with this file. | |
| 286 | * | |
| 287 | * @param text The text to convert into the original file encoding. | |
| 288 | * | |
| 289 | * @return A series of bytes ready for writing to a file. | |
| 290 | */ | |
| 291 | private byte[] asBytes( final String text ) { | |
| 292 | return text.getBytes( getEncoding() ); | |
| 293 | } | |
| 294 | ||
| 295 | /** | |
| 296 | * Converts the given bytes into a Java String. This will call setEncoding | |
| 297 | * with the encoding detected by the CharsetDetector. | |
| 298 | * | |
| 299 | * @param text The text of unknown character encoding. | |
| 300 | * | |
| 301 | * @return The text, in its auto-detected encoding, as a String. | |
| 302 | */ | |
| 303 | private String asString( final byte[] text ) { | |
| 304 | setEncoding( detectEncoding( text ) ); | |
| 305 | return new String( text, getEncoding() ); | |
| 306 | } | |
| 307 | ||
| 308 | Path getPath() { | |
| 309 | return this.path; | |
| 310 | } | |
| 311 | ||
| 312 | void setPath( final Path path ) { | |
| 313 | this.path = path; | |
| 314 | } | |
| 315 | ||
| 316 | public boolean isModified() { | |
| 317 | return this.modified.get(); | |
| 318 | } | |
| 319 | ||
| 320 | ReadOnlyBooleanProperty modifiedProperty() { | |
| 321 | return this.modified.getReadOnlyProperty(); | |
| 322 | } | |
| 323 | ||
| 324 | BooleanProperty canUndoProperty() { | |
| 325 | return this.canUndo; | |
| 326 | } | |
| 327 | ||
| 328 | BooleanProperty canRedoProperty() { | |
| 329 | return this.canRedo; | |
| 330 | } | |
| 331 | ||
| 332 | private UndoManager getUndoManager() { | |
| 333 | return getEditorPane().getUndoManager(); | |
| 334 | } | |
| 335 | ||
| 336 | /** | |
| 337 | * Forwards the request to the editor pane. | |
| 338 | * | |
| 339 | * @param <T> The type of event listener to add. | |
| 340 | * @param <U> The type of consumer to add. | |
| 341 | * @param event The event that should trigger updates to the listener. | |
| 342 | * @param consumer The listener to receive update events. | |
| 343 | */ | |
| 344 | public <T extends Event, U extends T> void addEventListener( | |
| 345 | final EventPattern<? super T, ? extends U> event, | |
| 346 | final Consumer<? super U> consumer ) { | |
| 347 | getEditorPane().addEventListener( event, consumer ); | |
| 348 | } | |
| 349 | ||
| 350 | /** | |
| 351 | * Forwards to the editor pane's listeners for keyboard events. | |
| 352 | * | |
| 353 | * @param map The new input map to replace the existing keyboard listener. | |
| 354 | */ | |
| 355 | public void addEventListener( final InputMap<InputEvent> map ) { | |
| 356 | getEditorPane().addEventListener( map ); | |
| 357 | } | |
| 358 | ||
| 359 | /** | |
| 360 | * Forwards to the editor pane's listeners for keyboard events. | |
| 361 | * | |
| 362 | * @param map The existing input map to remove from the keyboard listeners. | |
| 363 | */ | |
| 364 | public void removeEventListener( final InputMap<InputEvent> map ) { | |
| 365 | getEditorPane().removeEventListener( map ); | |
| 366 | } | |
| 367 | ||
| 368 | /** | |
| 369 | * Forwards to the editor pane's listeners for text change events. | |
| 370 | * | |
| 371 | * @param listener The listener to notify when the text changes. | |
| 372 | */ | |
| 373 | public void addTextChangeListener( final ChangeListener<String> listener ) { | |
| 374 | getEditorPane().addTextChangeListener( listener ); | |
| 375 | } | |
| 376 | ||
| 377 | /** | |
| 378 | * Forwards to the editor pane's listeners for caret paragraph change events. | |
| 379 | * | |
| 380 | * @param listener The listener to notify when the caret changes paragraphs. | |
| 381 | */ | |
| 382 | public void addCaretParagraphListener( final ChangeListener<Integer> listener ) { | |
| 383 | getEditorPane().addCaretParagraphListener( listener ); | |
| 384 | } | |
| 385 | ||
| 386 | /** | |
| 387 | * Forwards the request to the editor pane. | |
| 388 | * | |
| 389 | * @return The text to process. | |
| 390 | */ | |
| 391 | public String getEditorText() { | |
| 392 | return getEditorPane().getText(); | |
| 393 | } | |
| 394 | ||
| 395 | /** | |
| 396 | * Returns the editor pane, or creates one if it doesn't yet exist. | |
| 397 | * | |
| 398 | * @return The editor pane, never null. | |
| 399 | */ | |
| 400 | protected EditorPane getEditorPane() { | |
| 401 | if( this.editorPane == null ) { | |
| 402 | this.editorPane = new MarkdownEditorPane(); | |
| 403 | } | |
| 404 | ||
| 405 | return this.editorPane; | |
| 406 | } | |
| 407 | ||
| 408 | private AlertService getAlertService() { | |
| 409 | return this.alertService; | |
| 410 | } | |
| 411 | ||
| 412 | private Options getOptions() { | |
| 413 | return this.options; | |
| 414 | } | |
| 415 | ||
| 416 | private Charset getEncoding() { | |
| 417 | return this.encoding; | |
| 418 | } | |
| 419 | ||
| 420 | private void setEncoding( final Charset encoding ) { | |
| 421 | this.encoding = encoding; | |
| 422 | } | |
| 423 | ||
| 424 | /** | |
| 425 | * Returns the tab title, without any modified indicators. | |
| 426 | * | |
| 427 | * @return The tab title. | |
| 428 | */ | |
| 429 | @Override | |
| 430 | public String toString() { | |
| 431 | return getTabTitle(); | |
| 423 | 432 | } |
| 424 | 433 | } |
| 28 | 28 | package com.scrivenvar; |
| 29 | 29 | |
| 30 | import static com.scrivenvar.Messages.get; | |
| 31 | import com.scrivenvar.predicates.files.FileTypePredicate; | |
| 32 | import com.scrivenvar.service.Options; | |
| 33 | import com.scrivenvar.service.Settings; | |
| 34 | import com.scrivenvar.service.events.AlertMessage; | |
| 35 | import com.scrivenvar.service.events.AlertService; | |
| 36 | import static com.scrivenvar.service.events.AlertService.NO; | |
| 37 | import static com.scrivenvar.service.events.AlertService.YES; | |
| 38 | import com.scrivenvar.util.Utils; | |
| 39 | import java.io.File; | |
| 40 | import java.nio.file.Path; | |
| 41 | import java.util.ArrayList; | |
| 42 | import java.util.List; | |
| 43 | import java.util.function.Consumer; | |
| 44 | import java.util.prefs.Preferences; | |
| 45 | import java.util.stream.Collectors; | |
| 46 | import javafx.beans.property.ReadOnlyBooleanProperty; | |
| 47 | import javafx.beans.property.ReadOnlyBooleanWrapper; | |
| 48 | import javafx.beans.property.ReadOnlyObjectProperty; | |
| 49 | import javafx.beans.property.ReadOnlyObjectWrapper; | |
| 50 | import javafx.beans.value.ChangeListener; | |
| 51 | import javafx.beans.value.ObservableValue; | |
| 52 | import javafx.collections.ListChangeListener; | |
| 53 | import javafx.collections.ObservableList; | |
| 54 | import javafx.event.Event; | |
| 55 | import javafx.scene.Node; | |
| 56 | import javafx.scene.control.Alert; | |
| 57 | import javafx.scene.control.ButtonType; | |
| 58 | import javafx.scene.control.Tab; | |
| 59 | import javafx.scene.control.TabPane; | |
| 60 | import javafx.scene.control.TabPane.TabClosingPolicy; | |
| 61 | import javafx.scene.input.InputEvent; | |
| 62 | import javafx.stage.FileChooser; | |
| 63 | import javafx.stage.FileChooser.ExtensionFilter; | |
| 64 | import javafx.stage.Window; | |
| 65 | import org.fxmisc.richtext.StyledTextArea; | |
| 66 | import org.fxmisc.wellbehaved.event.EventPattern; | |
| 67 | import org.fxmisc.wellbehaved.event.InputMap; | |
| 68 | ||
| 69 | /** | |
| 70 | * Tab pane for file editors. | |
| 71 | * | |
| 72 | * @author Karl Tauber and White Magic Software, Ltd. | |
| 73 | */ | |
| 74 | public final class FileEditorTabPane extends TabPane { | |
| 75 | ||
| 76 | private final static String FILTER_PREFIX = "Dialog.file.choose.filter"; | |
| 77 | ||
| 78 | private final Options options = Services.load( Options.class ); | |
| 79 | private final Settings settings = Services.load( Settings.class ); | |
| 80 | private final AlertService alertService = Services.load( AlertService.class ); | |
| 81 | ||
| 82 | private final ReadOnlyObjectWrapper<FileEditorTab> activeFileEditor = new ReadOnlyObjectWrapper<>(); | |
| 83 | private final ReadOnlyBooleanWrapper anyFileEditorModified = new ReadOnlyBooleanWrapper(); | |
| 84 | ||
| 85 | public FileEditorTabPane() { | |
| 86 | final ObservableList<Tab> tabs = getTabs(); | |
| 87 | ||
| 88 | setFocusTraversable( false ); | |
| 89 | setTabClosingPolicy( TabClosingPolicy.ALL_TABS ); | |
| 90 | ||
| 91 | addTabChangeListener( (ObservableValue<? extends Tab> tabPane, | |
| 92 | final Tab oldTab, final Tab newTab) -> { | |
| 93 | if( newTab != null ) { | |
| 94 | activeFileEditor.set( (FileEditorTab)newTab.getUserData() ); | |
| 95 | } | |
| 96 | } ); | |
| 97 | ||
| 98 | final ChangeListener<Boolean> modifiedListener = (observable, oldValue, newValue) -> { | |
| 99 | for( final Tab tab : tabs ) { | |
| 100 | if( ((FileEditorTab)tab.getUserData()).isModified() ) { | |
| 101 | this.anyFileEditorModified.set( true ); | |
| 102 | break; | |
| 103 | } | |
| 104 | } | |
| 105 | }; | |
| 106 | ||
| 107 | tabs.addListener( (ListChangeListener<Tab>)change -> { | |
| 108 | while( change.next() ) { | |
| 109 | if( change.wasAdded() ) { | |
| 110 | change.getAddedSubList().stream().forEach( (tab) -> { | |
| 111 | ((FileEditorTab)tab.getUserData()).modifiedProperty().addListener( modifiedListener ); | |
| 112 | } ); | |
| 113 | } else if( change.wasRemoved() ) { | |
| 114 | change.getRemoved().stream().forEach( (tab) -> { | |
| 115 | ((FileEditorTab)tab.getUserData()).modifiedProperty().removeListener( modifiedListener ); | |
| 116 | } ); | |
| 117 | } | |
| 118 | } | |
| 119 | ||
| 120 | // Changes in the tabs may also change anyFileEditorModified property | |
| 121 | // (e.g. closed modified file) | |
| 122 | modifiedListener.changed( null, null, null ); | |
| 123 | } ); | |
| 124 | } | |
| 125 | ||
| 126 | public <T extends Event, U extends T> void addEventListener( | |
| 127 | final EventPattern<? super T, ? extends U> event, | |
| 128 | final Consumer<? super U> consumer ) { | |
| 129 | getActiveFileEditor().addEventListener( event, consumer ); | |
| 130 | } | |
| 131 | ||
| 132 | /** | |
| 133 | * Delegates to the active file editor pane, and, ultimately, to its text | |
| 134 | * area. | |
| 135 | * | |
| 136 | * @param map The map of methods to events. | |
| 137 | */ | |
| 138 | public void addEventListener( final InputMap<InputEvent> map ) { | |
| 139 | getActiveFileEditor().addEventListener( map ); | |
| 140 | } | |
| 141 | ||
| 142 | /** | |
| 143 | * Remove a keyboard event listener from the active file editor. | |
| 144 | * | |
| 145 | * @param map The keyboard events to remove. | |
| 146 | */ | |
| 147 | public void removeEventListener( final InputMap<InputEvent> map ) { | |
| 148 | getActiveFileEditor().removeEventListener( map ); | |
| 149 | } | |
| 150 | ||
| 151 | /** | |
| 152 | * Allows observers to be notified when the current file editor tab changes. | |
| 153 | * | |
| 154 | * @param listener The listener to notify of tab change events. | |
| 155 | */ | |
| 156 | public void addTabChangeListener( final ChangeListener<Tab> listener ) { | |
| 157 | // Observe the tab so that when a new tab is opened or selected, | |
| 158 | // a notification is kicked off. | |
| 159 | getSelectionModel().selectedItemProperty().addListener( listener ); | |
| 160 | } | |
| 161 | ||
| 162 | /** | |
| 163 | * Allows clients to manipulate the editor content directly. | |
| 164 | * | |
| 165 | * @return The text area for the active file editor. | |
| 166 | */ | |
| 167 | public StyledTextArea getEditor() { | |
| 168 | return getActiveFileEditor().getEditorPane().getEditor(); | |
| 169 | } | |
| 170 | ||
| 171 | public FileEditorTab getActiveFileEditor() { | |
| 172 | return this.activeFileEditor.get(); | |
| 173 | } | |
| 174 | ||
| 175 | ReadOnlyObjectProperty<FileEditorTab> activeFileEditorProperty() { | |
| 176 | return this.activeFileEditor.getReadOnlyProperty(); | |
| 177 | } | |
| 178 | ||
| 179 | ReadOnlyBooleanProperty anyFileEditorModifiedProperty() { | |
| 180 | return this.anyFileEditorModified.getReadOnlyProperty(); | |
| 181 | } | |
| 182 | ||
| 183 | private FileEditorTab createFileEditor( final Path path ) { | |
| 184 | final FileEditorTab tab = new FileEditorTab( path ); | |
| 185 | ||
| 186 | tab.setOnCloseRequest( e -> { | |
| 187 | if( !canCloseEditor( tab ) ) { | |
| 188 | e.consume(); | |
| 189 | } | |
| 190 | } ); | |
| 191 | ||
| 192 | return tab; | |
| 193 | } | |
| 194 | ||
| 195 | Node getNode() { | |
| 196 | return this; | |
| 197 | } | |
| 198 | ||
| 199 | /** | |
| 200 | * Called when the user selects New from the File menu. | |
| 201 | * | |
| 202 | * @return The newly added tab. | |
| 203 | */ | |
| 204 | FileEditorTab newEditor() { | |
| 205 | final FileEditorTab tab = createFileEditor( null ); | |
| 206 | ||
| 207 | getTabs().add( tab ); | |
| 208 | getSelectionModel().select( tab ); | |
| 209 | return tab; | |
| 210 | } | |
| 211 | ||
| 212 | List<FileEditorTab> openFileDialog() { | |
| 213 | final FileChooser dialog | |
| 214 | = createFileChooser( get( "Dialog.file.choose.open.title" ) ); | |
| 215 | final List<File> files = dialog.showOpenMultipleDialog( getWindow() ); | |
| 216 | ||
| 217 | return (files != null && !files.isEmpty()) | |
| 218 | ? openFiles( files ) | |
| 219 | : new ArrayList<>(); | |
| 220 | } | |
| 221 | ||
| 222 | /** | |
| 223 | * Opens the files into new editors, unless one of those files was a | |
| 224 | * definition file. The definition file is loaded into the definition pane, | |
| 225 | * but only the first one selected (multiple definition files will result in a | |
| 226 | * warning). | |
| 227 | * | |
| 228 | * @param files The list of non-definition files that the were requested to | |
| 229 | * open. | |
| 230 | * | |
| 231 | * @return A list of files that can be opened in text editors. | |
| 232 | */ | |
| 233 | private List<FileEditorTab> openFiles( final List<File> files ) { | |
| 234 | final List<FileEditorTab> openedEditors = new ArrayList<>(); | |
| 235 | ||
| 236 | final FileTypePredicate predicate | |
| 237 | = new FileTypePredicate( createExtensionFilter( "definition" ).getExtensions() ); | |
| 238 | ||
| 239 | // The user might have opened muliple definitions files. These will | |
| 240 | // be discarded from the text editable files. | |
| 241 | final List<File> definitions | |
| 242 | = files.stream().filter( predicate ).collect( Collectors.toList() ); | |
| 243 | ||
| 244 | // Create a modifiable list to remove any definition files that were | |
| 245 | // opened. | |
| 246 | final List<File> editors = new ArrayList<>( files ); | |
| 247 | editors.removeAll( definitions ); | |
| 248 | ||
| 249 | // If there are any editor-friendly files opened (e.g,. Markdown, XML), then | |
| 250 | // open them up in new tabs. | |
| 251 | if( editors.size() > 0 ) { | |
| 252 | saveLastDirectory( editors.get( 0 ) ); | |
| 253 | openedEditors.addAll( openEditors( editors, 0 ) ); | |
| 254 | } | |
| 255 | ||
| 256 | if( definitions.size() > 0 ) { | |
| 257 | openDefinition( definitions.get( 0 ) ); | |
| 258 | } | |
| 259 | ||
| 260 | return openedEditors; | |
| 261 | } | |
| 262 | ||
| 263 | private List<FileEditorTab> openEditors( final List<File> files, final int activeIndex ) { | |
| 264 | final int fileTally = files.size(); | |
| 265 | final List<FileEditorTab> editors = new ArrayList<>( fileTally ); | |
| 266 | final List<Tab> tabs = getTabs(); | |
| 267 | ||
| 268 | // Close single unmodified "Untitled" tab. | |
| 269 | if( tabs.size() == 1 ) { | |
| 270 | final FileEditorTab fileEditor = (FileEditorTab)(tabs.get( 0 ).getUserData()); | |
| 271 | ||
| 272 | if( fileEditor.getPath() == null && !fileEditor.isModified() ) { | |
| 273 | closeEditor( fileEditor, false ); | |
| 274 | } | |
| 275 | } | |
| 276 | ||
| 277 | for( int i = 0; i < fileTally; i++ ) { | |
| 278 | final Path path = files.get( i ).toPath(); | |
| 279 | ||
| 280 | // Check whether file is already opened. | |
| 281 | FileEditorTab fileEditor = findEditor( path ); | |
| 282 | ||
| 283 | if( fileEditor == null ) { | |
| 284 | fileEditor = createFileEditor( path ); | |
| 285 | getTabs().add( fileEditor ); | |
| 286 | editors.add( fileEditor ); | |
| 287 | } | |
| 288 | ||
| 289 | // Select first file. | |
| 290 | if( i == activeIndex ) { | |
| 291 | getSelectionModel().select( fileEditor ); | |
| 292 | } | |
| 293 | } | |
| 294 | ||
| 295 | return editors; | |
| 296 | } | |
| 297 | ||
| 298 | /** | |
| 299 | * Called when the user has opened a definition file (using the file open | |
| 300 | * dialog box). This will replace the current set of definitions for the | |
| 301 | * active tab. | |
| 302 | * | |
| 303 | * @param definition The file to open. | |
| 304 | */ | |
| 305 | private void openDefinition( final File definition ) { | |
| 306 | System.out.println( "open definition file: " + definition.toString() ); | |
| 307 | } | |
| 308 | ||
| 309 | boolean saveEditor( final FileEditorTab fileEditor ) { | |
| 310 | if( fileEditor == null || !fileEditor.isModified() ) { | |
| 311 | return true; | |
| 312 | } | |
| 313 | ||
| 314 | if( fileEditor.getPath() == null ) { | |
| 315 | getSelectionModel().select( fileEditor ); | |
| 316 | ||
| 317 | final FileChooser fileChooser = createFileChooser( Messages.get( "Dialog.file.choose.save.title" ) ); | |
| 318 | final File file = fileChooser.showSaveDialog( getWindow() ); | |
| 319 | if( file == null ) { | |
| 320 | return false; | |
| 321 | } | |
| 322 | ||
| 323 | saveLastDirectory( file ); | |
| 324 | fileEditor.setPath( file.toPath() ); | |
| 325 | } | |
| 326 | ||
| 327 | return fileEditor.save(); | |
| 328 | } | |
| 329 | ||
| 330 | boolean saveAllEditors() { | |
| 331 | boolean success = true; | |
| 332 | ||
| 333 | for( FileEditorTab fileEditor : getAllEditors() ) { | |
| 334 | if( !saveEditor( fileEditor ) ) { | |
| 335 | success = false; | |
| 336 | } | |
| 337 | } | |
| 338 | ||
| 339 | return success; | |
| 340 | } | |
| 341 | ||
| 342 | boolean canCloseEditor( final FileEditorTab tab ) { | |
| 343 | if( !tab.isModified() ) { | |
| 344 | return true; | |
| 345 | } | |
| 346 | ||
| 347 | final AlertMessage message = getAlertService().createAlertMessage( | |
| 348 | Messages.get( "Alert.file.close.title" ), | |
| 349 | Messages.get( "Alert.file.close.text" ), | |
| 350 | tab.getText() | |
| 351 | ); | |
| 352 | ||
| 353 | final Alert alert = getAlertService().createAlertConfirmation( message ); | |
| 354 | final ButtonType response = alert.showAndWait().get(); | |
| 355 | ||
| 356 | return response == YES ? saveEditor( tab ) : response == NO; | |
| 357 | } | |
| 358 | ||
| 359 | private AlertService getAlertService() { | |
| 360 | return this.alertService; | |
| 361 | } | |
| 362 | ||
| 363 | boolean closeEditor( FileEditorTab fileEditor, boolean save ) { | |
| 364 | if( fileEditor == null ) { | |
| 365 | return true; | |
| 366 | } | |
| 367 | ||
| 368 | final Tab tab = fileEditor; | |
| 369 | ||
| 370 | if( save ) { | |
| 371 | Event event = new Event( tab, tab, Tab.TAB_CLOSE_REQUEST_EVENT ); | |
| 372 | Event.fireEvent( tab, event ); | |
| 373 | ||
| 374 | if( event.isConsumed() ) { | |
| 375 | return false; | |
| 376 | } | |
| 377 | } | |
| 378 | ||
| 379 | getTabs().remove( tab ); | |
| 380 | ||
| 381 | if( tab.getOnClosed() != null ) { | |
| 382 | Event.fireEvent( tab, new Event( Tab.CLOSED_EVENT ) ); | |
| 383 | } | |
| 384 | ||
| 385 | return true; | |
| 386 | } | |
| 387 | ||
| 388 | boolean closeAllEditors() { | |
| 389 | final FileEditorTab[] allEditors = getAllEditors(); | |
| 390 | final FileEditorTab activeEditor = getActiveFileEditor(); | |
| 391 | ||
| 392 | // try to save active tab first because in case the user decides to cancel, | |
| 393 | // then it stays active | |
| 394 | if( activeEditor != null && !canCloseEditor( activeEditor ) ) { | |
| 395 | return false; | |
| 396 | } | |
| 397 | ||
| 398 | // This should be called any time a tab changes. | |
| 399 | persistPreferences(); | |
| 400 | ||
| 401 | // save modified tabs | |
| 402 | for( int i = 0; i < allEditors.length; i++ ) { | |
| 403 | final FileEditorTab fileEditor = allEditors[ i ]; | |
| 404 | ||
| 405 | if( fileEditor == activeEditor ) { | |
| 406 | continue; | |
| 407 | } | |
| 408 | ||
| 409 | if( fileEditor.isModified() ) { | |
| 410 | // activate the modified tab to make its modified content visible to the user | |
| 411 | getSelectionModel().select( i ); | |
| 412 | ||
| 413 | if( !canCloseEditor( fileEditor ) ) { | |
| 414 | return false; | |
| 415 | } | |
| 416 | } | |
| 417 | } | |
| 418 | ||
| 419 | // Close all tabs. | |
| 420 | for( final FileEditorTab fileEditor : allEditors ) { | |
| 421 | if( !closeEditor( fileEditor, false ) ) { | |
| 422 | return false; | |
| 423 | } | |
| 424 | } | |
| 425 | ||
| 426 | return getTabs().isEmpty(); | |
| 427 | } | |
| 428 | ||
| 429 | private FileEditorTab[] getAllEditors() { | |
| 430 | final ObservableList<Tab> tabs = getTabs(); | |
| 431 | final int length = tabs.size(); | |
| 432 | final FileEditorTab[] allEditors = new FileEditorTab[ length ]; | |
| 433 | ||
| 434 | for( int i = 0; i < length; i++ ) { | |
| 435 | allEditors[ i ] = (FileEditorTab)tabs.get( i ).getUserData(); | |
| 436 | } | |
| 437 | ||
| 438 | return allEditors; | |
| 439 | } | |
| 440 | ||
| 441 | /** | |
| 442 | * Returns the file editor tab that has the given path. | |
| 443 | * | |
| 444 | * @return null No file editor tab for the given path was found. | |
| 445 | */ | |
| 446 | private FileEditorTab findEditor( final Path path ) { | |
| 447 | for( final Tab tab : getTabs() ) { | |
| 448 | final FileEditorTab fileEditor = (FileEditorTab)tab; | |
| 449 | ||
| 450 | if( fileEditor.isPath( path ) ) { | |
| 451 | return fileEditor; | |
| 452 | } | |
| 453 | } | |
| 454 | ||
| 455 | return null; | |
| 456 | } | |
| 457 | ||
| 458 | private FileChooser createFileChooser( String title ) { | |
| 459 | final FileChooser fileChooser = new FileChooser(); | |
| 460 | ||
| 461 | fileChooser.setTitle( title ); | |
| 462 | fileChooser.getExtensionFilters().addAll( | |
| 463 | createExtensionFilters() ); | |
| 464 | ||
| 465 | final String lastDirectory = getState().get( "lastDirectory", null ); | |
| 466 | File file = new File( (lastDirectory != null) ? lastDirectory : "." ); | |
| 467 | ||
| 468 | if( !file.isDirectory() ) { | |
| 469 | file = new File( "." ); | |
| 470 | } | |
| 471 | ||
| 472 | fileChooser.setInitialDirectory( file ); | |
| 473 | return fileChooser; | |
| 474 | } | |
| 475 | ||
| 476 | private List<ExtensionFilter> createExtensionFilters() { | |
| 477 | final List<ExtensionFilter> list = new ArrayList<>(); | |
| 478 | ||
| 479 | // TODO: Return a list of all properties that match the filter prefix. | |
| 480 | // This will allow dynamic filters to be added and removed just by | |
| 481 | // updating the properties file. | |
| 482 | list.add( createExtensionFilter( "markdown" ) ); | |
| 483 | list.add( createExtensionFilter( "definition" ) ); | |
| 484 | list.add( createExtensionFilter( "xml" ) ); | |
| 485 | list.add( createExtensionFilter( "all" ) ); | |
| 486 | return list; | |
| 487 | } | |
| 488 | ||
| 489 | private ExtensionFilter createExtensionFilter( final String filetype ) { | |
| 490 | final String tKey = String.format( "%s.title.%s", FILTER_PREFIX, filetype ); | |
| 491 | final String eKey = String.format( "%s.ext.%s", FILTER_PREFIX, filetype ); | |
| 492 | ||
| 493 | return new ExtensionFilter( Messages.get( tKey ), getExtensions( eKey ) ); | |
| 494 | } | |
| 495 | ||
| 496 | private List<String> getExtensions( final String key ) { | |
| 497 | return getStringSettingList( key ); | |
| 498 | } | |
| 499 | ||
| 500 | private List<String> getStringSettingList( String key ) { | |
| 501 | return getStringSettingList( key, null ); | |
| 502 | } | |
| 503 | ||
| 504 | private List<String> getStringSettingList( String key, List<String> values ) { | |
| 505 | return getSettings().getStringSettingList( key, values ); | |
| 506 | } | |
| 507 | ||
| 508 | private void saveLastDirectory( final File file ) { | |
| 509 | getState().put( "lastDirectory", file.getParent() ); | |
| 510 | } | |
| 511 | ||
| 512 | public void restorePreferences() { | |
| 513 | int activeIndex = 0; | |
| 514 | ||
| 515 | final Preferences preferences = getState(); | |
| 516 | final String[] fileNames = Utils.getPrefsStrings( preferences, "file" ); | |
| 517 | final String activeFileName = preferences.get( "activeFile", null ); | |
| 518 | ||
| 519 | final ArrayList<File> files = new ArrayList<>( fileNames.length ); | |
| 520 | ||
| 521 | for( final String fileName : fileNames ) { | |
| 522 | final File file = new File( fileName ); | |
| 523 | ||
| 524 | if( file.exists() ) { | |
| 525 | files.add( file ); | |
| 526 | ||
| 527 | if( fileName.equals( activeFileName ) ) { | |
| 528 | activeIndex = files.size() - 1; | |
| 529 | } | |
| 530 | } | |
| 531 | } | |
| 532 | ||
| 533 | if( files.isEmpty() ) { | |
| 534 | newEditor(); | |
| 535 | return; | |
| 536 | } | |
| 537 | ||
| 538 | openEditors( files, activeIndex ); | |
| 539 | } | |
| 540 | ||
| 541 | public void persistPreferences() { | |
| 542 | final ObservableList<Tab> allEditors = getTabs(); | |
| 543 | final List<String> fileNames = new ArrayList<>( allEditors.size() ); | |
| 544 | ||
| 545 | for( final Tab tab : allEditors ) { | |
| 546 | final FileEditorTab fileEditor = (FileEditorTab)tab; | |
| 547 | ||
| 548 | if( fileEditor.getPath() != null ) { | |
| 549 | fileNames.add( fileEditor.getPath().toString() ); | |
| 550 | } | |
| 551 | } | |
| 552 | ||
| 553 | final Preferences preferences = getState(); | |
| 554 | Utils.putPrefsStrings( preferences, "file", fileNames.toArray( new String[ fileNames.size() ] ) ); | |
| 555 | ||
| 556 | final FileEditorTab activeEditor = getActiveFileEditor(); | |
| 557 | ||
| 558 | if( activeEditor != null && activeEditor.getPath() != null ) { | |
| 559 | preferences.put( "activeFile", activeEditor.getPath().toString() ); | |
| 560 | } else { | |
| 561 | preferences.remove( "activeFile" ); | |
| 562 | } | |
| 563 | } | |
| 564 | ||
| 565 | private Settings getSettings() { | |
| 566 | return this.settings; | |
| 567 | } | |
| 568 | ||
| 569 | protected Options getOptions() { | |
| 570 | return this.options; | |
| 571 | } | |
| 572 | ||
| 573 | private Window getWindow() { | |
| 574 | return getScene().getWindow(); | |
| 575 | } | |
| 576 | ||
| 577 | protected Preferences getState() { | |
| 578 | return getOptions().getState(); | |
| 30 | import com.scrivenvar.predicates.files.FileTypePredicate; | |
| 31 | import com.scrivenvar.service.Options; | |
| 32 | import com.scrivenvar.service.Settings; | |
| 33 | import com.scrivenvar.service.events.AlertMessage; | |
| 34 | import com.scrivenvar.service.events.AlertService; | |
| 35 | import static com.scrivenvar.service.events.AlertService.NO; | |
| 36 | import static com.scrivenvar.service.events.AlertService.YES; | |
| 37 | import com.scrivenvar.util.Utils; | |
| 38 | import java.io.File; | |
| 39 | import java.nio.file.Path; | |
| 40 | import java.util.ArrayList; | |
| 41 | import java.util.List; | |
| 42 | import java.util.function.Consumer; | |
| 43 | import java.util.prefs.Preferences; | |
| 44 | import java.util.stream.Collectors; | |
| 45 | import javafx.beans.property.ReadOnlyBooleanProperty; | |
| 46 | import javafx.beans.property.ReadOnlyBooleanWrapper; | |
| 47 | import javafx.beans.property.ReadOnlyObjectProperty; | |
| 48 | import javafx.beans.property.ReadOnlyObjectWrapper; | |
| 49 | import javafx.beans.value.ChangeListener; | |
| 50 | import javafx.beans.value.ObservableValue; | |
| 51 | import javafx.collections.ListChangeListener; | |
| 52 | import javafx.collections.ObservableList; | |
| 53 | import javafx.event.Event; | |
| 54 | import javafx.scene.Node; | |
| 55 | import javafx.scene.control.Alert; | |
| 56 | import javafx.scene.control.ButtonType; | |
| 57 | import javafx.scene.control.Tab; | |
| 58 | import javafx.scene.control.TabPane; | |
| 59 | import javafx.scene.control.TabPane.TabClosingPolicy; | |
| 60 | import javafx.scene.input.InputEvent; | |
| 61 | import javafx.stage.FileChooser; | |
| 62 | import javafx.stage.FileChooser.ExtensionFilter; | |
| 63 | import javafx.stage.Window; | |
| 64 | import org.fxmisc.richtext.StyledTextArea; | |
| 65 | import org.fxmisc.wellbehaved.event.EventPattern; | |
| 66 | import org.fxmisc.wellbehaved.event.InputMap; | |
| 67 | import static com.scrivenvar.Messages.get; | |
| 68 | ||
| 69 | /** | |
| 70 | * Tab pane for file editors. | |
| 71 | * | |
| 72 | * @author Karl Tauber and White Magic Software, Ltd. | |
| 73 | */ | |
| 74 | public final class FileEditorTabPane extends TabPane { | |
| 75 | ||
| 76 | private final static String FILTER_EXTENSIONS = "filter.file"; | |
| 77 | private final static String FILTER_EXTENSION_TITLES = "Dialog.file.choose.filter"; | |
| 78 | ||
| 79 | private final Options options = Services.load( Options.class ); | |
| 80 | private final Settings settings = Services.load( Settings.class ); | |
| 81 | private final AlertService alertService = Services.load( AlertService.class ); | |
| 82 | ||
| 83 | private final ReadOnlyObjectWrapper<Path> openDefinition = new ReadOnlyObjectWrapper<>(); | |
| 84 | private final ReadOnlyObjectWrapper<FileEditorTab> activeFileEditor = new ReadOnlyObjectWrapper<>(); | |
| 85 | private final ReadOnlyBooleanWrapper anyFileEditorModified = new ReadOnlyBooleanWrapper(); | |
| 86 | ||
| 87 | /** | |
| 88 | * Constructs a new file editor tab pane. | |
| 89 | */ | |
| 90 | public FileEditorTabPane() { | |
| 91 | final ObservableList<Tab> tabs = getTabs(); | |
| 92 | ||
| 93 | setFocusTraversable( false ); | |
| 94 | setTabClosingPolicy( TabClosingPolicy.ALL_TABS ); | |
| 95 | ||
| 96 | addTabSelectionListener( | |
| 97 | (ObservableValue<? extends Tab> tabPane, | |
| 98 | final Tab oldTab, final Tab newTab) -> { | |
| 99 | ||
| 100 | if( newTab != null ) { | |
| 101 | activeFileEditor.set( (FileEditorTab)newTab ); | |
| 102 | } | |
| 103 | } | |
| 104 | ); | |
| 105 | ||
| 106 | final ChangeListener<Boolean> modifiedListener = (observable, oldValue, newValue) -> { | |
| 107 | for( final Tab tab : tabs ) { | |
| 108 | if( ((FileEditorTab)tab).isModified() ) { | |
| 109 | this.anyFileEditorModified.set( true ); | |
| 110 | break; | |
| 111 | } | |
| 112 | } | |
| 113 | }; | |
| 114 | ||
| 115 | tabs.addListener( | |
| 116 | (ListChangeListener<Tab>)change -> { | |
| 117 | while( change.next() ) { | |
| 118 | if( change.wasAdded() ) { | |
| 119 | change.getAddedSubList().stream().forEach( (tab) -> { | |
| 120 | ((FileEditorTab)tab).modifiedProperty().addListener( modifiedListener ); | |
| 121 | } ); | |
| 122 | } else if( change.wasRemoved() ) { | |
| 123 | change.getRemoved().stream().forEach( (tab) -> { | |
| 124 | ((FileEditorTab)tab).modifiedProperty().removeListener( modifiedListener ); | |
| 125 | } ); | |
| 126 | } | |
| 127 | } | |
| 128 | ||
| 129 | // Changes in the tabs may also change anyFileEditorModified property | |
| 130 | // (e.g. closed modified file) | |
| 131 | modifiedListener.changed( null, null, null ); | |
| 132 | } | |
| 133 | ); | |
| 134 | } | |
| 135 | ||
| 136 | /** | |
| 137 | * Delegates to the active file editor. | |
| 138 | * | |
| 139 | * @param <T> Event type. | |
| 140 | * @param <U> Consumer type. | |
| 141 | * @param event Event to pass to the editor. | |
| 142 | * @param consumer Consumer to pass to the editor. | |
| 143 | */ | |
| 144 | public <T extends Event, U extends T> void addEventListener( | |
| 145 | final EventPattern<? super T, ? extends U> event, | |
| 146 | final Consumer<? super U> consumer ) { | |
| 147 | getActiveFileEditor().addEventListener( event, consumer ); | |
| 148 | } | |
| 149 | ||
| 150 | /** | |
| 151 | * Delegates to the active file editor pane, and, ultimately, to its text | |
| 152 | * area. | |
| 153 | * | |
| 154 | * @param map The map of methods to events. | |
| 155 | */ | |
| 156 | public void addEventListener( final InputMap<InputEvent> map ) { | |
| 157 | getActiveFileEditor().addEventListener( map ); | |
| 158 | } | |
| 159 | ||
| 160 | /** | |
| 161 | * Remove a keyboard event listener from the active file editor. | |
| 162 | * | |
| 163 | * @param map The keyboard events to remove. | |
| 164 | */ | |
| 165 | public void removeEventListener( final InputMap<InputEvent> map ) { | |
| 166 | getActiveFileEditor().removeEventListener( map ); | |
| 167 | } | |
| 168 | ||
| 169 | /** | |
| 170 | * Allows observers to be notified when the current file editor tab changes. | |
| 171 | * | |
| 172 | * @param listener The listener to notify of tab change events. | |
| 173 | */ | |
| 174 | public void addTabSelectionListener( final ChangeListener<Tab> listener ) { | |
| 175 | // Observe the tab so that when a new tab is opened or selected, | |
| 176 | // a notification is kicked off. | |
| 177 | getSelectionModel().selectedItemProperty().addListener( listener ); | |
| 178 | } | |
| 179 | ||
| 180 | /** | |
| 181 | * Allows clients to manipulate the editor content directly. | |
| 182 | * | |
| 183 | * @return The text area for the active file editor. | |
| 184 | */ | |
| 185 | public StyledTextArea getEditor() { | |
| 186 | return getActiveFileEditor().getEditorPane().getEditor(); | |
| 187 | } | |
| 188 | ||
| 189 | public FileEditorTab getActiveFileEditor() { | |
| 190 | return this.activeFileEditor.get(); | |
| 191 | } | |
| 192 | ||
| 193 | public ReadOnlyObjectProperty<FileEditorTab> activeFileEditorProperty() { | |
| 194 | return this.activeFileEditor.getReadOnlyProperty(); | |
| 195 | } | |
| 196 | ||
| 197 | ReadOnlyBooleanProperty anyFileEditorModifiedProperty() { | |
| 198 | return this.anyFileEditorModified.getReadOnlyProperty(); | |
| 199 | } | |
| 200 | ||
| 201 | private FileEditorTab createFileEditor( final Path path ) { | |
| 202 | final FileEditorTab tab = new FileEditorTab( path ); | |
| 203 | ||
| 204 | tab.setOnCloseRequest( e -> { | |
| 205 | if( !canCloseEditor( tab ) ) { | |
| 206 | e.consume(); | |
| 207 | } | |
| 208 | } ); | |
| 209 | ||
| 210 | return tab; | |
| 211 | } | |
| 212 | ||
| 213 | /** | |
| 214 | * Called when the user selects New from the File menu. | |
| 215 | * | |
| 216 | * @return The newly added tab. | |
| 217 | */ | |
| 218 | void newEditor() { | |
| 219 | final FileEditorTab tab = createFileEditor( null ); | |
| 220 | ||
| 221 | getTabs().add( tab ); | |
| 222 | getSelectionModel().select( tab ); | |
| 223 | } | |
| 224 | ||
| 225 | void openFileDialog() { | |
| 226 | final String title = get( "Dialog.file.choose.open.title" ); | |
| 227 | final FileChooser dialog = createFileChooser( title ); | |
| 228 | openFiles( dialog.showOpenMultipleDialog( getWindow() ) ); | |
| 229 | } | |
| 230 | ||
| 231 | /** | |
| 232 | * Opens the files into new editors, unless one of those files was a | |
| 233 | * definition file. The definition file is loaded into the definition pane, | |
| 234 | * but only the first one selected (multiple definition files will result in a | |
| 235 | * warning). | |
| 236 | * | |
| 237 | * @param files The list of non-definition files that the were requested to | |
| 238 | * open. | |
| 239 | * | |
| 240 | * @return A list of files that can be opened in text editors. | |
| 241 | */ | |
| 242 | private void openFiles( final List<File> files ) { | |
| 243 | final FileTypePredicate predicate | |
| 244 | = new FileTypePredicate( createExtensionFilter( "definition" ).getExtensions() ); | |
| 245 | ||
| 246 | // The user might have opened multiple definitions files. These will | |
| 247 | // be discarded from the text editable files. | |
| 248 | final List<File> definitions | |
| 249 | = files.stream().filter( predicate ).collect( Collectors.toList() ); | |
| 250 | ||
| 251 | // Create a modifiable list to remove any definition files that were | |
| 252 | // opened. | |
| 253 | final List<File> editors = new ArrayList<>( files ); | |
| 254 | ||
| 255 | if( editors.size() > 0 ) { | |
| 256 | saveLastDirectory( editors.get( 0 ) ); | |
| 257 | } | |
| 258 | ||
| 259 | editors.removeAll( definitions ); | |
| 260 | ||
| 261 | // Open editor-friendly files (e.g,. Markdown, XML) in new tabs. | |
| 262 | if( editors.size() > 0 ) { | |
| 263 | openEditors( editors, 0 ); | |
| 264 | } | |
| 265 | ||
| 266 | if( definitions.size() > 0 ) { | |
| 267 | openDefinition( definitions.get( 0 ) ); | |
| 268 | } | |
| 269 | } | |
| 270 | ||
| 271 | private void openEditors( final List<File> files, final int activeIndex ) { | |
| 272 | final int fileTally = files.size(); | |
| 273 | final List<Tab> tabs = getTabs(); | |
| 274 | ||
| 275 | // Close single unmodified "Untitled" tab. | |
| 276 | if( tabs.size() == 1 ) { | |
| 277 | final FileEditorTab fileEditor = (FileEditorTab)(tabs.get( 0 )); | |
| 278 | ||
| 279 | if( fileEditor.getPath() == null && !fileEditor.isModified() ) { | |
| 280 | closeEditor( fileEditor, false ); | |
| 281 | } | |
| 282 | } | |
| 283 | ||
| 284 | for( int i = 0; i < fileTally; i++ ) { | |
| 285 | final Path path = files.get( i ).toPath(); | |
| 286 | ||
| 287 | FileEditorTab fileEditorTab = findEditor( path ); | |
| 288 | ||
| 289 | // Only open new files. | |
| 290 | if( fileEditorTab == null ) { | |
| 291 | fileEditorTab = createFileEditor( path ); | |
| 292 | getTabs().add( fileEditorTab ); | |
| 293 | } | |
| 294 | ||
| 295 | // Select the first file in the list. | |
| 296 | if( i == activeIndex ) { | |
| 297 | getSelectionModel().select( fileEditorTab ); | |
| 298 | } | |
| 299 | } | |
| 300 | } | |
| 301 | ||
| 302 | /** | |
| 303 | * Returns a property that changes when a new definition file is opened. | |
| 304 | * | |
| 305 | * @return The path to a definition file that was opened. | |
| 306 | */ | |
| 307 | public ReadOnlyObjectProperty<Path> onOpenDefinitionFileProperty() { | |
| 308 | return getOnOpenDefinitionFile().getReadOnlyProperty(); | |
| 309 | } | |
| 310 | ||
| 311 | private ReadOnlyObjectWrapper<Path> getOnOpenDefinitionFile() { | |
| 312 | return this.openDefinition; | |
| 313 | } | |
| 314 | ||
| 315 | /** | |
| 316 | * Called when the user has opened a definition file (using the file open | |
| 317 | * dialog box). This will replace the current set of definitions for the | |
| 318 | * active tab. | |
| 319 | * | |
| 320 | * @param definition The file to open. | |
| 321 | */ | |
| 322 | private void openDefinition( final File definition ) { | |
| 323 | // TODO: Prevent reading this file twice when a new text document is opened. | |
| 324 | // (might be a matter of checking the value first). | |
| 325 | getOnOpenDefinitionFile().set( definition.toPath() ); | |
| 326 | } | |
| 327 | ||
| 328 | boolean saveEditor( final FileEditorTab fileEditor ) { | |
| 329 | if( fileEditor == null || !fileEditor.isModified() ) { | |
| 330 | return true; | |
| 331 | } | |
| 332 | ||
| 333 | if( fileEditor.getPath() == null ) { | |
| 334 | getSelectionModel().select( fileEditor ); | |
| 335 | ||
| 336 | final FileChooser fileChooser = createFileChooser( Messages.get( "Dialog.file.choose.save.title" ) ); | |
| 337 | final File file = fileChooser.showSaveDialog( getWindow() ); | |
| 338 | if( file == null ) { | |
| 339 | return false; | |
| 340 | } | |
| 341 | ||
| 342 | saveLastDirectory( file ); | |
| 343 | fileEditor.setPath( file.toPath() ); | |
| 344 | } | |
| 345 | ||
| 346 | return fileEditor.save(); | |
| 347 | } | |
| 348 | ||
| 349 | boolean saveAllEditors() { | |
| 350 | boolean success = true; | |
| 351 | ||
| 352 | for( FileEditorTab fileEditor : getAllEditors() ) { | |
| 353 | if( !saveEditor( fileEditor ) ) { | |
| 354 | success = false; | |
| 355 | } | |
| 356 | } | |
| 357 | ||
| 358 | return success; | |
| 359 | } | |
| 360 | ||
| 361 | /** | |
| 362 | * Answers whether the file has had modifications. ' | |
| 363 | * | |
| 364 | * @param tab THe tab to check for modifications. | |
| 365 | * | |
| 366 | * @return false The file is unmodified. | |
| 367 | */ | |
| 368 | boolean canCloseEditor( final FileEditorTab tab ) { | |
| 369 | if( !tab.isModified() ) { | |
| 370 | return true; | |
| 371 | } | |
| 372 | ||
| 373 | final AlertMessage message = getAlertService().createAlertMessage( | |
| 374 | Messages.get( "Alert.file.close.title" ), | |
| 375 | Messages.get( "Alert.file.close.text" ), | |
| 376 | tab.getText() | |
| 377 | ); | |
| 378 | ||
| 379 | final Alert alert = getAlertService().createAlertConfirmation( message ); | |
| 380 | final ButtonType response = alert.showAndWait().get(); | |
| 381 | ||
| 382 | return response == YES ? saveEditor( tab ) : response == NO; | |
| 383 | } | |
| 384 | ||
| 385 | private AlertService getAlertService() { | |
| 386 | return this.alertService; | |
| 387 | } | |
| 388 | ||
| 389 | boolean closeEditor( FileEditorTab fileEditor, boolean save ) { | |
| 390 | if( fileEditor == null ) { | |
| 391 | return true; | |
| 392 | } | |
| 393 | ||
| 394 | final Tab tab = fileEditor; | |
| 395 | ||
| 396 | if( save ) { | |
| 397 | Event event = new Event( tab, tab, Tab.TAB_CLOSE_REQUEST_EVENT ); | |
| 398 | Event.fireEvent( tab, event ); | |
| 399 | ||
| 400 | if( event.isConsumed() ) { | |
| 401 | return false; | |
| 402 | } | |
| 403 | } | |
| 404 | ||
| 405 | getTabs().remove( tab ); | |
| 406 | ||
| 407 | if( tab.getOnClosed() != null ) { | |
| 408 | Event.fireEvent( tab, new Event( Tab.CLOSED_EVENT ) ); | |
| 409 | } | |
| 410 | ||
| 411 | return true; | |
| 412 | } | |
| 413 | ||
| 414 | boolean closeAllEditors() { | |
| 415 | final FileEditorTab[] allEditors = getAllEditors(); | |
| 416 | final FileEditorTab activeEditor = getActiveFileEditor(); | |
| 417 | ||
| 418 | // try to save active tab first because in case the user decides to cancel, | |
| 419 | // then it stays active | |
| 420 | if( activeEditor != null && !canCloseEditor( activeEditor ) ) { | |
| 421 | return false; | |
| 422 | } | |
| 423 | ||
| 424 | // This should be called any time a tab changes. | |
| 425 | persistPreferences(); | |
| 426 | ||
| 427 | // save modified tabs | |
| 428 | for( int i = 0; i < allEditors.length; i++ ) { | |
| 429 | final FileEditorTab fileEditor = allEditors[ i ]; | |
| 430 | ||
| 431 | if( fileEditor == activeEditor ) { | |
| 432 | continue; | |
| 433 | } | |
| 434 | ||
| 435 | if( fileEditor.isModified() ) { | |
| 436 | // activate the modified tab to make its modified content visible to the user | |
| 437 | getSelectionModel().select( i ); | |
| 438 | ||
| 439 | if( !canCloseEditor( fileEditor ) ) { | |
| 440 | return false; | |
| 441 | } | |
| 442 | } | |
| 443 | } | |
| 444 | ||
| 445 | // Close all tabs. | |
| 446 | for( final FileEditorTab fileEditor : allEditors ) { | |
| 447 | if( !closeEditor( fileEditor, false ) ) { | |
| 448 | return false; | |
| 449 | } | |
| 450 | } | |
| 451 | ||
| 452 | return getTabs().isEmpty(); | |
| 453 | } | |
| 454 | ||
| 455 | private FileEditorTab[] getAllEditors() { | |
| 456 | final ObservableList<Tab> tabs = getTabs(); | |
| 457 | final int length = tabs.size(); | |
| 458 | final FileEditorTab[] allEditors = new FileEditorTab[ length ]; | |
| 459 | ||
| 460 | for( int i = 0; i < length; i++ ) { | |
| 461 | allEditors[ i ] = (FileEditorTab)tabs.get( i ); | |
| 462 | } | |
| 463 | ||
| 464 | return allEditors; | |
| 465 | } | |
| 466 | ||
| 467 | /** | |
| 468 | * Returns the file editor tab that has the given path. | |
| 469 | * | |
| 470 | * @return null No file editor tab for the given path was found. | |
| 471 | */ | |
| 472 | private FileEditorTab findEditor( final Path path ) { | |
| 473 | for( final Tab tab : getTabs() ) { | |
| 474 | final FileEditorTab fileEditor = (FileEditorTab)tab; | |
| 475 | ||
| 476 | if( fileEditor.isPath( path ) ) { | |
| 477 | return fileEditor; | |
| 478 | } | |
| 479 | } | |
| 480 | ||
| 481 | return null; | |
| 482 | } | |
| 483 | ||
| 484 | private FileChooser createFileChooser( String title ) { | |
| 485 | final FileChooser fileChooser = new FileChooser(); | |
| 486 | ||
| 487 | fileChooser.setTitle( title ); | |
| 488 | fileChooser.getExtensionFilters().addAll( | |
| 489 | createExtensionFilters() ); | |
| 490 | ||
| 491 | final String lastDirectory = getState().get( "lastDirectory", null ); | |
| 492 | File file = new File( (lastDirectory != null) ? lastDirectory : "." ); | |
| 493 | ||
| 494 | if( !file.isDirectory() ) { | |
| 495 | file = new File( "." ); | |
| 496 | } | |
| 497 | ||
| 498 | fileChooser.setInitialDirectory( file ); | |
| 499 | return fileChooser; | |
| 500 | } | |
| 501 | ||
| 502 | private List<ExtensionFilter> createExtensionFilters() { | |
| 503 | final List<ExtensionFilter> list = new ArrayList<>(); | |
| 504 | ||
| 505 | // TODO: Return a list of all properties that match the filter prefix. | |
| 506 | // This will allow dynamic filters to be added and removed just by | |
| 507 | // updating the properties file. | |
| 508 | list.add( createExtensionFilter( "markdown" ) ); | |
| 509 | list.add( createExtensionFilter( "definition" ) ); | |
| 510 | list.add( createExtensionFilter( "xml" ) ); | |
| 511 | list.add( createExtensionFilter( "all" ) ); | |
| 512 | return list; | |
| 513 | } | |
| 514 | ||
| 515 | private ExtensionFilter createExtensionFilter( final String filetype ) { | |
| 516 | final String tKey = String.format( "%s.title.%s", FILTER_EXTENSION_TITLES, filetype ); | |
| 517 | final String eKey = String.format( "%s.ext.%s", FILTER_EXTENSIONS, filetype ); | |
| 518 | ||
| 519 | return new ExtensionFilter( Messages.get( tKey ), getExtensions( eKey ) ); | |
| 520 | } | |
| 521 | ||
| 522 | private List<String> getExtensions( final String key ) { | |
| 523 | return getSettings().getStringSettingList( key ); | |
| 524 | } | |
| 525 | ||
| 526 | private void saveLastDirectory( final File file ) { | |
| 527 | getState().put( "lastDirectory", file.getParent() ); | |
| 528 | } | |
| 529 | ||
| 530 | public void restorePreferences() { | |
| 531 | int activeIndex = 0; | |
| 532 | ||
| 533 | final Preferences preferences = getState(); | |
| 534 | final String[] fileNames = Utils.getPrefsStrings( preferences, "file" ); | |
| 535 | final String activeFileName = preferences.get( "activeFile", null ); | |
| 536 | ||
| 537 | final ArrayList<File> files = new ArrayList<>( fileNames.length ); | |
| 538 | ||
| 539 | for( final String fileName : fileNames ) { | |
| 540 | final File file = new File( fileName ); | |
| 541 | ||
| 542 | if( file.exists() ) { | |
| 543 | files.add( file ); | |
| 544 | ||
| 545 | if( fileName.equals( activeFileName ) ) { | |
| 546 | activeIndex = files.size() - 1; | |
| 547 | } | |
| 548 | } | |
| 549 | } | |
| 550 | ||
| 551 | if( files.isEmpty() ) { | |
| 552 | newEditor(); | |
| 553 | } else { | |
| 554 | openEditors( files, activeIndex ); | |
| 555 | } | |
| 556 | } | |
| 557 | ||
| 558 | public void persistPreferences() { | |
| 559 | final ObservableList<Tab> allEditors = getTabs(); | |
| 560 | final List<String> fileNames = new ArrayList<>( allEditors.size() ); | |
| 561 | ||
| 562 | for( final Tab tab : allEditors ) { | |
| 563 | final FileEditorTab fileEditor = (FileEditorTab)tab; | |
| 564 | final Path filePath = fileEditor.getPath(); | |
| 565 | ||
| 566 | if( filePath != null ) { | |
| 567 | fileNames.add( filePath.toString() ); | |
| 568 | } | |
| 569 | } | |
| 570 | ||
| 571 | final Preferences preferences = getState(); | |
| 572 | Utils.putPrefsStrings( preferences, "file", fileNames.toArray( new String[ fileNames.size() ] ) ); | |
| 573 | ||
| 574 | final FileEditorTab activeEditor = getActiveFileEditor(); | |
| 575 | final Path filePath = activeEditor == null ? null : activeEditor.getPath(); | |
| 576 | ||
| 577 | if( filePath == null ) { | |
| 578 | preferences.remove( "activeFile" ); | |
| 579 | } else { | |
| 580 | preferences.put( "activeFile", filePath.toString() ); | |
| 581 | } | |
| 582 | } | |
| 583 | ||
| 584 | private Settings getSettings() { | |
| 585 | return this.settings; | |
| 586 | } | |
| 587 | ||
| 588 | protected Options getOptions() { | |
| 589 | return this.options; | |
| 590 | } | |
| 591 | ||
| 592 | private Window getWindow() { | |
| 593 | return getScene().getWindow(); | |
| 594 | } | |
| 595 | ||
| 596 | protected Preferences getState() { | |
| 597 | return getOptions().getState(); | |
| 598 | } | |
| 599 | ||
| 600 | Node getNode() { | |
| 601 | return this; | |
| 579 | 602 | } |
| 580 | 603 | } |
| 28 | 28 | package com.scrivenvar; |
| 29 | 29 | |
| 30 | import static com.scrivenvar.Constants.LOGO_128; | |
| 31 | import static com.scrivenvar.Constants.LOGO_16; | |
| 32 | import static com.scrivenvar.Constants.LOGO_256; | |
| 33 | import static com.scrivenvar.Constants.LOGO_32; | |
| 34 | import static com.scrivenvar.Constants.LOGO_512; | |
| 30 | import static com.scrivenvar.Constants.*; | |
| 35 | 31 | import com.scrivenvar.service.Options; |
| 36 | 32 | import com.scrivenvar.service.events.AlertService; |
| ... | ||
| 71 | 67 | initStage( stage ); |
| 72 | 68 | initAlertService(); |
| 73 | ||
| 69 | ||
| 74 | 70 | stage.show(); |
| 75 | 71 | } |
| ... | ||
| 93 | 89 | private void initStage( Stage stage ) { |
| 94 | 90 | stage.getIcons().addAll( |
| 95 | new Image( LOGO_16 ), | |
| 96 | new Image( LOGO_32 ), | |
| 97 | new Image( LOGO_128 ), | |
| 98 | new Image( LOGO_256 ), | |
| 99 | new Image( LOGO_512 ) ); | |
| 91 | createImage( FILE_LOGO_16 ), | |
| 92 | createImage( FILE_LOGO_32 ), | |
| 93 | createImage( FILE_LOGO_128 ), | |
| 94 | createImage( FILE_LOGO_256 ), | |
| 95 | createImage( FILE_LOGO_512 ) ); | |
| 100 | 96 | stage.setTitle( getApplicationTitle() ); |
| 101 | 97 | stage.setScene( getScene() ); |
| ... | ||
| 121 | 117 | public static void showDocument( String uri ) { |
| 122 | 118 | getApplication().getHostServices().showDocument( uri ); |
| 119 | } | |
| 120 | ||
| 121 | private Image createImage( final String filename ) { | |
| 122 | return new Image( filename ); | |
| 123 | 123 | } |
| 124 | 124 | } |
| 28 | 28 | package com.scrivenvar; |
| 29 | 29 | |
| 30 | import static com.scrivenvar.Constants.LOGO_32; | |
| 31 | import static com.scrivenvar.Messages.get; | |
| 32 | import com.scrivenvar.definition.DefinitionPane; | |
| 33 | import com.scrivenvar.editor.MarkdownEditorPane; | |
| 34 | import com.scrivenvar.editor.VariableNameInjector; | |
| 35 | import com.scrivenvar.preview.HTMLPreviewPane; | |
| 36 | import com.scrivenvar.processors.HTMLPreviewProcessor; | |
| 37 | import com.scrivenvar.processors.MarkdownCaretInsertionProcessor; | |
| 38 | import com.scrivenvar.processors.MarkdownCaretReplacementProcessor; | |
| 39 | import com.scrivenvar.processors.MarkdownProcessor; | |
| 40 | import com.scrivenvar.processors.Processor; | |
| 41 | import com.scrivenvar.processors.VariableProcessor; | |
| 42 | import com.scrivenvar.service.Options; | |
| 43 | import com.scrivenvar.util.Action; | |
| 44 | import com.scrivenvar.util.ActionUtils; | |
| 45 | import static com.scrivenvar.util.StageState.K_PANE_SPLIT_DEFINITION; | |
| 46 | import static com.scrivenvar.util.StageState.K_PANE_SPLIT_EDITOR; | |
| 47 | import static com.scrivenvar.util.StageState.K_PANE_SPLIT_PREVIEW; | |
| 48 | import com.scrivenvar.yaml.YamlParser; | |
| 49 | import com.scrivenvar.yaml.YamlTreeAdapter; | |
| 50 | import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.BOLD; | |
| 51 | import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.CODE; | |
| 52 | import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.FILE_ALT; | |
| 53 | import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.FILE_CODE_ALT; | |
| 54 | import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.FLOPPY_ALT; | |
| 55 | import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.FOLDER_OPEN_ALT; | |
| 56 | import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.HEADER; | |
| 57 | import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.ITALIC; | |
| 58 | import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.LINK; | |
| 59 | import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.LIST_OL; | |
| 60 | import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.LIST_UL; | |
| 61 | import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.PICTURE_ALT; | |
| 62 | import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.QUOTE_LEFT; | |
| 63 | import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.REPEAT; | |
| 64 | import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.STRIKETHROUGH; | |
| 65 | import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.UNDO; | |
| 66 | import java.io.IOException; | |
| 67 | import java.io.InputStream; | |
| 68 | import java.util.Map; | |
| 69 | import java.util.function.Function; | |
| 70 | import java.util.prefs.Preferences; | |
| 71 | import javafx.beans.binding.Bindings; | |
| 72 | import javafx.beans.binding.BooleanBinding; | |
| 73 | import javafx.beans.property.BooleanProperty; | |
| 74 | import javafx.beans.property.SimpleBooleanProperty; | |
| 75 | import javafx.beans.value.ObservableBooleanValue; | |
| 76 | import javafx.beans.value.ObservableValue; | |
| 77 | import javafx.collections.ListChangeListener.Change; | |
| 78 | import javafx.collections.ObservableList; | |
| 79 | import javafx.event.Event; | |
| 80 | import javafx.scene.Node; | |
| 81 | import javafx.scene.Scene; | |
| 82 | import javafx.scene.control.Alert; | |
| 83 | import javafx.scene.control.Alert.AlertType; | |
| 84 | import javafx.scene.control.Menu; | |
| 85 | import javafx.scene.control.MenuBar; | |
| 86 | import javafx.scene.control.SplitPane; | |
| 87 | import javafx.scene.control.Tab; | |
| 88 | import javafx.scene.control.ToolBar; | |
| 89 | import javafx.scene.control.TreeView; | |
| 90 | import javafx.scene.image.Image; | |
| 91 | import javafx.scene.image.ImageView; | |
| 92 | import static javafx.scene.input.KeyCode.ESCAPE; | |
| 93 | import javafx.scene.input.KeyEvent; | |
| 94 | import static javafx.scene.input.KeyEvent.CHAR_UNDEFINED; | |
| 95 | import static javafx.scene.input.KeyEvent.KEY_PRESSED; | |
| 96 | import javafx.scene.layout.BorderPane; | |
| 97 | import javafx.scene.layout.VBox; | |
| 98 | import javafx.stage.Window; | |
| 99 | import javafx.stage.WindowEvent; | |
| 100 | ||
| 101 | /** | |
| 102 | * Main window containing a tab pane in the center for file editors. | |
| 103 | * | |
| 104 | * @author Karl Tauber and White Magic Software, Ltd. | |
| 105 | */ | |
| 106 | public class MainWindow { | |
| 107 | ||
| 108 | private final Options options = Services.load( Options.class ); | |
| 109 | ||
| 110 | private Scene scene; | |
| 111 | ||
| 112 | private TreeView<String> treeView; | |
| 113 | private DefinitionPane definitionPane; | |
| 114 | private FileEditorTabPane fileEditorPane; | |
| 115 | private HTMLPreviewPane previewPane; | |
| 116 | ||
| 117 | private VariableNameInjector variableNameInjector; | |
| 118 | ||
| 119 | private YamlTreeAdapter yamlTreeAdapter; | |
| 120 | private YamlParser yamlParser; | |
| 121 | ||
| 122 | private MenuBar menuBar; | |
| 123 | ||
| 124 | public MainWindow() { | |
| 125 | initLayout(); | |
| 126 | initTabAddedListener(); | |
| 127 | restorePreferences(); | |
| 128 | initTabChangeListener(); | |
| 129 | initVariableNameInjector(); | |
| 130 | } | |
| 131 | ||
| 132 | private void initLayout() { | |
| 133 | final SplitPane splitPane = new SplitPane( | |
| 134 | getDefinitionPane().getNode(), | |
| 135 | getFileEditorPane().getNode(), | |
| 136 | getPreviewPane().getNode() ); | |
| 137 | ||
| 138 | splitPane.setDividerPositions( | |
| 139 | getFloat( K_PANE_SPLIT_DEFINITION, .10f ), | |
| 140 | getFloat( K_PANE_SPLIT_EDITOR, .45f ), | |
| 141 | getFloat( K_PANE_SPLIT_PREVIEW, .45f ) ); | |
| 142 | ||
| 143 | // See: http://broadlyapplicable.blogspot.ca/2015/03/javafx-capture-restorePreferences-splitpane.html | |
| 144 | final BorderPane borderPane = new BorderPane(); | |
| 145 | borderPane.setPrefSize( 1024, 800 ); | |
| 146 | borderPane.setTop( createMenuBar() ); | |
| 147 | borderPane.setCenter( splitPane ); | |
| 148 | ||
| 149 | final Scene appScene = new Scene( borderPane ); | |
| 150 | setScene( appScene ); | |
| 151 | appScene.getStylesheets().add( Constants.STYLESHEET_PREVIEW ); | |
| 152 | appScene.windowProperty().addListener( | |
| 153 | (observable, oldWindow, newWindow) -> { | |
| 154 | newWindow.setOnCloseRequest( e -> { | |
| 155 | if( !getFileEditorPane().closeAllEditors() ) { | |
| 156 | e.consume(); | |
| 157 | } | |
| 158 | } ); | |
| 159 | ||
| 160 | // Workaround JavaFX bug: deselect menubar if window loses focus. | |
| 161 | newWindow.focusedProperty().addListener( | |
| 162 | (obs, oldFocused, newFocused) -> { | |
| 163 | if( !newFocused ) { | |
| 164 | // Send an ESC key event to the menubar | |
| 165 | this.menuBar.fireEvent( | |
| 166 | new KeyEvent( | |
| 167 | KEY_PRESSED, CHAR_UNDEFINED, "", ESCAPE, | |
| 168 | false, false, false, false ) ); | |
| 169 | } | |
| 170 | } ); | |
| 171 | } ); | |
| 172 | } | |
| 173 | ||
| 174 | private void initVariableNameInjector() { | |
| 175 | setVariableNameInjector( new VariableNameInjector( | |
| 176 | getFileEditorPane(), | |
| 177 | getDefinitionPane() ) | |
| 178 | ); | |
| 179 | } | |
| 180 | ||
| 181 | private Window getWindow() { | |
| 182 | return getScene().getWindow(); | |
| 183 | } | |
| 184 | ||
| 185 | public Scene getScene() { | |
| 186 | return this.scene; | |
| 187 | } | |
| 188 | ||
| 189 | private void setScene( Scene scene ) { | |
| 190 | this.scene = scene; | |
| 191 | } | |
| 192 | ||
| 193 | /** | |
| 194 | * Creates a boolean property that is bound to another boolean value of the | |
| 195 | * active editor. | |
| 196 | */ | |
| 197 | private BooleanProperty createActiveBooleanProperty( | |
| 198 | final Function<FileEditorTab, ObservableBooleanValue> func ) { | |
| 199 | ||
| 200 | final BooleanProperty b = new SimpleBooleanProperty(); | |
| 201 | final FileEditorTab tab = getActiveFileEditor(); | |
| 202 | ||
| 203 | if( tab != null ) { | |
| 204 | b.bind( func.apply( tab ) ); | |
| 205 | } | |
| 206 | ||
| 207 | getFileEditorPane().activeFileEditorProperty().addListener( | |
| 208 | (observable, oldFileEditor, newFileEditor) -> { | |
| 209 | b.unbind(); | |
| 210 | ||
| 211 | if( newFileEditor != null ) { | |
| 212 | b.bind( func.apply( newFileEditor ) ); | |
| 213 | } else { | |
| 214 | b.set( false ); | |
| 215 | } | |
| 216 | } ); | |
| 217 | ||
| 218 | return b; | |
| 219 | } | |
| 220 | ||
| 221 | //---- File actions ------------------------------------------------------- | |
| 222 | private void fileNew() { | |
| 223 | getFileEditorPane().newEditor(); | |
| 224 | } | |
| 225 | ||
| 226 | private void fileOpen() { | |
| 227 | getFileEditorPane().openFileDialog(); | |
| 228 | } | |
| 229 | ||
| 230 | private void fileClose() { | |
| 231 | getFileEditorPane().closeEditor( getActiveFileEditor(), true ); | |
| 232 | } | |
| 233 | ||
| 234 | private void fileCloseAll() { | |
| 235 | getFileEditorPane().closeAllEditors(); | |
| 236 | } | |
| 237 | ||
| 238 | private void fileSave() { | |
| 239 | getFileEditorPane().saveEditor( getActiveFileEditor() ); | |
| 240 | } | |
| 241 | ||
| 242 | private void fileSaveAll() { | |
| 243 | getFileEditorPane().saveAllEditors(); | |
| 244 | } | |
| 245 | ||
| 246 | private void fileExit() { | |
| 247 | final Window window = getWindow(); | |
| 248 | Event.fireEvent( window, | |
| 249 | new WindowEvent( window, WindowEvent.WINDOW_CLOSE_REQUEST ) ); | |
| 250 | } | |
| 251 | ||
| 252 | //---- Help actions ------------------------------------------------------- | |
| 253 | private void helpAbout() { | |
| 254 | Alert alert = new Alert( AlertType.INFORMATION ); | |
| 255 | alert.setTitle( Messages.get( "Dialog.about.title" ) ); | |
| 256 | alert.setHeaderText( Messages.get( "Dialog.about.header" ) ); | |
| 257 | alert.setContentText( Messages.get( "Dialog.about.content" ) ); | |
| 258 | alert.setGraphic( new ImageView( new Image( LOGO_32 ) ) ); | |
| 259 | alert.initOwner( getWindow() ); | |
| 260 | ||
| 261 | alert.showAndWait(); | |
| 262 | } | |
| 263 | ||
| 264 | private FileEditorTabPane getFileEditorPane() { | |
| 265 | if( this.fileEditorPane == null ) { | |
| 266 | this.fileEditorPane = createFileEditorPane(); | |
| 267 | } | |
| 268 | ||
| 269 | return this.fileEditorPane; | |
| 270 | } | |
| 271 | ||
| 272 | /** | |
| 273 | * Create an editor pane to hold file editor tabs. | |
| 274 | * | |
| 275 | * @return A new instance, never null. | |
| 276 | */ | |
| 277 | private FileEditorTabPane createFileEditorPane() { | |
| 278 | return new FileEditorTabPane(); | |
| 279 | } | |
| 280 | ||
| 281 | /** | |
| 282 | * Reloads the preferences from the previous load. | |
| 283 | */ | |
| 284 | private void restorePreferences() { | |
| 285 | getFileEditorPane().restorePreferences(); | |
| 286 | } | |
| 287 | ||
| 288 | private void initTabAddedListener() { | |
| 289 | final FileEditorTabPane editorPane = getFileEditorPane(); | |
| 290 | ||
| 291 | // Make sure the text processor kicks off when new files are opened. | |
| 292 | final ObservableList<Tab> tabs = editorPane.getTabs(); | |
| 293 | ||
| 294 | // Update the preview pane on tab changes. | |
| 295 | tabs.addListener( (final Change<? extends Tab> change) -> { | |
| 296 | while( change.next() ) { | |
| 297 | if( change.wasAdded() ) { | |
| 298 | // Multiple tabs can be added simultaneously. | |
| 299 | for( final Tab newTab : change.getAddedSubList() ) { | |
| 300 | final FileEditorTab tab = (FileEditorTab)newTab; | |
| 301 | ||
| 302 | initTextChangeListener( tab ); | |
| 303 | initCaretParagraphListener( tab ); | |
| 304 | process( tab ); | |
| 305 | } | |
| 306 | } | |
| 307 | } | |
| 308 | } ); | |
| 309 | } | |
| 310 | ||
| 311 | /** | |
| 312 | * Listen for tab changes. | |
| 313 | */ | |
| 314 | private void initTabChangeListener() { | |
| 315 | final FileEditorTabPane editorPane = getFileEditorPane(); | |
| 316 | ||
| 317 | // Update the preview pane changing tabs. | |
| 318 | editorPane.addTabChangeListener( | |
| 319 | (ObservableValue<? extends Tab> tabPane, | |
| 320 | final Tab oldTab, final Tab newTab) -> { | |
| 321 | ||
| 322 | final FileEditorTab tab = (FileEditorTab)newTab; | |
| 323 | ||
| 324 | if( tab != null ) { | |
| 325 | // When a new tab is selected, ensure that the base path to images | |
| 326 | // is set correctly. | |
| 327 | getPreviewPane().setPath( tab.getPath() ); | |
| 328 | process( tab ); | |
| 329 | } | |
| 330 | } ); | |
| 331 | } | |
| 332 | ||
| 333 | private void initTextChangeListener( final FileEditorTab tab ) { | |
| 334 | tab.addTextChangeListener( (ObservableValue<? extends String> editor, | |
| 335 | final String oldValue, final String newValue) -> { | |
| 336 | process( tab ); | |
| 337 | } ); | |
| 338 | } | |
| 339 | ||
| 340 | private void initCaretParagraphListener( final FileEditorTab tab ) { | |
| 341 | tab.addCaretParagraphListener( (ObservableValue<? extends Integer> editor, | |
| 342 | final Integer oldValue, final Integer newValue) -> { | |
| 343 | process( tab ); | |
| 344 | } ); | |
| 345 | } | |
| 346 | ||
| 347 | /** | |
| 348 | * Called whenever the preview pane becomes out of sync with the file editor | |
| 349 | * tab. This can be called when the text changes, the caret paragraph changes, | |
| 350 | * or the file tab changes. | |
| 351 | * | |
| 352 | * @param tab The file editor tab that has been changed in some fashion. | |
| 353 | */ | |
| 354 | private void process( final FileEditorTab tab ) { | |
| 355 | // TODO: Use a factory based on the filename extension. The default | |
| 356 | // extension will be for a markdown file (e.g., on file new). | |
| 357 | final Processor<String> hpp = new HTMLPreviewProcessor( getPreviewPane() ); | |
| 358 | final Processor<String> mcrp = new MarkdownCaretReplacementProcessor( hpp ); | |
| 359 | final Processor<String> mp = new MarkdownProcessor( mcrp ); | |
| 360 | final Processor<String> mcip = new MarkdownCaretInsertionProcessor( mp, tab.getCaretPosition() ); | |
| 361 | final Processor<String> vp = new VariableProcessor( mcip, getResolvedMap() ); | |
| 362 | ||
| 363 | vp.processChain( tab.getEditorText() ); | |
| 364 | } | |
| 365 | ||
| 366 | private MarkdownEditorPane getActiveEditor() { | |
| 367 | return (MarkdownEditorPane)(getActiveFileEditor().getEditorPane()); | |
| 368 | } | |
| 369 | ||
| 370 | private FileEditorTab getActiveFileEditor() { | |
| 371 | return getFileEditorPane().getActiveFileEditor(); | |
| 372 | } | |
| 373 | ||
| 374 | protected DefinitionPane createDefinitionPane() { | |
| 375 | return new DefinitionPane( getTreeView() ); | |
| 376 | } | |
| 377 | ||
| 378 | private DefinitionPane getDefinitionPane() { | |
| 379 | if( this.definitionPane == null ) { | |
| 380 | this.definitionPane = createDefinitionPane(); | |
| 381 | } | |
| 382 | ||
| 383 | return this.definitionPane; | |
| 384 | } | |
| 385 | ||
| 386 | public MenuBar getMenuBar() { | |
| 387 | return this.menuBar; | |
| 388 | } | |
| 389 | ||
| 390 | public void setMenuBar( MenuBar menuBar ) { | |
| 391 | this.menuBar = menuBar; | |
| 392 | } | |
| 393 | ||
| 394 | public VariableNameInjector getVariableNameInjector() { | |
| 395 | return this.variableNameInjector; | |
| 396 | } | |
| 397 | ||
| 398 | public void setVariableNameInjector( VariableNameInjector variableNameInjector ) { | |
| 399 | this.variableNameInjector = variableNameInjector; | |
| 400 | } | |
| 401 | ||
| 402 | private float getFloat( final String key, final float defaultValue ) { | |
| 403 | return getPreferences().getFloat( key, defaultValue ); | |
| 404 | } | |
| 405 | ||
| 406 | private Preferences getPreferences() { | |
| 407 | return getOptions().getState(); | |
| 408 | } | |
| 409 | ||
| 410 | private Options getOptions() { | |
| 411 | return this.options; | |
| 412 | } | |
| 413 | ||
| 414 | private synchronized TreeView<String> getTreeView() throws RuntimeException { | |
| 415 | if( this.treeView == null ) { | |
| 416 | try { | |
| 417 | this.treeView = createTreeView(); | |
| 418 | } catch( IOException ex ) { | |
| 419 | ||
| 420 | // TODO: Pop an error message. | |
| 421 | throw new RuntimeException( ex ); | |
| 422 | } | |
| 423 | } | |
| 424 | ||
| 425 | return this.treeView; | |
| 426 | } | |
| 427 | ||
| 428 | private InputStream asStream( final String resource ) { | |
| 429 | return getClass().getResourceAsStream( resource ); | |
| 430 | } | |
| 431 | ||
| 432 | private TreeView<String> createTreeView() throws IOException { | |
| 433 | // TODO: Associate variable file with path to current file. | |
| 434 | return getYamlTreeAdapter().adapt( | |
| 435 | asStream( "/com/scrivenvar/variables.yaml" ), | |
| 436 | get( "Pane.defintion.node.root.title" ) | |
| 437 | ); | |
| 438 | } | |
| 439 | ||
| 440 | private Map<String, String> getResolvedMap() { | |
| 441 | return getYamlParser().createResolvedMap(); | |
| 442 | } | |
| 443 | ||
| 444 | private YamlTreeAdapter getYamlTreeAdapter() { | |
| 445 | if( this.yamlTreeAdapter == null ) { | |
| 446 | setYamlTreeAdapter( new YamlTreeAdapter( getYamlParser() ) ); | |
| 447 | } | |
| 448 | ||
| 449 | return this.yamlTreeAdapter; | |
| 450 | } | |
| 451 | ||
| 452 | private void setYamlTreeAdapter( final YamlTreeAdapter yamlTreeAdapter ) { | |
| 453 | this.yamlTreeAdapter = yamlTreeAdapter; | |
| 454 | } | |
| 455 | ||
| 456 | private YamlParser getYamlParser() { | |
| 457 | if( this.yamlParser == null ) { | |
| 458 | setYamlParser( new YamlParser() ); | |
| 459 | } | |
| 460 | ||
| 461 | return this.yamlParser; | |
| 462 | } | |
| 463 | ||
| 464 | private void setYamlParser( final YamlParser yamlParser ) { | |
| 465 | this.yamlParser = yamlParser; | |
| 466 | } | |
| 467 | ||
| 468 | private Node createMenuBar() { | |
| 469 | final BooleanBinding activeFileEditorIsNull = getFileEditorPane().activeFileEditorProperty().isNull(); | |
| 470 | ||
| 471 | // File actions | |
| 472 | Action fileNewAction = new Action( Messages.get( "Main.menu.file.new" ), "Shortcut+N", FILE_ALT, e -> fileNew() ); | |
| 473 | Action fileOpenAction = new Action( Messages.get( "Main.menu.file.open" ), "Shortcut+O", FOLDER_OPEN_ALT, e -> fileOpen() ); | |
| 474 | Action fileCloseAction = new Action( Messages.get( "Main.menu.file.close" ), "Shortcut+W", null, e -> fileClose(), activeFileEditorIsNull ); | |
| 475 | Action fileCloseAllAction = new Action( Messages.get( "Main.menu.file.close_all" ), null, null, e -> fileCloseAll(), activeFileEditorIsNull ); | |
| 476 | Action fileSaveAction = new Action( Messages.get( "Main.menu.file.save" ), "Shortcut+S", FLOPPY_ALT, e -> fileSave(), | |
| 477 | createActiveBooleanProperty( FileEditorTab::modifiedProperty ).not() ); | |
| 478 | Action fileSaveAllAction = new Action( Messages.get( "Main.menu.file.save_all" ), "Shortcut+Shift+S", null, e -> fileSaveAll(), | |
| 479 | Bindings.not( getFileEditorPane().anyFileEditorModifiedProperty() ) ); | |
| 480 | Action fileExitAction = new Action( Messages.get( "Main.menu.file.exit" ), null, null, e -> fileExit() ); | |
| 481 | ||
| 482 | // Edit actions | |
| 483 | Action editUndoAction = new Action( Messages.get( "Main.menu.edit.undo" ), "Shortcut+Z", UNDO, | |
| 484 | e -> getActiveEditor().undo(), | |
| 485 | createActiveBooleanProperty( FileEditorTab::canUndoProperty ).not() ); | |
| 486 | Action editRedoAction = new Action( Messages.get( "Main.menu.edit.redo" ), "Shortcut+Y", REPEAT, | |
| 487 | e -> getActiveEditor().redo(), | |
| 488 | createActiveBooleanProperty( FileEditorTab::canRedoProperty ).not() ); | |
| 489 | ||
| 490 | // Insert actions | |
| 491 | Action insertBoldAction = new Action( Messages.get( "Main.menu.insert.bold" ), "Shortcut+B", BOLD, | |
| 492 | e -> getActiveEditor().surroundSelection( "**", "**" ), | |
| 493 | activeFileEditorIsNull ); | |
| 494 | Action insertItalicAction = new Action( Messages.get( "Main.menu.insert.italic" ), "Shortcut+I", ITALIC, | |
| 495 | e -> getActiveEditor().surroundSelection( "*", "*" ), | |
| 496 | activeFileEditorIsNull ); | |
| 497 | Action insertStrikethroughAction = new Action( Messages.get( "Main.menu.insert.strikethrough" ), "Shortcut+T", STRIKETHROUGH, | |
| 498 | e -> getActiveEditor().surroundSelection( "~~", "~~" ), | |
| 499 | activeFileEditorIsNull ); | |
| 500 | Action insertBlockquoteAction = new Action( Messages.get( "Main.menu.insert.blockquote" ), "Ctrl+Q", QUOTE_LEFT, // not Shortcut+Q because of conflict on Mac | |
| 501 | e -> getActiveEditor().surroundSelection( "\n\n> ", "" ), | |
| 502 | activeFileEditorIsNull ); | |
| 503 | Action insertCodeAction = new Action( Messages.get( "Main.menu.insert.code" ), "Shortcut+K", CODE, | |
| 504 | e -> getActiveEditor().surroundSelection( "`", "`" ), | |
| 505 | activeFileEditorIsNull ); | |
| 506 | Action insertFencedCodeBlockAction = new Action( Messages.get( "Main.menu.insert.fenced_code_block" ), "Shortcut+Shift+K", FILE_CODE_ALT, | |
| 507 | e -> getActiveEditor().surroundSelection( "\n\n```\n", "\n```\n\n", Messages.get( "Main.menu.insert.fenced_code_block.prompt" ) ), | |
| 508 | activeFileEditorIsNull ); | |
| 509 | ||
| 510 | Action insertLinkAction = new Action( Messages.get( "Main.menu.insert.link" ), "Shortcut+L", LINK, | |
| 511 | e -> getActiveEditor().insertLink(), | |
| 512 | activeFileEditorIsNull ); | |
| 513 | Action insertImageAction = new Action( Messages.get( "Main.menu.insert.image" ), "Shortcut+G", PICTURE_ALT, | |
| 514 | e -> getActiveEditor().insertImage(), | |
| 515 | activeFileEditorIsNull ); | |
| 516 | ||
| 517 | final Action[] headers = new Action[ 6 ]; | |
| 518 | ||
| 519 | // Insert header actions (H1 ... H6) | |
| 520 | for( int i = 1; i <= 6; i++ ) { | |
| 521 | final String hashes = new String( new char[ i ] ).replace( "\0", "#" ); | |
| 522 | final String markup = String.format( "\n\n%s ", hashes ); | |
| 523 | final String text = Messages.get( "Main.menu.insert.header_" + i ); | |
| 524 | final String accelerator = "Shortcut+" + i; | |
| 525 | final String prompt = Messages.get( "Main.menu.insert.header_" + i + ".prompt" ); | |
| 526 | ||
| 527 | headers[ i - 1 ] = new Action( text, accelerator, HEADER, | |
| 528 | e -> getActiveEditor().surroundSelection( markup, "", prompt ), | |
| 529 | activeFileEditorIsNull ); | |
| 530 | } | |
| 531 | ||
| 532 | Action insertUnorderedListAction = new Action( Messages.get( "Main.menu.insert.unordered_list" ), "Shortcut+U", LIST_UL, | |
| 533 | e -> getActiveEditor().surroundSelection( "\n\n* ", "" ), | |
| 534 | activeFileEditorIsNull ); | |
| 535 | Action insertOrderedListAction = new Action( Messages.get( "Main.menu.insert.ordered_list" ), "Shortcut+Shift+O", LIST_OL, | |
| 536 | e -> getActiveEditor().surroundSelection( "\n\n1. ", "" ), | |
| 537 | activeFileEditorIsNull ); | |
| 538 | Action insertHorizontalRuleAction = new Action( Messages.get( "Main.menu.insert.horizontal_rule" ), "Shortcut+H", null, | |
| 539 | e -> getActiveEditor().surroundSelection( "\n\n---\n\n", "" ), | |
| 540 | activeFileEditorIsNull ); | |
| 541 | ||
| 542 | // Help actions | |
| 543 | Action helpAboutAction = new Action( Messages.get( "Main.menu.help.about" ), null, null, e -> helpAbout() ); | |
| 544 | ||
| 545 | //---- MenuBar ---- | |
| 546 | Menu fileMenu = ActionUtils.createMenu( Messages.get( "Main.menu.file" ), | |
| 547 | fileNewAction, | |
| 548 | fileOpenAction, | |
| 549 | null, | |
| 550 | fileCloseAction, | |
| 551 | fileCloseAllAction, | |
| 552 | null, | |
| 553 | fileSaveAction, | |
| 554 | fileSaveAllAction, | |
| 555 | null, | |
| 556 | fileExitAction ); | |
| 557 | ||
| 558 | Menu editMenu = ActionUtils.createMenu( Messages.get( "Main.menu.edit" ), | |
| 559 | editUndoAction, | |
| 560 | editRedoAction ); | |
| 561 | ||
| 562 | Menu insertMenu = ActionUtils.createMenu( Messages.get( "Main.menu.insert" ), | |
| 563 | insertBoldAction, | |
| 564 | insertItalicAction, | |
| 565 | insertStrikethroughAction, | |
| 566 | insertBlockquoteAction, | |
| 567 | insertCodeAction, | |
| 568 | insertFencedCodeBlockAction, | |
| 569 | null, | |
| 570 | insertLinkAction, | |
| 571 | insertImageAction, | |
| 572 | null, | |
| 573 | headers[ 0 ], | |
| 574 | headers[ 1 ], | |
| 575 | headers[ 2 ], | |
| 576 | headers[ 3 ], | |
| 577 | headers[ 4 ], | |
| 578 | headers[ 5 ], | |
| 579 | null, | |
| 580 | insertUnorderedListAction, | |
| 581 | insertOrderedListAction, | |
| 582 | insertHorizontalRuleAction ); | |
| 583 | ||
| 584 | Menu helpMenu = ActionUtils.createMenu( Messages.get( "Main.menu.help" ), | |
| 585 | helpAboutAction ); | |
| 586 | ||
| 587 | menuBar = new MenuBar( fileMenu, editMenu, insertMenu, helpMenu ); | |
| 588 | ||
| 589 | //---- ToolBar ---- | |
| 590 | ToolBar toolBar = ActionUtils.createToolBar( | |
| 591 | fileNewAction, | |
| 592 | fileOpenAction, | |
| 593 | fileSaveAction, | |
| 594 | null, | |
| 595 | editUndoAction, | |
| 596 | editRedoAction, | |
| 597 | null, | |
| 598 | insertBoldAction, | |
| 599 | insertItalicAction, | |
| 600 | insertBlockquoteAction, | |
| 601 | insertCodeAction, | |
| 602 | insertFencedCodeBlockAction, | |
| 603 | null, | |
| 604 | insertLinkAction, | |
| 605 | insertImageAction, | |
| 606 | null, | |
| 607 | headers[ 0 ], | |
| 608 | null, | |
| 609 | insertUnorderedListAction, | |
| 610 | insertOrderedListAction ); | |
| 611 | ||
| 612 | return new VBox( menuBar, toolBar ); | |
| 613 | } | |
| 614 | ||
| 615 | private synchronized HTMLPreviewPane getPreviewPane() { | |
| 616 | if( this.previewPane == null ) { | |
| 617 | this.previewPane = new HTMLPreviewPane(); | |
| 618 | } | |
| 619 | ||
| 620 | return this.previewPane; | |
| 621 | } | |
| 622 | ||
| 30 | import static com.scrivenvar.Constants.FILE_LOGO_32; | |
| 31 | import static com.scrivenvar.Constants.STYLESHEET_SCENE; | |
| 32 | import static com.scrivenvar.Messages.get; | |
| 33 | import com.scrivenvar.definition.DefinitionFactory; | |
| 34 | import com.scrivenvar.definition.DefinitionPane; | |
| 35 | import com.scrivenvar.definition.DefinitionSource; | |
| 36 | import com.scrivenvar.editors.VariableNameInjector; | |
| 37 | import com.scrivenvar.editors.markdown.MarkdownEditorPane; | |
| 38 | import com.scrivenvar.preview.HTMLPreviewPane; | |
| 39 | import com.scrivenvar.processors.HTMLPreviewProcessor; | |
| 40 | import com.scrivenvar.processors.MarkdownCaretInsertionProcessor; | |
| 41 | import com.scrivenvar.processors.MarkdownCaretReplacementProcessor; | |
| 42 | import com.scrivenvar.processors.MarkdownProcessor; | |
| 43 | import com.scrivenvar.processors.Processor; | |
| 44 | import com.scrivenvar.processors.VariableProcessor; | |
| 45 | import com.scrivenvar.service.Options; | |
| 46 | import com.scrivenvar.util.Action; | |
| 47 | import com.scrivenvar.util.ActionUtils; | |
| 48 | import static com.scrivenvar.util.StageState.K_PANE_SPLIT_DEFINITION; | |
| 49 | import static com.scrivenvar.util.StageState.K_PANE_SPLIT_EDITOR; | |
| 50 | import static com.scrivenvar.util.StageState.K_PANE_SPLIT_PREVIEW; | |
| 51 | import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.BOLD; | |
| 52 | import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.CODE; | |
| 53 | import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.FILE_ALT; | |
| 54 | import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.FILE_CODE_ALT; | |
| 55 | import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.FLOPPY_ALT; | |
| 56 | import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.FOLDER_OPEN_ALT; | |
| 57 | import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.HEADER; | |
| 58 | import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.ITALIC; | |
| 59 | import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.LINK; | |
| 60 | import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.LIST_OL; | |
| 61 | import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.LIST_UL; | |
| 62 | import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.PICTURE_ALT; | |
| 63 | import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.QUOTE_LEFT; | |
| 64 | import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.REPEAT; | |
| 65 | import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.STRIKETHROUGH; | |
| 66 | import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.UNDO; | |
| 67 | import java.io.File; | |
| 68 | import java.nio.file.Path; | |
| 69 | import java.util.HashMap; | |
| 70 | import java.util.Map; | |
| 71 | import java.util.function.Function; | |
| 72 | import java.util.prefs.Preferences; | |
| 73 | import javafx.beans.binding.Bindings; | |
| 74 | import javafx.beans.binding.BooleanBinding; | |
| 75 | import javafx.beans.property.BooleanProperty; | |
| 76 | import javafx.beans.property.SimpleBooleanProperty; | |
| 77 | import javafx.beans.value.ObservableBooleanValue; | |
| 78 | import javafx.beans.value.ObservableValue; | |
| 79 | import javafx.collections.ListChangeListener.Change; | |
| 80 | import javafx.collections.ObservableList; | |
| 81 | import javafx.event.Event; | |
| 82 | import javafx.scene.Node; | |
| 83 | import javafx.scene.Scene; | |
| 84 | import javafx.scene.control.Alert; | |
| 85 | import javafx.scene.control.Alert.AlertType; | |
| 86 | import javafx.scene.control.Menu; | |
| 87 | import javafx.scene.control.MenuBar; | |
| 88 | import javafx.scene.control.SplitPane; | |
| 89 | import javafx.scene.control.Tab; | |
| 90 | import javafx.scene.control.ToolBar; | |
| 91 | import javafx.scene.control.TreeView; | |
| 92 | import javafx.scene.image.Image; | |
| 93 | import javafx.scene.image.ImageView; | |
| 94 | import static javafx.scene.input.KeyCode.ESCAPE; | |
| 95 | import javafx.scene.input.KeyEvent; | |
| 96 | import static javafx.scene.input.KeyEvent.CHAR_UNDEFINED; | |
| 97 | import static javafx.scene.input.KeyEvent.KEY_PRESSED; | |
| 98 | import javafx.scene.layout.BorderPane; | |
| 99 | import javafx.scene.layout.VBox; | |
| 100 | import javafx.stage.Window; | |
| 101 | import javafx.stage.WindowEvent; | |
| 102 | ||
| 103 | /** | |
| 104 | * Main window containing a tab pane in the center for file editors. | |
| 105 | * | |
| 106 | * @author Karl Tauber and White Magic Software, Ltd. | |
| 107 | */ | |
| 108 | public class MainWindow { | |
| 109 | ||
| 110 | private final Options options = Services.load( Options.class ); | |
| 111 | ||
| 112 | private Scene scene; | |
| 113 | ||
| 114 | private DefinitionPane definitionPane; | |
| 115 | private FileEditorTabPane fileEditorPane; | |
| 116 | private HTMLPreviewPane previewPane; | |
| 117 | ||
| 118 | private VariableNameInjector variableNameInjector; | |
| 119 | ||
| 120 | private MenuBar menuBar; | |
| 121 | ||
| 122 | public MainWindow() { | |
| 123 | initLayout(); | |
| 124 | initOpenDefinitionListener(); | |
| 125 | initTabAddedListener(); | |
| 126 | initTabChangeListener(); | |
| 127 | initPreferences(); | |
| 128 | initVariableNameInjector(); | |
| 129 | } | |
| 130 | ||
| 131 | /** | |
| 132 | * Listen for file editor tab pane to receive an open definition source event. | |
| 133 | */ | |
| 134 | private void initOpenDefinitionListener() { | |
| 135 | getFileEditorPane().onOpenDefinitionFileProperty().addListener( | |
| 136 | (ObservableValue<? extends Path> definitionFile, | |
| 137 | final Path oldPath, final Path newPath) -> { | |
| 138 | final DefinitionSource ds = createDefinitionSource( newPath ); | |
| 139 | associate( ds, getActiveFileEditor() ); | |
| 140 | } ); | |
| 141 | } | |
| 142 | ||
| 143 | /** | |
| 144 | * When tabs are added, hook the various change listeners onto the new tab so | |
| 145 | * that the preview pane refreshes as necessary. | |
| 146 | */ | |
| 147 | private void initTabAddedListener() { | |
| 148 | final FileEditorTabPane editorPane = getFileEditorPane(); | |
| 149 | ||
| 150 | // Make sure the text processor kicks off when new files are opened. | |
| 151 | final ObservableList<Tab> tabs = editorPane.getTabs(); | |
| 152 | ||
| 153 | // Update the preview pane on tab changes. | |
| 154 | tabs.addListener( | |
| 155 | (final Change<? extends Tab> change) -> { | |
| 156 | while( change.next() ) { | |
| 157 | if( change.wasAdded() ) { | |
| 158 | // Multiple tabs can be added simultaneously. | |
| 159 | for( final Tab newTab : change.getAddedSubList() ) { | |
| 160 | final FileEditorTab tab = (FileEditorTab)newTab; | |
| 161 | ||
| 162 | initTextChangeListener( tab ); | |
| 163 | initCaretParagraphListener( tab ); | |
| 164 | } | |
| 165 | } | |
| 166 | } | |
| 167 | } | |
| 168 | ); | |
| 169 | } | |
| 170 | ||
| 171 | /** | |
| 172 | * Reloads the preferences from the previous load. | |
| 173 | */ | |
| 174 | private void initPreferences() { | |
| 175 | getFileEditorPane().restorePreferences(); | |
| 176 | } | |
| 177 | ||
| 178 | /** | |
| 179 | * Listen for new tab selection events. | |
| 180 | */ | |
| 181 | private void initTabChangeListener() { | |
| 182 | final FileEditorTabPane editorPane = getFileEditorPane(); | |
| 183 | ||
| 184 | // Update the preview pane changing tabs. | |
| 185 | editorPane.addTabSelectionListener( | |
| 186 | (ObservableValue<? extends Tab> tabPane, | |
| 187 | final Tab oldTab, final Tab newTab) -> { | |
| 188 | ||
| 189 | // If there was no old tab, then this is a first time load, which | |
| 190 | // can be ignored. | |
| 191 | if( oldTab != null ) { | |
| 192 | if( newTab == null ) { | |
| 193 | closeRemainingTab(); | |
| 194 | } else { | |
| 195 | // Synchronize the preview with the edited text. | |
| 196 | refreshSelectedTab( (FileEditorTab)newTab ); | |
| 197 | } | |
| 198 | } | |
| 199 | } | |
| 200 | ); | |
| 201 | } | |
| 202 | ||
| 203 | /** | |
| 204 | * Initialize the variable name editor. | |
| 205 | */ | |
| 206 | private void initVariableNameInjector() { | |
| 207 | setVariableNameInjector( | |
| 208 | new VariableNameInjector( getFileEditorPane(), getDefinitionPane() ) | |
| 209 | ); | |
| 210 | } | |
| 211 | ||
| 212 | private void initTextChangeListener( final FileEditorTab tab ) { | |
| 213 | tab.addTextChangeListener( | |
| 214 | (ObservableValue<? extends String> editor, | |
| 215 | final String oldValue, final String newValue) -> { | |
| 216 | refreshSelectedTab( tab ); | |
| 217 | } | |
| 218 | ); | |
| 219 | } | |
| 220 | ||
| 221 | private void initCaretParagraphListener( final FileEditorTab tab ) { | |
| 222 | tab.addCaretParagraphListener( | |
| 223 | (ObservableValue<? extends Integer> editor, | |
| 224 | final Integer oldValue, final Integer newValue) -> { | |
| 225 | refreshSelectedTab( tab ); | |
| 226 | } | |
| 227 | ); | |
| 228 | } | |
| 229 | ||
| 230 | /** | |
| 231 | * Called whenever the preview pane becomes out of sync with the file editor | |
| 232 | * tab. This can be called when the text changes, the caret paragraph changes, | |
| 233 | * or the file tab changes. | |
| 234 | * | |
| 235 | * @param tab The file editor tab that has been changed in some fashion. | |
| 236 | */ | |
| 237 | private void refreshSelectedTab( final FileEditorTab tab ) { | |
| 238 | final HTMLPreviewPane preview = getPreviewPane(); | |
| 239 | preview.setPath( tab.getPath() ); | |
| 240 | ||
| 241 | final Processor<String> hpp = new HTMLPreviewProcessor( preview ); | |
| 242 | final Processor<String> mcrp = new MarkdownCaretReplacementProcessor( hpp ); | |
| 243 | final Processor<String> mp = new MarkdownProcessor( mcrp ); | |
| 244 | final Processor<String> mcip = new MarkdownCaretInsertionProcessor( mp, tab.getCaretPosition() ); | |
| 245 | final Processor<String> vp = new VariableProcessor( mcip, getResolvedMap() ); | |
| 246 | ||
| 247 | vp.processChain( tab.getEditorText() ); | |
| 248 | } | |
| 249 | ||
| 250 | /** | |
| 251 | * TODO: Patch into loading of definition source. | |
| 252 | * | |
| 253 | * @return | |
| 254 | */ | |
| 255 | private Map<String, String> getResolvedMap() { | |
| 256 | return new HashMap<>(); | |
| 257 | } | |
| 258 | ||
| 259 | /** | |
| 260 | * TODO: Patch into loading of definition source. | |
| 261 | * | |
| 262 | * @return | |
| 263 | */ | |
| 264 | private TreeView<String> getTreeView() { | |
| 265 | return new TreeView<>(); | |
| 266 | } | |
| 267 | ||
| 268 | /** | |
| 269 | * Called when the tab has changed to a new editor to replace the current | |
| 270 | * definition pane with the | |
| 271 | * | |
| 272 | * @param tab Reference to the tab that has the file being edited. | |
| 273 | */ | |
| 274 | private void updateDefinitionPane( final FileEditorTab tab ) { | |
| 275 | // Look up the path to the variable definition file associated with the | |
| 276 | // given tab. | |
| 277 | final Path path = getVariableDefinitionPath( tab.getPath() ); | |
| 278 | final DefinitionSource ds = createDefinitionSource( path ); | |
| 279 | ||
| 280 | associate( ds, tab ); | |
| 281 | } | |
| 282 | ||
| 283 | private void associate( final DefinitionSource ds, final FileEditorTab tab ) { | |
| 284 | System.out.println( "Associate " + ds + " with " + tab ); | |
| 285 | } | |
| 286 | ||
| 287 | /** | |
| 288 | * Searches the persistent settings for the variable definition file that is | |
| 289 | * associated with the given path. | |
| 290 | * | |
| 291 | * @param tabPath The path that may be associated with some variables. | |
| 292 | * | |
| 293 | * @return A path to the variable definition file for the given document path. | |
| 294 | */ | |
| 295 | private Path getVariableDefinitionPath( final Path tabPath ) { | |
| 296 | return new File( "/tmp/variables.yaml" ).toPath(); | |
| 297 | } | |
| 298 | ||
| 299 | /** | |
| 300 | * Creates a boolean property that is bound to another boolean value of the | |
| 301 | * active editor. | |
| 302 | */ | |
| 303 | private BooleanProperty createActiveBooleanProperty( | |
| 304 | final Function<FileEditorTab, ObservableBooleanValue> func ) { | |
| 305 | ||
| 306 | final BooleanProperty b = new SimpleBooleanProperty(); | |
| 307 | final FileEditorTab tab = getActiveFileEditor(); | |
| 308 | ||
| 309 | if( tab != null ) { | |
| 310 | b.bind( func.apply( tab ) ); | |
| 311 | } | |
| 312 | ||
| 313 | getFileEditorPane().activeFileEditorProperty().addListener( | |
| 314 | (observable, oldFileEditor, newFileEditor) -> { | |
| 315 | b.unbind(); | |
| 316 | ||
| 317 | if( newFileEditor != null ) { | |
| 318 | b.bind( func.apply( newFileEditor ) ); | |
| 319 | } else { | |
| 320 | b.set( false ); | |
| 321 | } | |
| 322 | } | |
| 323 | ); | |
| 324 | ||
| 325 | return b; | |
| 326 | } | |
| 327 | ||
| 328 | /** | |
| 329 | * Called when the last open tab is closed. This clears out the preview pane | |
| 330 | * and the definition pane. | |
| 331 | */ | |
| 332 | private void closeRemainingTab() { | |
| 333 | getPreviewPane().clear(); | |
| 334 | getDefinitionPane().clear(); | |
| 335 | } | |
| 336 | ||
| 337 | //---- File actions ------------------------------------------------------- | |
| 338 | private void fileNew() { | |
| 339 | getFileEditorPane().newEditor(); | |
| 340 | } | |
| 341 | ||
| 342 | private void fileOpen() { | |
| 343 | getFileEditorPane().openFileDialog(); | |
| 344 | } | |
| 345 | ||
| 346 | private void fileClose() { | |
| 347 | getFileEditorPane().closeEditor( getActiveFileEditor(), true ); | |
| 348 | } | |
| 349 | ||
| 350 | private void fileCloseAll() { | |
| 351 | getFileEditorPane().closeAllEditors(); | |
| 352 | } | |
| 353 | ||
| 354 | private void fileSave() { | |
| 355 | getFileEditorPane().saveEditor( getActiveFileEditor() ); | |
| 356 | } | |
| 357 | ||
| 358 | private void fileSaveAll() { | |
| 359 | getFileEditorPane().saveAllEditors(); | |
| 360 | } | |
| 361 | ||
| 362 | private void fileExit() { | |
| 363 | final Window window = getWindow(); | |
| 364 | Event.fireEvent( window, | |
| 365 | new WindowEvent( window, WindowEvent.WINDOW_CLOSE_REQUEST ) ); | |
| 366 | } | |
| 367 | ||
| 368 | //---- Help actions ------------------------------------------------------- | |
| 369 | private void helpAbout() { | |
| 370 | Alert alert = new Alert( AlertType.INFORMATION ); | |
| 371 | alert.setTitle( get( "Dialog.about.title" ) ); | |
| 372 | alert.setHeaderText( get( "Dialog.about.header" ) ); | |
| 373 | alert.setContentText( get( "Dialog.about.content" ) ); | |
| 374 | alert.setGraphic( new ImageView( new Image( FILE_LOGO_32 ) ) ); | |
| 375 | alert.initOwner( getWindow() ); | |
| 376 | ||
| 377 | alert.showAndWait(); | |
| 378 | } | |
| 379 | ||
| 380 | //---- Convenience accessors ---------------------------------------------- | |
| 381 | private float getFloat( final String key, final float defaultValue ) { | |
| 382 | return getPreferences().getFloat( key, defaultValue ); | |
| 383 | } | |
| 384 | ||
| 385 | private Preferences getPreferences() { | |
| 386 | return getOptions().getState(); | |
| 387 | } | |
| 388 | ||
| 389 | private Window getWindow() { | |
| 390 | return getScene().getWindow(); | |
| 391 | } | |
| 392 | ||
| 393 | private MarkdownEditorPane getActiveEditor() { | |
| 394 | return (MarkdownEditorPane)(getActiveFileEditor().getEditorPane()); | |
| 395 | } | |
| 396 | ||
| 397 | private FileEditorTab getActiveFileEditor() { | |
| 398 | return getFileEditorPane().getActiveFileEditor(); | |
| 399 | } | |
| 400 | ||
| 401 | //---- Member accessors --------------------------------------------------- | |
| 402 | public Scene getScene() { | |
| 403 | return this.scene; | |
| 404 | } | |
| 405 | ||
| 406 | private void setScene( Scene scene ) { | |
| 407 | this.scene = scene; | |
| 408 | } | |
| 409 | ||
| 410 | private FileEditorTabPane getFileEditorPane() { | |
| 411 | if( this.fileEditorPane == null ) { | |
| 412 | this.fileEditorPane = createFileEditorPane(); | |
| 413 | } | |
| 414 | ||
| 415 | return this.fileEditorPane; | |
| 416 | } | |
| 417 | ||
| 418 | private synchronized HTMLPreviewPane getPreviewPane() { | |
| 419 | if( this.previewPane == null ) { | |
| 420 | this.previewPane = createPreviewPane(); | |
| 421 | } | |
| 422 | ||
| 423 | return this.previewPane; | |
| 424 | } | |
| 425 | ||
| 426 | private DefinitionPane getDefinitionPane() { | |
| 427 | if( this.definitionPane == null ) { | |
| 428 | this.definitionPane = createDefinitionPane(); | |
| 429 | } | |
| 430 | ||
| 431 | return this.definitionPane; | |
| 432 | } | |
| 433 | ||
| 434 | public VariableNameInjector getVariableNameInjector() { | |
| 435 | return this.variableNameInjector; | |
| 436 | } | |
| 437 | ||
| 438 | public void setVariableNameInjector( final VariableNameInjector injector ) { | |
| 439 | this.variableNameInjector = injector; | |
| 440 | } | |
| 441 | ||
| 442 | private Options getOptions() { | |
| 443 | return this.options; | |
| 444 | } | |
| 445 | ||
| 446 | public MenuBar getMenuBar() { | |
| 447 | return this.menuBar; | |
| 448 | } | |
| 449 | ||
| 450 | public void setMenuBar( MenuBar menuBar ) { | |
| 451 | this.menuBar = menuBar; | |
| 452 | } | |
| 453 | ||
| 454 | //---- Member creators ---------------------------------------------------- | |
| 455 | private DefinitionSource createDefinitionSource( final Path path ) { | |
| 456 | return createDefinitionFactory().fileDefinitionSource( path ); | |
| 457 | } | |
| 458 | ||
| 459 | /** | |
| 460 | * Create an editor pane to hold file editor tabs. | |
| 461 | * | |
| 462 | * @return A new instance, never null. | |
| 463 | */ | |
| 464 | private FileEditorTabPane createFileEditorPane() { | |
| 465 | return new FileEditorTabPane(); | |
| 466 | } | |
| 467 | ||
| 468 | private HTMLPreviewPane createPreviewPane() { | |
| 469 | return new HTMLPreviewPane(); | |
| 470 | } | |
| 471 | ||
| 472 | protected DefinitionPane createDefinitionPane() { | |
| 473 | return new DefinitionPane( getTreeView() ); | |
| 474 | } | |
| 475 | ||
| 476 | private DefinitionFactory createDefinitionFactory() { | |
| 477 | return new DefinitionFactory(); | |
| 478 | } | |
| 479 | ||
| 480 | private Node createMenuBar() { | |
| 481 | final BooleanBinding activeFileEditorIsNull = getFileEditorPane().activeFileEditorProperty().isNull(); | |
| 482 | ||
| 483 | // File actions | |
| 484 | Action fileNewAction = new Action( get( "Main.menu.file.new" ), "Shortcut+N", FILE_ALT, e -> fileNew() ); | |
| 485 | Action fileOpenAction = new Action( get( "Main.menu.file.open" ), "Shortcut+O", FOLDER_OPEN_ALT, e -> fileOpen() ); | |
| 486 | Action fileCloseAction = new Action( get( "Main.menu.file.close" ), "Shortcut+W", null, e -> fileClose(), activeFileEditorIsNull ); | |
| 487 | Action fileCloseAllAction = new Action( get( "Main.menu.file.close_all" ), null, null, e -> fileCloseAll(), activeFileEditorIsNull ); | |
| 488 | Action fileSaveAction = new Action( get( "Main.menu.file.save" ), "Shortcut+S", FLOPPY_ALT, e -> fileSave(), | |
| 489 | createActiveBooleanProperty( FileEditorTab::modifiedProperty ).not() ); | |
| 490 | Action fileSaveAllAction = new Action( get( "Main.menu.file.save_all" ), "Shortcut+Shift+S", null, e -> fileSaveAll(), | |
| 491 | Bindings.not( getFileEditorPane().anyFileEditorModifiedProperty() ) ); | |
| 492 | Action fileExitAction = new Action( get( "Main.menu.file.exit" ), null, null, e -> fileExit() ); | |
| 493 | ||
| 494 | // Edit actions | |
| 495 | Action editUndoAction = new Action( get( "Main.menu.edit.undo" ), "Shortcut+Z", UNDO, | |
| 496 | e -> getActiveEditor().undo(), | |
| 497 | createActiveBooleanProperty( FileEditorTab::canUndoProperty ).not() ); | |
| 498 | Action editRedoAction = new Action( get( "Main.menu.edit.redo" ), "Shortcut+Y", REPEAT, | |
| 499 | e -> getActiveEditor().redo(), | |
| 500 | createActiveBooleanProperty( FileEditorTab::canRedoProperty ).not() ); | |
| 501 | ||
| 502 | // Insert actions | |
| 503 | Action insertBoldAction = new Action( get( "Main.menu.insert.bold" ), "Shortcut+B", BOLD, | |
| 504 | e -> getActiveEditor().surroundSelection( "**", "**" ), | |
| 505 | activeFileEditorIsNull ); | |
| 506 | Action insertItalicAction = new Action( get( "Main.menu.insert.italic" ), "Shortcut+I", ITALIC, | |
| 507 | e -> getActiveEditor().surroundSelection( "*", "*" ), | |
| 508 | activeFileEditorIsNull ); | |
| 509 | Action insertStrikethroughAction = new Action( get( "Main.menu.insert.strikethrough" ), "Shortcut+T", STRIKETHROUGH, | |
| 510 | e -> getActiveEditor().surroundSelection( "~~", "~~" ), | |
| 511 | activeFileEditorIsNull ); | |
| 512 | Action insertBlockquoteAction = new Action( get( "Main.menu.insert.blockquote" ), "Ctrl+Q", QUOTE_LEFT, // not Shortcut+Q because of conflict on Mac | |
| 513 | e -> getActiveEditor().surroundSelection( "\n\n> ", "" ), | |
| 514 | activeFileEditorIsNull ); | |
| 515 | Action insertCodeAction = new Action( get( "Main.menu.insert.code" ), "Shortcut+K", CODE, | |
| 516 | e -> getActiveEditor().surroundSelection( "`", "`" ), | |
| 517 | activeFileEditorIsNull ); | |
| 518 | Action insertFencedCodeBlockAction = new Action( get( "Main.menu.insert.fenced_code_block" ), "Shortcut+Shift+K", FILE_CODE_ALT, | |
| 519 | e -> getActiveEditor().surroundSelection( "\n\n```\n", "\n```\n\n", get( "Main.menu.insert.fenced_code_block.prompt" ) ), | |
| 520 | activeFileEditorIsNull ); | |
| 521 | ||
| 522 | Action insertLinkAction = new Action( get( "Main.menu.insert.link" ), "Shortcut+L", LINK, | |
| 523 | e -> getActiveEditor().insertLink(), | |
| 524 | activeFileEditorIsNull ); | |
| 525 | Action insertImageAction = new Action( get( "Main.menu.insert.image" ), "Shortcut+G", PICTURE_ALT, | |
| 526 | e -> getActiveEditor().insertImage(), | |
| 527 | activeFileEditorIsNull ); | |
| 528 | ||
| 529 | final Action[] headers = new Action[ 6 ]; | |
| 530 | ||
| 531 | // Insert header actions (H1 ... H6) | |
| 532 | for( int i = 1; i <= 6; i++ ) { | |
| 533 | final String hashes = new String( new char[ i ] ).replace( "\0", "#" ); | |
| 534 | final String markup = String.format( "\n\n%s ", hashes ); | |
| 535 | final String text = get( "Main.menu.insert.header_" + i ); | |
| 536 | final String accelerator = "Shortcut+" + i; | |
| 537 | final String prompt = get( "Main.menu.insert.header_" + i + ".prompt" ); | |
| 538 | ||
| 539 | headers[ i - 1 ] = new Action( text, accelerator, HEADER, | |
| 540 | e -> getActiveEditor().surroundSelection( markup, "", prompt ), | |
| 541 | activeFileEditorIsNull ); | |
| 542 | } | |
| 543 | ||
| 544 | Action insertUnorderedListAction = new Action( get( "Main.menu.insert.unordered_list" ), "Shortcut+U", LIST_UL, | |
| 545 | e -> getActiveEditor().surroundSelection( "\n\n* ", "" ), | |
| 546 | activeFileEditorIsNull ); | |
| 547 | Action insertOrderedListAction = new Action( get( "Main.menu.insert.ordered_list" ), "Shortcut+Shift+O", LIST_OL, | |
| 548 | e -> getActiveEditor().surroundSelection( "\n\n1. ", "" ), | |
| 549 | activeFileEditorIsNull ); | |
| 550 | Action insertHorizontalRuleAction = new Action( get( "Main.menu.insert.horizontal_rule" ), "Shortcut+H", null, | |
| 551 | e -> getActiveEditor().surroundSelection( "\n\n---\n\n", "" ), | |
| 552 | activeFileEditorIsNull ); | |
| 553 | ||
| 554 | // Help actions | |
| 555 | Action helpAboutAction = new Action( get( "Main.menu.help.about" ), null, null, e -> helpAbout() ); | |
| 556 | ||
| 557 | //---- MenuBar ---- | |
| 558 | Menu fileMenu = ActionUtils.createMenu( get( "Main.menu.file" ), | |
| 559 | fileNewAction, | |
| 560 | fileOpenAction, | |
| 561 | null, | |
| 562 | fileCloseAction, | |
| 563 | fileCloseAllAction, | |
| 564 | null, | |
| 565 | fileSaveAction, | |
| 566 | fileSaveAllAction, | |
| 567 | null, | |
| 568 | fileExitAction ); | |
| 569 | ||
| 570 | Menu editMenu = ActionUtils.createMenu( get( "Main.menu.edit" ), | |
| 571 | editUndoAction, | |
| 572 | editRedoAction ); | |
| 573 | ||
| 574 | Menu insertMenu = ActionUtils.createMenu( get( "Main.menu.insert" ), | |
| 575 | insertBoldAction, | |
| 576 | insertItalicAction, | |
| 577 | insertStrikethroughAction, | |
| 578 | insertBlockquoteAction, | |
| 579 | insertCodeAction, | |
| 580 | insertFencedCodeBlockAction, | |
| 581 | null, | |
| 582 | insertLinkAction, | |
| 583 | insertImageAction, | |
| 584 | null, | |
| 585 | headers[ 0 ], | |
| 586 | headers[ 1 ], | |
| 587 | headers[ 2 ], | |
| 588 | headers[ 3 ], | |
| 589 | headers[ 4 ], | |
| 590 | headers[ 5 ], | |
| 591 | null, | |
| 592 | insertUnorderedListAction, | |
| 593 | insertOrderedListAction, | |
| 594 | insertHorizontalRuleAction ); | |
| 595 | ||
| 596 | Menu helpMenu = ActionUtils.createMenu( get( "Main.menu.help" ), | |
| 597 | helpAboutAction ); | |
| 598 | ||
| 599 | menuBar = new MenuBar( fileMenu, editMenu, insertMenu, helpMenu ); | |
| 600 | ||
| 601 | //---- ToolBar ---- | |
| 602 | ToolBar toolBar = ActionUtils.createToolBar( | |
| 603 | fileNewAction, | |
| 604 | fileOpenAction, | |
| 605 | fileSaveAction, | |
| 606 | null, | |
| 607 | editUndoAction, | |
| 608 | editRedoAction, | |
| 609 | null, | |
| 610 | insertBoldAction, | |
| 611 | insertItalicAction, | |
| 612 | insertBlockquoteAction, | |
| 613 | insertCodeAction, | |
| 614 | insertFencedCodeBlockAction, | |
| 615 | null, | |
| 616 | insertLinkAction, | |
| 617 | insertImageAction, | |
| 618 | null, | |
| 619 | headers[ 0 ], | |
| 620 | null, | |
| 621 | insertUnorderedListAction, | |
| 622 | insertOrderedListAction ); | |
| 623 | ||
| 624 | return new VBox( menuBar, toolBar ); | |
| 625 | } | |
| 626 | ||
| 627 | private void initLayout() { | |
| 628 | final SplitPane splitPane = new SplitPane( | |
| 629 | getDefinitionPane().getNode(), | |
| 630 | getFileEditorPane().getNode(), | |
| 631 | getPreviewPane().getNode() ); | |
| 632 | ||
| 633 | splitPane.setDividerPositions( | |
| 634 | getFloat( K_PANE_SPLIT_DEFINITION, .10f ), | |
| 635 | getFloat( K_PANE_SPLIT_EDITOR, .45f ), | |
| 636 | getFloat( K_PANE_SPLIT_PREVIEW, .45f ) ); | |
| 637 | ||
| 638 | // See: http://broadlyapplicable.blogspot.ca/2015/03/javafx-capture-restorePreferences-splitpane.html | |
| 639 | final BorderPane borderPane = new BorderPane(); | |
| 640 | borderPane.setPrefSize( 1024, 800 ); | |
| 641 | borderPane.setTop( createMenuBar() ); | |
| 642 | borderPane.setCenter( splitPane ); | |
| 643 | ||
| 644 | final Scene appScene = new Scene( borderPane ); | |
| 645 | setScene( appScene ); | |
| 646 | appScene.getStylesheets().add( STYLESHEET_SCENE ); | |
| 647 | appScene.windowProperty().addListener( | |
| 648 | (observable, oldWindow, newWindow) -> { | |
| 649 | newWindow.setOnCloseRequest( e -> { | |
| 650 | if( !getFileEditorPane().closeAllEditors() ) { | |
| 651 | e.consume(); | |
| 652 | } | |
| 653 | } ); | |
| 654 | ||
| 655 | // Workaround JavaFX bug: deselect menubar if window loses focus. | |
| 656 | newWindow.focusedProperty().addListener( | |
| 657 | (obs, oldFocused, newFocused) -> { | |
| 658 | if( !newFocused ) { | |
| 659 | // Send an ESC key event to the menubar | |
| 660 | this.menuBar.fireEvent( | |
| 661 | new KeyEvent( | |
| 662 | KEY_PRESSED, CHAR_UNDEFINED, "", ESCAPE, | |
| 663 | false, false, false, false ) ); | |
| 664 | } | |
| 665 | } | |
| 666 | ); | |
| 667 | } | |
| 668 | ); | |
| 669 | } | |
| 623 | 670 | } |
| 624 | 671 |
| 27 | 27 | package com.scrivenvar; |
| 28 | 28 | |
| 29 | import static com.scrivenvar.Constants.BUNDLE_NAME; | |
| 30 | 29 | import java.text.MessageFormat; |
| 31 | 30 | import java.util.ResourceBundle; |
| 32 | 31 | import java.util.Stack; |
| 32 | import static com.scrivenvar.Constants.APP_BUNDLE_NAME; | |
| 33 | 33 | |
| 34 | 34 | /** |
| 35 | 35 | * Recursively resolves message properties. Property values can refer to other |
| 36 | 36 | * properties using a <code>${var}</code> syntax. |
| 37 | 37 | * |
| 38 | 38 | * @author Karl Tauber, Dave Jarvis |
| 39 | 39 | */ |
| 40 | 40 | public class Messages { |
| 41 | 41 | |
| 42 | private static final ResourceBundle RESOURCE_BUNDLE = ResourceBundle.getBundle( BUNDLE_NAME ); | |
| 42 | private static final ResourceBundle RESOURCE_BUNDLE = ResourceBundle.getBundle(APP_BUNDLE_NAME ); | |
| 43 | 43 | |
| 44 | 44 | private Messages() { |
| 1 | /* | |
| 2 | * Copyright 2016 White Magic Software, Ltd. | |
| 3 | * | |
| 4 | * All rights reserved. | |
| 5 | * | |
| 6 | * Redistribution and use in source and binary forms, with or without | |
| 7 | * modification, are permitted provided that the following conditions are met: | |
| 8 | * | |
| 9 | * o Redistributions of source code must retain the above copyright | |
| 10 | * notice, this list of conditions and the following disclaimer. | |
| 11 | * | |
| 12 | * o Redistributions in binary form must reproduce the above copyright | |
| 13 | * notice, this list of conditions and the following disclaimer in the | |
| 14 | * documentation and/or other materials provided with the distribution. | |
| 15 | * | |
| 16 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | |
| 17 | * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | |
| 18 | * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | |
| 19 | * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT | |
| 20 | * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | |
| 21 | * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT | |
| 22 | * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | |
| 23 | * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | |
| 24 | * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | |
| 25 | * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | |
| 26 | * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | |
| 27 | */ | |
| 28 | package com.scrivenvar; | |
| 29 | ||
| 30 | import com.scrivenvar.definition.DefinitionPane; | |
| 31 | import static javafx.application.Application.launch; | |
| 32 | import javafx.scene.control.TreeItem; | |
| 33 | import javafx.scene.control.TreeView; | |
| 34 | import javafx.stage.Stage; | |
| 35 | ||
| 36 | /** | |
| 37 | * TestDefinitionPane application for debugging. | |
| 38 | */ | |
| 39 | public final class TestDefinitionPane extends TestHarness { | |
| 40 | /** | |
| 41 | * Application entry point. | |
| 42 | * | |
| 43 | * @param stage The primary application stage. | |
| 44 | * | |
| 45 | * @throws Exception Could not read configuration file. | |
| 46 | */ | |
| 47 | @Override | |
| 48 | public void start( final Stage stage ) throws Exception { | |
| 49 | super.start( stage ); | |
| 50 | ||
| 51 | TreeView<String> root = createTreeView(); | |
| 52 | DefinitionPane pane = createDefinitionPane( root ); | |
| 53 | ||
| 54 | test( pane, "language.ai.", "article" ); | |
| 55 | test( pane, "language.ai", "ai" ); | |
| 56 | test( pane, "l", "location" ); | |
| 57 | test( pane, "la", "language" ); | |
| 58 | test( pane, "c.p.n", "name" ); | |
| 59 | test( pane, "c.p.n.", "First" ); | |
| 60 | test( pane, "...", "c" ); | |
| 61 | test( pane, "foo", "c" ); | |
| 62 | test( pane, "foo.bar", "c" ); | |
| 63 | test( pane, "", "c" ); | |
| 64 | test( pane, "c", "protagonist" ); | |
| 65 | test( pane, "c.", "protagonist" ); | |
| 66 | test( pane, "c.p", "protagonist" ); | |
| 67 | test( pane, "c.protagonist", "protagonist" ); | |
| 68 | ||
| 69 | System.exit( 0 ); | |
| 70 | } | |
| 71 | ||
| 72 | private void test( DefinitionPane pane, String path, String value ) { | |
| 73 | System.out.println( "---------------------------" ); | |
| 74 | System.out.println( "Find Path: '" + path + "'" ); | |
| 75 | final TreeItem<String> node = pane.findNode( path ); | |
| 76 | System.out.println( "Path Node: " + node ); | |
| 77 | System.out.println( "Node Val : " + node.getValue() ); | |
| 78 | } | |
| 79 | ||
| 80 | public static void main( String[] args ) { | |
| 81 | launch( args ); | |
| 82 | } | |
| 83 | } | |
| 84 | 1 |
| 1 | /* | |
| 2 | * Copyright 2016 White Magic Software, Ltd. | |
| 3 | * | |
| 4 | * All rights reserved. | |
| 5 | * | |
| 6 | * Redistribution and use in source and binary forms, with or without | |
| 7 | * modification, are permitted provided that the following conditions are met: | |
| 8 | * | |
| 9 | * o Redistributions of source code must retain the above copyright | |
| 10 | * notice, this list of conditions and the following disclaimer. | |
| 11 | * | |
| 12 | * o Redistributions in binary form must reproduce the above copyright | |
| 13 | * notice, this list of conditions and the following disclaimer in the | |
| 14 | * documentation and/or other materials provided with the distribution. | |
| 15 | * | |
| 16 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | |
| 17 | * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | |
| 18 | * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | |
| 19 | * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT | |
| 20 | * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | |
| 21 | * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT | |
| 22 | * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | |
| 23 | * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | |
| 24 | * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | |
| 25 | * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | |
| 26 | * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | |
| 27 | */ | |
| 28 | package com.scrivenvar; | |
| 29 | ||
| 30 | import static com.scrivenvar.Messages.get; | |
| 31 | import com.scrivenvar.definition.DefinitionPane; | |
| 32 | import com.scrivenvar.yaml.YamlParser; | |
| 33 | import com.scrivenvar.yaml.YamlTreeAdapter; | |
| 34 | import java.io.IOException; | |
| 35 | import java.io.InputStream; | |
| 36 | import javafx.application.Application; | |
| 37 | import javafx.scene.Scene; | |
| 38 | import javafx.scene.control.TreeView; | |
| 39 | import javafx.scene.layout.BorderPane; | |
| 40 | import javafx.stage.Stage; | |
| 41 | import org.fxmisc.flowless.VirtualizedScrollPane; | |
| 42 | import org.fxmisc.richtext.StyleClassedTextArea; | |
| 43 | ||
| 44 | /** | |
| 45 | * TestDefinitionPane application for debugging and head-banging. | |
| 46 | */ | |
| 47 | public abstract class TestHarness extends Application { | |
| 48 | ||
| 49 | private static Application app; | |
| 50 | private Scene scene; | |
| 51 | ||
| 52 | /** | |
| 53 | * Application entry point. | |
| 54 | * | |
| 55 | * @param stage The primary application stage. | |
| 56 | * | |
| 57 | * @throws Exception Could not read configuration file. | |
| 58 | */ | |
| 59 | @Override | |
| 60 | public void start( final Stage stage ) throws Exception { | |
| 61 | initApplication(); | |
| 62 | initScene(); | |
| 63 | initStage( stage ); | |
| 64 | } | |
| 65 | ||
| 66 | protected TreeView<String> createTreeView() throws IOException { | |
| 67 | return new YamlTreeAdapter( new YamlParser() ).adapt( | |
| 68 | asStream( "/com/scrivenvar/variables.yaml" ), | |
| 69 | get( "Pane.defintion.node.root.title" ) | |
| 70 | ); | |
| 71 | } | |
| 72 | ||
| 73 | protected DefinitionPane createDefinitionPane( TreeView<String> root ) { | |
| 74 | return new DefinitionPane( root ); | |
| 75 | } | |
| 76 | ||
| 77 | private void initApplication() { | |
| 78 | app = this; | |
| 79 | } | |
| 80 | ||
| 81 | private void initScene() { | |
| 82 | final StyleClassedTextArea editor = new StyleClassedTextArea( false ); | |
| 83 | final VirtualizedScrollPane<StyleClassedTextArea> scrollPane = new VirtualizedScrollPane<>( editor ); | |
| 84 | ||
| 85 | final BorderPane borderPane = new BorderPane(); | |
| 86 | borderPane.setPrefSize( 1024, 800 ); | |
| 87 | borderPane.setCenter( scrollPane ); | |
| 88 | ||
| 89 | setScene( new Scene( borderPane ) ); | |
| 90 | } | |
| 91 | ||
| 92 | private void initStage( Stage stage ) { | |
| 93 | stage.setScene( getScene() ); | |
| 94 | } | |
| 95 | ||
| 96 | private Scene getScene() { | |
| 97 | return this.scene; | |
| 98 | } | |
| 99 | ||
| 100 | private void setScene( Scene scene ) { | |
| 101 | this.scene = scene; | |
| 102 | } | |
| 103 | ||
| 104 | private static Application getApplication() { | |
| 105 | return app; | |
| 106 | } | |
| 107 | ||
| 108 | public static void showDocument( String uri ) { | |
| 109 | getApplication().getHostServices().showDocument( uri ); | |
| 110 | } | |
| 111 | ||
| 112 | protected InputStream asStream( String resource ) { | |
| 113 | return getClass().getResourceAsStream( resource ); | |
| 114 | } | |
| 115 | } | |
| 116 | 1 |
| 1 | /* | |
| 2 | * Copyright 2016 White Magic Software, Ltd. | |
| 3 | * | |
| 4 | * All rights reserved. | |
| 5 | * | |
| 6 | * Redistribution and use in source and binary forms, with or without | |
| 7 | * modification, are permitted provided that the following conditions are met: | |
| 8 | * | |
| 9 | * o Redistributions of source code must retain the above copyright | |
| 10 | * notice, this list of conditions and the following disclaimer. | |
| 11 | * | |
| 12 | * o Redistributions in binary form must reproduce the above copyright | |
| 13 | * notice, this list of conditions and the following disclaimer in the | |
| 14 | * documentation and/or other materials provided with the distribution. | |
| 15 | * | |
| 16 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | |
| 17 | * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | |
| 18 | * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | |
| 19 | * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT | |
| 20 | * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | |
| 21 | * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT | |
| 22 | * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | |
| 23 | * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | |
| 24 | * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | |
| 25 | * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | |
| 26 | * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | |
| 27 | */ | |
| 28 | package com.scrivenvar; | |
| 29 | ||
| 30 | import com.scrivenvar.ui.VariableTreeItem; | |
| 31 | import java.util.Collection; | |
| 32 | import java.util.HashMap; | |
| 33 | import java.util.Map; | |
| 34 | import static java.util.concurrent.ThreadLocalRandom.current; | |
| 35 | import java.util.concurrent.TimeUnit; | |
| 36 | import static java.util.concurrent.TimeUnit.DAYS; | |
| 37 | import static java.util.concurrent.TimeUnit.HOURS; | |
| 38 | import static java.util.concurrent.TimeUnit.MILLISECONDS; | |
| 39 | import static java.util.concurrent.TimeUnit.MINUTES; | |
| 40 | import static java.util.concurrent.TimeUnit.NANOSECONDS; | |
| 41 | import static java.util.concurrent.TimeUnit.SECONDS; | |
| 42 | import static javafx.application.Application.launch; | |
| 43 | import javafx.scene.control.TreeItem; | |
| 44 | import javafx.scene.control.TreeView; | |
| 45 | import javafx.stage.Stage; | |
| 46 | import org.ahocorasick.trie.*; | |
| 47 | import org.ahocorasick.trie.Trie.TrieBuilder; | |
| 48 | import static org.apache.commons.lang.RandomStringUtils.randomNumeric; | |
| 49 | import org.apache.commons.lang.StringUtils; | |
| 50 | ||
| 51 | /** | |
| 52 | * Tests substituting variable definitions with their values in a swath of text. | |
| 53 | * | |
| 54 | * @author White Magic Software, Ltd. | |
| 55 | */ | |
| 56 | public class TestVariableNameProcessor extends TestHarness { | |
| 57 | ||
| 58 | private final static int TEXT_SIZE = 1000000; | |
| 59 | private final static int MATCHES_DIVISOR = 1000; | |
| 60 | ||
| 61 | private final static StringBuilder SOURCE | |
| 62 | = new StringBuilder( randomNumeric( TEXT_SIZE ) ); | |
| 63 | ||
| 64 | private final static boolean DEBUG = false; | |
| 65 | ||
| 66 | public TestVariableNameProcessor() { | |
| 67 | } | |
| 68 | ||
| 69 | @Override | |
| 70 | public void start( final Stage stage ) throws Exception { | |
| 71 | super.start( stage ); | |
| 72 | ||
| 73 | final TreeView<String> treeView = createTreeView(); | |
| 74 | final Map<String, String> definitions = new HashMap<>(); | |
| 75 | ||
| 76 | populate( treeView.getRoot(), definitions ); | |
| 77 | injectVariables( definitions ); | |
| 78 | ||
| 79 | final String text = SOURCE.toString(); | |
| 80 | ||
| 81 | show( text ); | |
| 82 | ||
| 83 | long duration = System.nanoTime(); | |
| 84 | ||
| 85 | // TODO: Test replaceEach (with intercoluated variables) and replaceEachRepeatedly | |
| 86 | // (without intercoluation). | |
| 87 | final String result = testBorAhoCorasick( text, definitions ); | |
| 88 | ||
| 89 | duration = System.nanoTime() - duration; | |
| 90 | ||
| 91 | show( result ); | |
| 92 | System.out.println( elapsed( duration ) ); | |
| 93 | ||
| 94 | System.exit( 0 ); | |
| 95 | } | |
| 96 | ||
| 97 | private void show( final String s ) { | |
| 98 | if( DEBUG ) { | |
| 99 | System.out.printf( "%s\n\n", s ); | |
| 100 | } | |
| 101 | } | |
| 102 | ||
| 103 | private String testBorAhoCorasick( | |
| 104 | final String text, | |
| 105 | final Map<String, String> definitions ) { | |
| 106 | // Create a buffer sufficiently large that re-allocations are minimized. | |
| 107 | final StringBuilder sb = new StringBuilder( text.length() << 1 ); | |
| 108 | ||
| 109 | final TrieBuilder builder = Trie.builder(); | |
| 110 | builder.onlyWholeWords(); | |
| 111 | builder.removeOverlaps(); | |
| 112 | ||
| 113 | final String[] keys = keys( definitions ); | |
| 114 | ||
| 115 | for( final String key : keys ) { | |
| 116 | builder.addKeyword( key ); | |
| 117 | } | |
| 118 | ||
| 119 | final Trie trie = builder.build(); | |
| 120 | final Collection<Emit> emits = trie.parseText( text ); | |
| 121 | ||
| 122 | int prevIndex = 0; | |
| 123 | ||
| 124 | for( final Emit emit : emits ) { | |
| 125 | final int matchIndex = emit.getStart(); | |
| 126 | ||
| 127 | sb.append( text.substring( prevIndex, matchIndex ) ); | |
| 128 | sb.append( definitions.get( emit.getKeyword() ) ); | |
| 129 | prevIndex = emit.getEnd() + 1; | |
| 130 | } | |
| 131 | ||
| 132 | // Add the remainder of the string (contains no more matches). | |
| 133 | sb.append( text.substring( prevIndex ) ); | |
| 134 | ||
| 135 | return sb.toString(); | |
| 136 | } | |
| 137 | ||
| 138 | private String testStringUtils( | |
| 139 | final String text, final Map<String, String> definitions ) { | |
| 140 | final String[] keys = keys( definitions ); | |
| 141 | final String[] values = values( definitions ); | |
| 142 | ||
| 143 | return StringUtils.replaceEach( text, keys, values ); | |
| 144 | } | |
| 145 | ||
| 146 | private String[] keys( final Map<String, String> definitions ) { | |
| 147 | final int size = definitions.size(); | |
| 148 | return definitions.keySet().toArray( new String[ size ] ); | |
| 149 | } | |
| 150 | ||
| 151 | private String[] values( final Map<String, String> definitions ) { | |
| 152 | final int size = definitions.size(); | |
| 153 | return definitions.values().toArray( new String[ size ] ); | |
| 154 | } | |
| 155 | ||
| 156 | /** | |
| 157 | * Decomposes a period of time into days, hours, minutes, seconds, | |
| 158 | * milliseconds, and nanoseconds. | |
| 159 | * | |
| 160 | * @param duration Time in nanoseconds. | |
| 161 | * | |
| 162 | * @return A non-null, comma-separated string (without newline). | |
| 163 | */ | |
| 164 | public String elapsed( long duration ) { | |
| 165 | final TimeUnit scale = NANOSECONDS; | |
| 166 | ||
| 167 | long days = scale.toDays( duration ); | |
| 168 | duration -= DAYS.toMillis( days ); | |
| 169 | long hours = scale.toHours( duration ); | |
| 170 | duration -= HOURS.toMillis( hours ); | |
| 171 | long minutes = scale.toMinutes( duration ); | |
| 172 | duration -= MINUTES.toMillis( minutes ); | |
| 173 | long seconds = scale.toSeconds( duration ); | |
| 174 | duration -= SECONDS.toMillis( seconds ); | |
| 175 | long millis = scale.toMillis( duration ); | |
| 176 | duration -= MILLISECONDS.toMillis( seconds ); | |
| 177 | long nanos = scale.toNanos( duration ); | |
| 178 | ||
| 179 | return String.format( | |
| 180 | "%d days, %d hours, %d minutes, %d seconds, %d millis, %d nanos", | |
| 181 | days, hours, minutes, seconds, millis, nanos | |
| 182 | ); | |
| 183 | } | |
| 184 | ||
| 185 | private void injectVariables( final Map<String, String> definitions ) { | |
| 186 | for( int i = (SOURCE.length() / MATCHES_DIVISOR) + 1; i > 0; i-- ) { | |
| 187 | final int r = current().nextInt( 1, SOURCE.length() ); | |
| 188 | SOURCE.insert( r, randomKey( definitions ) ); | |
| 189 | } | |
| 190 | } | |
| 191 | ||
| 192 | private String randomKey( final Map<String, String> map ) { | |
| 193 | final Object[] keys = map.keySet().toArray(); | |
| 194 | final int r = current().nextInt( keys.length ); | |
| 195 | return keys[ r ].toString(); | |
| 196 | } | |
| 197 | ||
| 198 | private void populate( final TreeItem<String> parent, final Map<String, String> map ) { | |
| 199 | for( final TreeItem<String> child : parent.getChildren() ) { | |
| 200 | if( child.isLeaf() ) { | |
| 201 | final String key = asDefinition( ((VariableTreeItem<String>)child).toPath() ); | |
| 202 | final String value = child.getValue(); | |
| 203 | ||
| 204 | map.put( key, value ); | |
| 205 | } else { | |
| 206 | populate( child, map ); | |
| 207 | } | |
| 208 | } | |
| 209 | } | |
| 210 | ||
| 211 | private String asDefinition( final String key ) { | |
| 212 | return "$" + key + "$"; | |
| 213 | } | |
| 214 | ||
| 215 | public static void main( String[] args ) { | |
| 216 | launch( args ); | |
| 217 | } | |
| 218 | } | |
| 219 | 1 |
| 1 | /* | |
| 2 | * Copyright 2016 White Magic Software, Ltd. | |
| 3 | * | |
| 4 | * All rights reserved. | |
| 5 | * | |
| 6 | * Redistribution and use in source and binary forms, with or without | |
| 7 | * modification, are permitted provided that the following conditions are met: | |
| 8 | * | |
| 9 | * o Redistributions of source code must retain the above copyright | |
| 10 | * notice, this list of conditions and the following disclaimer. | |
| 11 | * | |
| 12 | * o Redistributions in binary form must reproduce the above copyright | |
| 13 | * notice, this list of conditions and the following disclaimer in the | |
| 14 | * documentation and/or other materials provided with the distribution. | |
| 15 | * | |
| 16 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | |
| 17 | * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | |
| 18 | * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | |
| 19 | * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT | |
| 20 | * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | |
| 21 | * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT | |
| 22 | * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | |
| 23 | * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | |
| 24 | * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | |
| 25 | * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | |
| 26 | * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | |
| 27 | */ | |
| 28 | package com.scrivenvar.definition; | |
| 29 | ||
| 30 | /** | |
| 31 | * Implements common behaviour for definition sources. | |
| 32 | * | |
| 33 | * @author White Magic Software, Ltd. | |
| 34 | */ | |
| 35 | public abstract class AbstractDefinitionSource implements DefinitionSource { | |
| 36 | } | |
| 1 | 37 |
| 1 | /* | |
| 2 | * Copyright 2016 White Magic Software, Ltd. | |
| 3 | * | |
| 4 | * All rights reserved. | |
| 5 | * | |
| 6 | * Redistribution and use in source and binary forms, with or without | |
| 7 | * modification, are permitted provided that the following conditions are met: | |
| 8 | * | |
| 9 | * o Redistributions of source code must retain the above copyright | |
| 10 | * notice, this list of conditions and the following disclaimer. | |
| 11 | * | |
| 12 | * o Redistributions in binary form must reproduce the above copyright | |
| 13 | * notice, this list of conditions and the following disclaimer in the | |
| 14 | * documentation and/or other materials provided with the distribution. | |
| 15 | * | |
| 16 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | |
| 17 | * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | |
| 18 | * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | |
| 19 | * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT | |
| 20 | * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | |
| 21 | * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT | |
| 22 | * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | |
| 23 | * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | |
| 24 | * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | |
| 25 | * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | |
| 26 | * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | |
| 27 | */ | |
| 28 | package com.scrivenvar.definition; | |
| 29 | ||
| 30 | import com.scrivenvar.Services; | |
| 31 | import com.scrivenvar.definition.yaml.YamlFileDefinitionSource; | |
| 32 | import com.scrivenvar.predicates.files.FileTypePredicate; | |
| 33 | import com.scrivenvar.service.Settings; | |
| 34 | import java.nio.file.Path; | |
| 35 | import java.util.Iterator; | |
| 36 | import java.util.List; | |
| 37 | ||
| 38 | /** | |
| 39 | * Responsible for creating objects that can read and write definition data | |
| 40 | * sources. The data source could be YAML, TOML, JSON, flat files, or from a | |
| 41 | * database. | |
| 42 | * | |
| 43 | * @author White Magic Software, Ltd. | |
| 44 | */ | |
| 45 | public class DefinitionFactory { | |
| 46 | ||
| 47 | /** | |
| 48 | * Refers to filename extension settings in the configuration file. Do not | |
| 49 | * terminate this key prefix with a period. | |
| 50 | */ | |
| 51 | private static final String EXTENSIONS_PREFIX = "file.ext.definition"; | |
| 52 | ||
| 53 | private final Settings settings = Services.load( Settings.class ); | |
| 54 | ||
| 55 | /** | |
| 56 | * Default (empty) constructor. | |
| 57 | */ | |
| 58 | public DefinitionFactory() { | |
| 59 | } | |
| 60 | ||
| 61 | /** | |
| 62 | * Creates a definition source that can read and write files that match the | |
| 63 | * given file type (from the path). | |
| 64 | * | |
| 65 | * @param path Reference to a variable definition file. | |
| 66 | * | |
| 67 | * @return | |
| 68 | */ | |
| 69 | public DefinitionSource fileDefinitionSource( final Path path ) { | |
| 70 | final Settings properties = getSettings(); | |
| 71 | final Iterator<String> keys = properties.getKeys( EXTENSIONS_PREFIX ); | |
| 72 | ||
| 73 | DefinitionSource definitions = null; | |
| 74 | ||
| 75 | while( keys.hasNext() ) { | |
| 76 | final String key = keys.next(); | |
| 77 | final List<String> patterns = properties.getStringSettingList( key ); | |
| 78 | final FileTypePredicate predicate = new FileTypePredicate( patterns ); | |
| 79 | ||
| 80 | if( predicate.test( path.toFile() ) ) { | |
| 81 | final String filetype = key.replace( EXTENSIONS_PREFIX + ".", "" ); | |
| 82 | ||
| 83 | definitions = createFileDefinitionSource( filetype, path ); | |
| 84 | } | |
| 85 | } | |
| 86 | ||
| 87 | return definitions; | |
| 88 | } | |
| 89 | ||
| 90 | /** | |
| 91 | * Creates a definition source based on the file type. | |
| 92 | * | |
| 93 | * @param filetype Property key name suffix from settings.properties file. | |
| 94 | * @param path Path to the file that corresponds to the extension. | |
| 95 | * | |
| 96 | * @return A DefinitionSource capable of parsing the data stored at the path. | |
| 97 | */ | |
| 98 | private DefinitionSource createFileDefinitionSource( | |
| 99 | final String filetype, final Path path ) { | |
| 100 | final DefinitionSource result; | |
| 101 | ||
| 102 | switch( filetype ) { | |
| 103 | case "yaml": | |
| 104 | result = new YamlFileDefinitionSource( path ); | |
| 105 | break; | |
| 106 | ||
| 107 | default: | |
| 108 | result = new EmptyDefinitionSource(); | |
| 109 | break; | |
| 110 | } | |
| 111 | ||
| 112 | return result; | |
| 113 | } | |
| 114 | ||
| 115 | private Settings getSettings() { | |
| 116 | return this.settings; | |
| 117 | } | |
| 118 | } | |
| 1 | 119 |
| 28 | 28 | package com.scrivenvar.definition; |
| 29 | 29 | |
| 30 | import static com.scrivenvar.Constants.SEPARATOR; | |
| 31 | import static com.scrivenvar.definition.Lists.getFirst; | |
| 30 | import com.scrivenvar.AbstractPane; | |
| 31 | import static com.scrivenvar.definition.yaml.YamlParser.SEPARATOR; | |
| 32 | 32 | import com.scrivenvar.predicates.strings.ContainsPredicate; |
| 33 | 33 | import com.scrivenvar.predicates.strings.StartsPredicate; |
| 34 | 34 | import com.scrivenvar.predicates.strings.StringPredicate; |
| 35 | import com.scrivenvar.ui.AbstractPane; | |
| 36 | import com.scrivenvar.ui.VariableTreeItem; | |
| 35 | import static com.scrivenvar.util.Lists.getFirst; | |
| 37 | 36 | import java.util.List; |
| 38 | 37 | import javafx.collections.ObservableList; |
| ... | ||
| 50 | 49 | public class DefinitionPane extends AbstractPane { |
| 51 | 50 | |
| 51 | /** | |
| 52 | * Trimmed off the end of a word to match a variable name. | |
| 53 | */ | |
| 52 | 54 | private final static String TERMINALS = ":;,.!?-/\\¡¿"; |
| 53 | 55 | |
| ... | ||
| 63 | 65 | setTreeView( root ); |
| 64 | 66 | initTreeView(); |
| 67 | } | |
| 68 | ||
| 69 | public void clear() { | |
| 70 | getTreeView().setRoot( null ); | |
| 65 | 71 | } |
| 66 | 72 | |
| ... | ||
| 127 | 133 | * </ol> |
| 128 | 134 | * |
| 129 | * @param path The word typed by the user, which contains dot-separated node | |
| 135 | * @param word The word typed by the user, which contains dot-separated node | |
| 130 | 136 | * names that represent a path within the YAML tree plus a partial variable |
| 131 | 137 | * name match (for a node). |
| 132 | 138 | * |
| 133 | 139 | * @return The node value that starts with the suffix portion of the given |
| 134 | 140 | * path, never null. |
| 135 | 141 | */ |
| 136 | public TreeItem<String> findNode( String path ) { | |
| 142 | public TreeItem<String> findNode( final String word ) { | |
| 143 | String path = word; | |
| 144 | ||
| 137 | 145 | TreeItem<String> cItem = getTreeRoot(); |
| 138 | 146 | TreeItem<String> pItem = cItem; |
| 1 | /* | |
| 2 | * Copyright 2016 White Magic Software, Ltd. | |
| 3 | * | |
| 4 | * All rights reserved. | |
| 5 | * | |
| 6 | * Redistribution and use in source and binary forms, with or without | |
| 7 | * modification, are permitted provided that the following conditions are met: | |
| 8 | * | |
| 9 | * o Redistributions of source code must retain the above copyright | |
| 10 | * notice, this list of conditions and the following disclaimer. | |
| 11 | * | |
| 12 | * o Redistributions in binary form must reproduce the above copyright | |
| 13 | * notice, this list of conditions and the following disclaimer in the | |
| 14 | * documentation and/or other materials provided with the distribution. | |
| 15 | * | |
| 16 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | |
| 17 | * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | |
| 18 | * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | |
| 19 | * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT | |
| 20 | * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | |
| 21 | * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT | |
| 22 | * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | |
| 23 | * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | |
| 24 | * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | |
| 25 | * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | |
| 26 | * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | |
| 27 | */ | |
| 28 | package com.scrivenvar.definition; | |
| 29 | ||
| 30 | import java.io.IOException; | |
| 31 | import java.util.Map; | |
| 32 | import javafx.scene.control.TreeView; | |
| 33 | ||
| 34 | /** | |
| 35 | * Represents behaviours for reading and writing variable definitions. | |
| 36 | * | |
| 37 | * @author White Magic Software, Ltd. | |
| 38 | */ | |
| 39 | public interface DefinitionSource { | |
| 40 | ||
| 41 | /** | |
| 42 | * Creates a TreeView from this definition source. The definition source is | |
| 43 | * responsible for observing the TreeView instance for changes and persisting | |
| 44 | * them, if needed. | |
| 45 | * | |
| 46 | * @return A hierarchical tree suitable for displaying in the definition pane. | |
| 47 | * | |
| 48 | * @throws IOException Could not obtain the definition source data. | |
| 49 | */ | |
| 50 | public TreeView<String> asTreeView() throws IOException; | |
| 51 | ||
| 52 | /** | |
| 53 | * Returns all the strings with their values resolved in a flat hierarchy. | |
| 54 | * This copies all the keys and resolved values into a new map. | |
| 55 | * | |
| 56 | * @return The new map created with all values having been resolved, | |
| 57 | * recursively. | |
| 58 | */ | |
| 59 | public Map<String, String> getResolvedMap(); | |
| 60 | } | |
| 1 | 61 |
| 1 | /* | |
| 2 | * Copyright 2016 White Magic Software, Ltd. | |
| 3 | * | |
| 4 | * All rights reserved. | |
| 5 | * | |
| 6 | * Redistribution and use in source and binary forms, with or without | |
| 7 | * modification, are permitted provided that the following conditions are met: | |
| 8 | * | |
| 9 | * o Redistributions of source code must retain the above copyright | |
| 10 | * notice, this list of conditions and the following disclaimer. | |
| 11 | * | |
| 12 | * o Redistributions in binary form must reproduce the above copyright | |
| 13 | * notice, this list of conditions and the following disclaimer in the | |
| 14 | * documentation and/or other materials provided with the distribution. | |
| 15 | * | |
| 16 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | |
| 17 | * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | |
| 18 | * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | |
| 19 | * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT | |
| 20 | * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | |
| 21 | * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT | |
| 22 | * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | |
| 23 | * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | |
| 24 | * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | |
| 25 | * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | |
| 26 | * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | |
| 27 | */ | |
| 28 | package com.scrivenvar.definition; | |
| 29 | ||
| 30 | import java.io.IOException; | |
| 31 | import java.util.HashMap; | |
| 32 | import java.util.Map; | |
| 33 | import javafx.scene.control.TreeView; | |
| 34 | ||
| 35 | /** | |
| 36 | * Creates a definition source that has no information to load or save. | |
| 37 | * | |
| 38 | * @author White Magic Software, Ltd. | |
| 39 | */ | |
| 40 | public class EmptyDefinitionSource extends AbstractDefinitionSource { | |
| 41 | ||
| 42 | public EmptyDefinitionSource() { | |
| 43 | } | |
| 44 | ||
| 45 | @Override | |
| 46 | public TreeView<String> asTreeView() throws IOException { | |
| 47 | return new TreeView<>(); | |
| 48 | } | |
| 49 | ||
| 50 | @Override | |
| 51 | public Map<String, String> getResolvedMap() { | |
| 52 | return new HashMap<>(); | |
| 53 | } | |
| 54 | ||
| 55 | } | |
| 1 | 56 |
| 1 | /* | |
| 2 | * Copyright 2016 White Magic Software, Ltd. | |
| 3 | * | |
| 4 | * All rights reserved. | |
| 5 | * | |
| 6 | * Redistribution and use in source and binary forms, with or without | |
| 7 | * modification, are permitted provided that the following conditions are met: | |
| 8 | * | |
| 9 | * o Redistributions of source code must retain the above copyright | |
| 10 | * notice, this list of conditions and the following disclaimer. | |
| 11 | * | |
| 12 | * o Redistributions in binary form must reproduce the above copyright | |
| 13 | * notice, this list of conditions and the following disclaimer in the | |
| 14 | * documentation and/or other materials provided with the distribution. | |
| 15 | * | |
| 16 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | |
| 17 | * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | |
| 18 | * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | |
| 19 | * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT | |
| 20 | * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | |
| 21 | * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT | |
| 22 | * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | |
| 23 | * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | |
| 24 | * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | |
| 25 | * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | |
| 26 | * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | |
| 27 | */ | |
| 28 | package com.scrivenvar.definition; | |
| 29 | ||
| 30 | import java.util.List; | |
| 31 | ||
| 32 | /** | |
| 33 | * Convenience class that provides a clearer API for obtaining list elements. | |
| 34 | * | |
| 35 | * @author White Magic Software, Ltd. | |
| 36 | */ | |
| 37 | public final class Lists { | |
| 38 | ||
| 39 | private Lists() { | |
| 40 | } | |
| 41 | ||
| 42 | /** | |
| 43 | * Returns the first item in the given list, or null if not found. | |
| 44 | * | |
| 45 | * @param <T> The generic list type. | |
| 46 | * @param list The list that may have a first item. | |
| 47 | * | |
| 48 | * @return null if the list is null or there is no first item. | |
| 49 | */ | |
| 50 | public static <T> T getFirst( final List<T> list ) { | |
| 51 | return getFirst( list, null ); | |
| 52 | } | |
| 53 | ||
| 54 | /** | |
| 55 | * Returns the last item in the given list, or null if not found. | |
| 56 | * | |
| 57 | * @param <T> The generic list type. | |
| 58 | * @param list The list that may have a last item. | |
| 59 | * | |
| 60 | * @return null if the list is null or there is no last item. | |
| 61 | */ | |
| 62 | public static <T> T getLast( final List<T> list ) { | |
| 63 | return getLast( list, null ); | |
| 64 | } | |
| 65 | ||
| 66 | /** | |
| 67 | * Returns the first item in the given list, or t if not found. | |
| 68 | * | |
| 69 | * @param <T> The generic list type. | |
| 70 | * @param list The list that may have a first item. | |
| 71 | * @param t The default return value. | |
| 72 | * | |
| 73 | * @return null if the list is null or there is no first item. | |
| 74 | */ | |
| 75 | public static <T> T getFirst( final List<T> list, final T t ) { | |
| 76 | return isEmpty( list ) ? t : list.get( 0 ); | |
| 77 | } | |
| 78 | ||
| 79 | /** | |
| 80 | * Returns the last item in the given list, or t if not found. | |
| 81 | * | |
| 82 | * @param <T> The generic list type. | |
| 83 | * @param list The list that may have a last item. | |
| 84 | * @param t The default return value. | |
| 85 | * | |
| 86 | * @return null if the list is null or there is no last item. | |
| 87 | */ | |
| 88 | public static <T> T getLast( final List<T> list, final T t ) { | |
| 89 | return isEmpty( list ) ? t : list.get( list.size() - 1 ); | |
| 90 | } | |
| 91 | ||
| 92 | /** | |
| 93 | * Returns true if the given list is null or empty. | |
| 94 | * | |
| 95 | * @param <T> The generic list type. | |
| 96 | * @param list The list that has a last item. | |
| 97 | * | |
| 98 | * @return true The list is empty. | |
| 99 | */ | |
| 100 | public static <T> boolean isEmpty( final List<T> list ) { | |
| 101 | return list == null || list.isEmpty(); | |
| 102 | } | |
| 103 | } | |
| 104 | 1 |
| 1 | /* | |
| 2 | * Copyright 2016 White Magic Software, Ltd. | |
| 3 | * | |
| 4 | * All rights reserved. | |
| 5 | * | |
| 6 | * Redistribution and use in source and binary forms, with or without | |
| 7 | * modification, are permitted provided that the following conditions are met: | |
| 8 | * | |
| 9 | * o Redistributions of source code must retain the above copyright | |
| 10 | * notice, this list of conditions and the following disclaimer. | |
| 11 | * | |
| 12 | * o Redistributions in binary form must reproduce the above copyright | |
| 13 | * notice, this list of conditions and the following disclaimer in the | |
| 14 | * documentation and/or other materials provided with the distribution. | |
| 15 | * | |
| 16 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | |
| 17 | * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | |
| 18 | * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | |
| 19 | * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT | |
| 20 | * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | |
| 21 | * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT | |
| 22 | * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | |
| 23 | * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | |
| 24 | * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | |
| 25 | * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | |
| 26 | * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | |
| 27 | */ | |
| 28 | package com.scrivenvar.definition; | |
| 29 | ||
| 30 | import com.scrivenvar.decorators.VariableDecorator; | |
| 31 | import com.scrivenvar.decorators.YamlVariableDecorator; | |
| 32 | import static com.scrivenvar.definition.yaml.YamlParser.SEPARATOR; | |
| 33 | import static com.scrivenvar.editors.VariableNameInjector.DEFAULT_MAX_VAR_LENGTH; | |
| 34 | import java.util.HashMap; | |
| 35 | import java.util.Map; | |
| 36 | import java.util.Stack; | |
| 37 | import javafx.scene.control.TreeItem; | |
| 38 | ||
| 39 | /** | |
| 40 | * Provides behaviour afforded to variable names and their corresponding value. | |
| 41 | * | |
| 42 | * @author White Magic Software, Ltd. | |
| 43 | * @param <T> The type of TreeItem (usually String). | |
| 44 | */ | |
| 45 | public class VariableTreeItem<T> extends TreeItem<T> { | |
| 46 | ||
| 47 | private final static int DEFAULT_MAP_SIZE = 1000; | |
| 48 | ||
| 49 | private final static VariableDecorator VARIABLE_DECORATOR = | |
| 50 | new YamlVariableDecorator(); | |
| 51 | ||
| 52 | /** | |
| 53 | * Flattened tree. | |
| 54 | */ | |
| 55 | private Map<String, String> map; | |
| 56 | ||
| 57 | /** | |
| 58 | * Constructs a new item with a default value. | |
| 59 | * | |
| 60 | * @param value Passed up to superclass. | |
| 61 | */ | |
| 62 | public VariableTreeItem( final T value ) { | |
| 63 | super( value ); | |
| 64 | } | |
| 65 | ||
| 66 | /** | |
| 67 | * Finds a leaf starting at the current node with text that matches the given | |
| 68 | * value. | |
| 69 | * | |
| 70 | * @param text The text to match against each leaf in the tree. | |
| 71 | * | |
| 72 | * @return The leaf that has a value starting with the given text. | |
| 73 | */ | |
| 74 | public VariableTreeItem<T> findLeaf( final String text ) { | |
| 75 | final Stack<VariableTreeItem<T>> stack = new Stack<>(); | |
| 76 | final VariableTreeItem<T> root = this; | |
| 77 | ||
| 78 | stack.push( root ); | |
| 79 | ||
| 80 | boolean found = false; | |
| 81 | VariableTreeItem<T> node = null; | |
| 82 | ||
| 83 | while( !found && !stack.isEmpty() ) { | |
| 84 | node = stack.pop(); | |
| 85 | ||
| 86 | if( node.valueStartsWith( text ) ) { | |
| 87 | found = true; | |
| 88 | } else { | |
| 89 | for( final TreeItem<T> child : node.getChildren() ) { | |
| 90 | stack.push( (VariableTreeItem<T>)child ); | |
| 91 | } | |
| 92 | ||
| 93 | // No match found, yet. | |
| 94 | node = null; | |
| 95 | } | |
| 96 | } | |
| 97 | ||
| 98 | return (VariableTreeItem<T>)node; | |
| 99 | } | |
| 100 | ||
| 101 | /** | |
| 102 | * Returns true if this node is a leaf and its value starts with the given | |
| 103 | * text. | |
| 104 | * | |
| 105 | * @param s The text to compare against the node value. | |
| 106 | * | |
| 107 | * @return true Node is a leaf and its value starts with the given value. | |
| 108 | */ | |
| 109 | private boolean valueStartsWith( final String s ) { | |
| 110 | return isLeaf() && getValue().toString().startsWith( s ); | |
| 111 | } | |
| 112 | ||
| 113 | /** | |
| 114 | * Returns the path for this node, with nodes made distinct using the | |
| 115 | * separator character. This uses two loops: one for pushing nodes onto a | |
| 116 | * stack and one for popping them off to create the path in desired order. | |
| 117 | * | |
| 118 | * @return A non-null string, possibly empty. | |
| 119 | */ | |
| 120 | public String toPath() { | |
| 121 | final Stack<TreeItem<T>> stack = new Stack<>(); | |
| 122 | TreeItem<T> node = this; | |
| 123 | ||
| 124 | while( node.getParent() != null ) { | |
| 125 | stack.push( node ); | |
| 126 | node = node.getParent(); | |
| 127 | } | |
| 128 | ||
| 129 | final StringBuilder sb = new StringBuilder( DEFAULT_MAX_VAR_LENGTH ); | |
| 130 | ||
| 131 | while( !stack.isEmpty() ) { | |
| 132 | node = stack.pop(); | |
| 133 | ||
| 134 | if( !node.isLeaf() ) { | |
| 135 | sb.append( node.getValue() ); | |
| 136 | ||
| 137 | // This will add a superfluous separator, but instead of peeking at | |
| 138 | // the stack all the time, the last separator will be removed outside | |
| 139 | // the loop (one operation executed once). | |
| 140 | sb.append( SEPARATOR ); | |
| 141 | } | |
| 142 | } | |
| 143 | ||
| 144 | // Remove the trailing SEPARATOR. | |
| 145 | if( sb.length() > 0 ) { | |
| 146 | sb.setLength( sb.length() - 1 ); | |
| 147 | } | |
| 148 | ||
| 149 | return sb.toString(); | |
| 150 | } | |
| 151 | ||
| 152 | /** | |
| 153 | * Returns the hierarchy, flattened to key-value pairs. | |
| 154 | * | |
| 155 | * @return A map of this tree's key-value pairs. | |
| 156 | */ | |
| 157 | public Map<String, String> getMap() { | |
| 158 | if( this.map == null ) { | |
| 159 | this.map = new HashMap<>( DEFAULT_MAP_SIZE ); | |
| 160 | populate( this, this.map ); | |
| 161 | } | |
| 162 | ||
| 163 | return this.map; | |
| 164 | } | |
| 165 | ||
| 166 | private void populate( final TreeItem<T> parent, final Map<String, String> map ) { | |
| 167 | for( final TreeItem<T> child : parent.getChildren() ) { | |
| 168 | if( child.isLeaf() ) { | |
| 169 | @SuppressWarnings( "unchecked" ) | |
| 170 | final String key = toVariable( ((VariableTreeItem<String>)child).toPath() ); | |
| 171 | final String value = child.getValue().toString(); | |
| 172 | ||
| 173 | map.put( key, value ); | |
| 174 | } else { | |
| 175 | populate( child, map ); | |
| 176 | } | |
| 177 | } | |
| 178 | } | |
| 179 | ||
| 180 | /** | |
| 181 | * Converts the name of the key to a simple variable by enclosing it with | |
| 182 | * dollar symbols. | |
| 183 | * | |
| 184 | * @param key The key name to change to a variable. | |
| 185 | * | |
| 186 | * @return $key$ | |
| 187 | */ | |
| 188 | public String toVariable( final String key ) { | |
| 189 | return VARIABLE_DECORATOR.decorate( key ); | |
| 190 | } | |
| 191 | } | |
| 1 | 192 |
| 1 | /* | |
| 2 | * Copyright 2016 White Magic Software, Ltd. | |
| 3 | * | |
| 4 | * All rights reserved. | |
| 5 | * | |
| 6 | * Redistribution and use in source and binary forms, with or without | |
| 7 | * modification, are permitted provided that the following conditions are met: | |
| 8 | * | |
| 9 | * o Redistributions of source code must retain the above copyright | |
| 10 | * notice, this list of conditions and the following disclaimer. | |
| 11 | * | |
| 12 | * o Redistributions in binary form must reproduce the above copyright | |
| 13 | * notice, this list of conditions and the following disclaimer in the | |
| 14 | * documentation and/or other materials provided with the distribution. | |
| 15 | * | |
| 16 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | |
| 17 | * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | |
| 18 | * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | |
| 19 | * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT | |
| 20 | * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | |
| 21 | * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT | |
| 22 | * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | |
| 23 | * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | |
| 24 | * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | |
| 25 | * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | |
| 26 | * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | |
| 27 | */ | |
| 28 | package com.scrivenvar.definition.yaml; | |
| 29 | ||
| 30 | import com.scrivenvar.definition.AbstractDefinitionSource; | |
| 31 | import java.nio.file.Path; | |
| 32 | ||
| 33 | /** | |
| 34 | * Implements common behaviour for file definition sources. | |
| 35 | * | |
| 36 | * @author White Magic Software, Ltd. | |
| 37 | */ | |
| 38 | public abstract class FileDefinitionSource extends AbstractDefinitionSource { | |
| 39 | ||
| 40 | private Path path; | |
| 41 | ||
| 42 | /** | |
| 43 | * Constructs a new file definition source that can read and write data in the | |
| 44 | * hierarchical format contained within the file location specified by the | |
| 45 | * path. | |
| 46 | * | |
| 47 | * @param path Must not be null. | |
| 48 | */ | |
| 49 | public FileDefinitionSource( final Path path ) { | |
| 50 | setPath( path ); | |
| 51 | } | |
| 52 | ||
| 53 | private void setPath( final Path path ) { | |
| 54 | this.path = path; | |
| 55 | } | |
| 56 | ||
| 57 | protected Path getPath() { | |
| 58 | return this.path; | |
| 59 | } | |
| 60 | ||
| 61 | /** | |
| 62 | * Returns the path represented by this object. | |
| 63 | * | |
| 64 | * @return The | |
| 65 | */ | |
| 66 | @Override | |
| 67 | public String toString() { | |
| 68 | return getPath().toString(); | |
| 69 | } | |
| 70 | } | |
| 1 | 71 |
| 1 | /* | |
| 2 | * Copyright 2016 White Magic Software, Ltd. | |
| 3 | * | |
| 4 | * All rights reserved. | |
| 5 | * | |
| 6 | * Redistribution and use in source and binary forms, with or without | |
| 7 | * modification, are permitted provided that the following conditions are met: | |
| 8 | * | |
| 9 | * o Redistributions of source code must retain the above copyright | |
| 10 | * notice, this list of conditions and the following disclaimer. | |
| 11 | * | |
| 12 | * o Redistributions in binary form must reproduce the above copyright | |
| 13 | * notice, this list of conditions and the following disclaimer in the | |
| 14 | * documentation and/or other materials provided with the distribution. | |
| 15 | * | |
| 16 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | |
| 17 | * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | |
| 18 | * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | |
| 19 | * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT | |
| 20 | * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | |
| 21 | * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT | |
| 22 | * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | |
| 23 | * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | |
| 24 | * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | |
| 25 | * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | |
| 26 | * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | |
| 27 | */ | |
| 28 | package com.scrivenvar.definition.yaml; | |
| 29 | ||
| 30 | import static com.scrivenvar.Messages.get; | |
| 31 | import java.io.IOException; | |
| 32 | import java.io.InputStream; | |
| 33 | import java.nio.file.Files; | |
| 34 | import java.nio.file.Path; | |
| 35 | import java.util.Map; | |
| 36 | import javafx.scene.control.TreeView; | |
| 37 | ||
| 38 | /** | |
| 39 | * Represents a definition data source for YAML files. | |
| 40 | * | |
| 41 | * @author White Magic Software, Ltd. | |
| 42 | */ | |
| 43 | public class YamlFileDefinitionSource extends FileDefinitionSource { | |
| 44 | ||
| 45 | private YamlTreeAdapter yamlTreeAdapter; | |
| 46 | private YamlParser yamlParser; | |
| 47 | ||
| 48 | /** | |
| 49 | * Constructs a new YAML definition source, populated from the given file. | |
| 50 | * | |
| 51 | * @param path Path to the YAML definition file. | |
| 52 | */ | |
| 53 | public YamlFileDefinitionSource( final Path path ) { | |
| 54 | super( path ); | |
| 55 | } | |
| 56 | ||
| 57 | /** | |
| 58 | * TODO: Associate variable file with path to current file. | |
| 59 | * | |
| 60 | * @return The TreeView for this definition source. | |
| 61 | * | |
| 62 | * @throws IOException | |
| 63 | */ | |
| 64 | @Override | |
| 65 | public TreeView<String> asTreeView() throws IOException { | |
| 66 | ||
| 67 | try( final InputStream in = Files.newInputStream( getPath() ) ) { | |
| 68 | return getYamlTreeAdapter().adapt( | |
| 69 | in, | |
| 70 | get( "Pane.defintion.node.root.title" ) | |
| 71 | ); | |
| 72 | } | |
| 73 | } | |
| 74 | ||
| 75 | @Override | |
| 76 | public Map<String, String> getResolvedMap() { | |
| 77 | return getYamlParser().createResolvedMap(); | |
| 78 | } | |
| 79 | ||
| 80 | private YamlTreeAdapter getYamlTreeAdapter() { | |
| 81 | if( this.yamlTreeAdapter == null ) { | |
| 82 | setYamlTreeAdapter( new YamlTreeAdapter( getYamlParser() ) ); | |
| 83 | } | |
| 84 | ||
| 85 | return this.yamlTreeAdapter; | |
| 86 | } | |
| 87 | ||
| 88 | private void setYamlTreeAdapter( final YamlTreeAdapter yamlTreeAdapter ) { | |
| 89 | this.yamlTreeAdapter = yamlTreeAdapter; | |
| 90 | } | |
| 91 | ||
| 92 | private YamlParser getYamlParser() { | |
| 93 | if( this.yamlParser == null ) { | |
| 94 | setYamlParser( new YamlParser() ); | |
| 95 | } | |
| 96 | ||
| 97 | return this.yamlParser; | |
| 98 | } | |
| 99 | ||
| 100 | private void setYamlParser( final YamlParser yamlParser ) { | |
| 101 | this.yamlParser = yamlParser; | |
| 102 | } | |
| 103 | ||
| 104 | private InputStream asStream( final String resource ) { | |
| 105 | return getClass().getResourceAsStream( resource ); | |
| 106 | } | |
| 107 | } | |
| 1 | 108 |
| 1 | /* | |
| 2 | * Copyright 2016 White Magic Software, Ltd. | |
| 3 | * | |
| 4 | * All rights reserved. | |
| 5 | * | |
| 6 | * Redistribution and use in source and binary forms, with or without | |
| 7 | * modification, are permitted provided that the following conditions are met: | |
| 8 | * | |
| 9 | * o Redistributions of source code must retain the above copyright | |
| 10 | * notice, this list of conditions and the following disclaimer. | |
| 11 | * | |
| 12 | * o Redistributions in binary form must reproduce the above copyright | |
| 13 | * notice, this list of conditions and the following disclaimer in the | |
| 14 | * documentation and/or other materials provided with the distribution. | |
| 15 | * | |
| 16 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | |
| 17 | * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | |
| 18 | * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | |
| 19 | * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT | |
| 20 | * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | |
| 21 | * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT | |
| 22 | * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | |
| 23 | * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | |
| 24 | * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | |
| 25 | * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | |
| 26 | * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | |
| 27 | */ | |
| 28 | package com.scrivenvar.definition.yaml; | |
| 29 | ||
| 30 | import com.fasterxml.jackson.core.JsonGenerationException; | |
| 31 | import com.fasterxml.jackson.core.ObjectCodec; | |
| 32 | import com.fasterxml.jackson.core.io.IOContext; | |
| 33 | import com.fasterxml.jackson.databind.JsonNode; | |
| 34 | import com.fasterxml.jackson.databind.ObjectMapper; | |
| 35 | import com.fasterxml.jackson.databind.node.ObjectNode; | |
| 36 | import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; | |
| 37 | import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator; | |
| 38 | import com.scrivenvar.decorators.VariableDecorator; | |
| 39 | import com.scrivenvar.decorators.YamlVariableDecorator; | |
| 40 | import java.io.IOException; | |
| 41 | import java.io.InputStream; | |
| 42 | import java.io.Writer; | |
| 43 | import java.security.InvalidParameterException; | |
| 44 | import java.text.MessageFormat; | |
| 45 | import java.util.HashMap; | |
| 46 | import java.util.Map; | |
| 47 | import java.util.Map.Entry; | |
| 48 | import java.util.regex.Matcher; | |
| 49 | import java.util.regex.Pattern; | |
| 50 | import org.yaml.snakeyaml.DumperOptions; | |
| 51 | ||
| 52 | /** | |
| 53 | * <p> | |
| 54 | * This program loads a YAML document into memory, scans for variable | |
| 55 | * declarations, then substitutes any self-referential values back into the | |
| 56 | * document. Its output is the given YAML document without any variables. | |
| 57 | * Variables in the YAML document are denoted using a bracketed dollar symbol | |
| 58 | * syntax. For example: $field.name$. Some nomenclature to keep from going | |
| 59 | * squirrely, consider: | |
| 60 | * </p> | |
| 61 | * | |
| 62 | * <pre> | |
| 63 | * root: | |
| 64 | * node: | |
| 65 | * name: $field.name$ | |
| 66 | * field: | |
| 67 | * name: Alan Turing | |
| 68 | * </pre> | |
| 69 | * | |
| 70 | * The various components of the given YAML are called: | |
| 71 | * | |
| 72 | * <ul> | |
| 73 | * <li><code>$field.name$</code> - delimited reference</li> | |
| 74 | * <li><code>field.name</code> - reference</li> | |
| 75 | * <li><code>name</code> - YAML field</li> | |
| 76 | * <li><code>Alan Turing</code> - (dereferenced) field value</li> | |
| 77 | * </ul> | |
| 78 | * | |
| 79 | * @author White Magic Software, Ltd. | |
| 80 | */ | |
| 81 | public class YamlParser { | |
| 82 | ||
| 83 | /** | |
| 84 | * Separates YAML variable nodes (e.g., the dots in | |
| 85 | * <code>$root.node.var$</code>). | |
| 86 | */ | |
| 87 | public static final String SEPARATOR = "."; | |
| 88 | ||
| 89 | private final static int GROUP_DELIMITED = 1; | |
| 90 | private final static int GROUP_REFERENCE = 2; | |
| 91 | ||
| 92 | private final static VariableDecorator VARIABLE_DECORATOR | |
| 93 | = new YamlVariableDecorator(); | |
| 94 | ||
| 95 | /** | |
| 96 | * Compiled version of DEFAULT_REGEX. | |
| 97 | */ | |
| 98 | private final static Pattern REGEX_PATTERN | |
| 99 | = Pattern.compile( YamlVariableDecorator.REGEX ); | |
| 100 | ||
| 101 | /** | |
| 102 | * Should be JsonPointer.SEPARATOR, but Jackson YAML uses magic values. | |
| 103 | */ | |
| 104 | private final static char SEPARATOR_YAML = '/'; | |
| 105 | ||
| 106 | /** | |
| 107 | * Start of the Universe (the YAML document node that contains all others). | |
| 108 | */ | |
| 109 | private ObjectNode documentRoot; | |
| 110 | ||
| 111 | /** | |
| 112 | * Map of references to dereferenced field values. | |
| 113 | */ | |
| 114 | private Map<String, String> references; | |
| 115 | ||
| 116 | public YamlParser() { | |
| 117 | } | |
| 118 | ||
| 119 | /** | |
| 120 | * Returns the given string with all the delimited references swapped with | |
| 121 | * their recursively resolved values. | |
| 122 | * | |
| 123 | * @param text The text to parse with zero or more delimited references to | |
| 124 | * replace. | |
| 125 | * | |
| 126 | * @return The substituted value. | |
| 127 | */ | |
| 128 | public String substitute( String text ) { | |
| 129 | final Matcher matcher = patternMatch( text ); | |
| 130 | final Map<String, String> map = getReferences(); | |
| 131 | ||
| 132 | while( matcher.find() ) { | |
| 133 | final String key = matcher.group( GROUP_DELIMITED ); | |
| 134 | final String value = map.get( key ); | |
| 135 | ||
| 136 | if( value == null ) { | |
| 137 | missing( text ); | |
| 138 | } else { | |
| 139 | text = text.replace( key, value ); | |
| 140 | } | |
| 141 | } | |
| 142 | ||
| 143 | return text; | |
| 144 | } | |
| 145 | ||
| 146 | /** | |
| 147 | * Returns all the strings with their values resolved in a flat hierarchy. | |
| 148 | * This copies all the keys and resolved values into a new map. | |
| 149 | * | |
| 150 | * @return The new map created with all values having been resolved, | |
| 151 | * recursively. | |
| 152 | */ | |
| 153 | public Map<String, String> createResolvedMap() { | |
| 154 | final Map<String, String> map = new HashMap<>( 1024 ); | |
| 155 | ||
| 156 | resolve( getDocumentRoot(), "", map ); | |
| 157 | ||
| 158 | return map; | |
| 159 | } | |
| 160 | ||
| 161 | /** | |
| 162 | * Iterate over a given root node (at any level of the tree) and adapt each | |
| 163 | * leaf node. | |
| 164 | * | |
| 165 | * @param rootNode A JSON node (YAML node) to adapt. | |
| 166 | */ | |
| 167 | private void resolve( | |
| 168 | final JsonNode rootNode, final String path, final Map<String, String> map ) { | |
| 169 | ||
| 170 | rootNode.fields().forEachRemaining( | |
| 171 | (Entry<String, JsonNode> leaf) -> resolve( leaf, path, map ) | |
| 172 | ); | |
| 173 | } | |
| 174 | ||
| 175 | /** | |
| 176 | * Recursively adapt each rootNode to a corresponding rootItem. | |
| 177 | * | |
| 178 | * @param rootNode The node to adapt. | |
| 179 | */ | |
| 180 | private void resolve( | |
| 181 | final Entry<String, JsonNode> rootNode, | |
| 182 | final String path, | |
| 183 | final Map<String, String> map ) { | |
| 184 | ||
| 185 | final JsonNode leafNode = rootNode.getValue(); | |
| 186 | final String key = rootNode.getKey(); | |
| 187 | ||
| 188 | if( leafNode.isValueNode() ) { | |
| 189 | final String value = rootNode.getValue().asText(); | |
| 190 | ||
| 191 | map.put( VARIABLE_DECORATOR.decorate( path + key ), substitute( value ) ); | |
| 192 | } | |
| 193 | ||
| 194 | if( leafNode.isObject() ) { | |
| 195 | resolve( leafNode, path + key + SEPARATOR, map ); | |
| 196 | } | |
| 197 | } | |
| 198 | ||
| 199 | /** | |
| 200 | * Reads the first document from the given stream of YAML data and returns a | |
| 201 | * corresponding object that represents the YAML hierarchy. The calling class | |
| 202 | * is responsible for closing the stream. Calling classes should use | |
| 203 | * <code>JsonNode.fields()</code> to walk through the YAML tree of fields. | |
| 204 | * | |
| 205 | * @param in The input stream containing YAML content. | |
| 206 | * | |
| 207 | * @return An object hierarchy to represent the content. | |
| 208 | * | |
| 209 | * @throws IOException Could not read the stream. | |
| 210 | */ | |
| 211 | public JsonNode process( final InputStream in ) throws IOException { | |
| 212 | ||
| 213 | final ObjectNode root = (ObjectNode)getObjectMapper().readTree( in ); | |
| 214 | setDocumentRoot( root ); | |
| 215 | process( root ); | |
| 216 | return getDocumentRoot(); | |
| 217 | } | |
| 218 | ||
| 219 | /** | |
| 220 | * Iterate over a given root node (at any level of the tree) and process each | |
| 221 | * leaf node. | |
| 222 | * | |
| 223 | * @param root A node to process. | |
| 224 | */ | |
| 225 | private void process( final JsonNode root ) { | |
| 226 | root.fields().forEachRemaining( this::process ); | |
| 227 | } | |
| 228 | ||
| 229 | /** | |
| 230 | * Process the given field, which is a named node. This is where the | |
| 231 | * application does the up-front work of mapping references to their fully | |
| 232 | * recursively dereferenced values. | |
| 233 | * | |
| 234 | * @param field The named node. | |
| 235 | */ | |
| 236 | private void process( final Entry<String, JsonNode> field ) { | |
| 237 | final JsonNode node = field.getValue(); | |
| 238 | ||
| 239 | if( node.isObject() ) { | |
| 240 | process( node ); | |
| 241 | } else { | |
| 242 | final JsonNode fieldValue = field.getValue(); | |
| 243 | ||
| 244 | // Only basic data types can be parsed into variable values. For | |
| 245 | // node structures, YAML has a built-in mechanism. | |
| 246 | if( fieldValue.isValueNode() ) { | |
| 247 | try { | |
| 248 | resolve( fieldValue.asText() ); | |
| 249 | } catch( StackOverflowError e ) { | |
| 250 | throw new IllegalArgumentException( | |
| 251 | "Unresolvable: " + node.textValue() + " = " + fieldValue ); | |
| 252 | } | |
| 253 | } | |
| 254 | } | |
| 255 | } | |
| 256 | ||
| 257 | /** | |
| 258 | * Inserts the delimited references and field values into the cache. This will | |
| 259 | * overwrite existing references. | |
| 260 | * | |
| 261 | * @param fieldValue YAML field containing zero or more delimited references. | |
| 262 | * If it contains a delimited reference, the parameter is modified with the | |
| 263 | * dereferenced value before it is returned. | |
| 264 | * | |
| 265 | * @return fieldValue without delimited references. | |
| 266 | */ | |
| 267 | private String resolve( String fieldValue ) { | |
| 268 | final Matcher matcher = patternMatch( fieldValue ); | |
| 269 | ||
| 270 | while( matcher.find() ) { | |
| 271 | final String delimited = matcher.group( GROUP_DELIMITED ); | |
| 272 | final String reference = matcher.group( GROUP_REFERENCE ); | |
| 273 | final String dereference = resolve( lookup( reference ) ); | |
| 274 | ||
| 275 | fieldValue = fieldValue.replace( delimited, dereference ); | |
| 276 | ||
| 277 | // This will perform some superfluous calls by overwriting existing | |
| 278 | // items in the delimited reference map. | |
| 279 | put( delimited, dereference ); | |
| 280 | } | |
| 281 | ||
| 282 | return fieldValue; | |
| 283 | } | |
| 284 | ||
| 285 | /** | |
| 286 | * Inserts a key/value pair into the references map. The map retains | |
| 287 | * references and dereferenced values found in the YAML. If the reference | |
| 288 | * already exists, this will overwrite with a new value. | |
| 289 | * | |
| 290 | * @param delimited The variable name. | |
| 291 | * @param dereferenced The resolved value. | |
| 292 | */ | |
| 293 | private void put( String delimited, String dereferenced ) { | |
| 294 | if( dereferenced.isEmpty() ) { | |
| 295 | missing( delimited ); | |
| 296 | } else { | |
| 297 | getReferences().put( delimited, dereferenced ); | |
| 298 | } | |
| 299 | } | |
| 300 | ||
| 301 | /** | |
| 302 | * Writes the modified YAML document to standard output. | |
| 303 | */ | |
| 304 | private void writeDocument() throws IOException { | |
| 305 | getObjectMapper().writeValue( System.out, getDocumentRoot() ); | |
| 306 | } | |
| 307 | ||
| 308 | /** | |
| 309 | * Called when a delimited reference is dereferenced to an empty string. This | |
| 310 | * should produce a warning for the user. | |
| 311 | * | |
| 312 | * @param delimited Delimited reference with no derived value. | |
| 313 | */ | |
| 314 | private void missing( final String delimited ) { | |
| 315 | throw new InvalidParameterException( | |
| 316 | MessageFormat.format( "Missing value for '{0}'.", delimited ) ); | |
| 317 | } | |
| 318 | ||
| 319 | /** | |
| 320 | * Returns a REGEX_PATTERN matcher for the given text. | |
| 321 | * | |
| 322 | * @param text The text that contains zero or more instances of a | |
| 323 | * REGEX_PATTERN that can be found using the regular expression. | |
| 324 | */ | |
| 325 | private Matcher patternMatch( String text ) { | |
| 326 | return getPattern().matcher( text ); | |
| 327 | } | |
| 328 | ||
| 329 | /** | |
| 330 | * Finds the YAML value for a reference. | |
| 331 | * | |
| 332 | * @param reference References a value in the YAML document. | |
| 333 | * | |
| 334 | * @return The dereferenced value. | |
| 335 | */ | |
| 336 | private String lookup( final String reference ) { | |
| 337 | return getDocumentRoot().at( asPath( reference ) ).asText(); | |
| 338 | } | |
| 339 | ||
| 340 | /** | |
| 341 | * Converts a reference (not delimited) to a path that can be used to find a | |
| 342 | * value that should exist inside the YAML document. | |
| 343 | * | |
| 344 | * @param reference The reference to convert to a YAML document path. | |
| 345 | * | |
| 346 | * @return The reference with a leading slash and its separator characters | |
| 347 | * converted to slashes. | |
| 348 | */ | |
| 349 | private String asPath( final String reference ) { | |
| 350 | return SEPARATOR_YAML + reference.replace( getDelimitedSeparator(), SEPARATOR_YAML ); | |
| 351 | } | |
| 352 | ||
| 353 | /** | |
| 354 | * Sets the parent node for the entire YAML document tree. | |
| 355 | * | |
| 356 | * @param documentRoot The parent node. | |
| 357 | */ | |
| 358 | private void setDocumentRoot( ObjectNode documentRoot ) { | |
| 359 | this.documentRoot = documentRoot; | |
| 360 | } | |
| 361 | ||
| 362 | /** | |
| 363 | * Returns the parent node for the entire YAML document tree. | |
| 364 | * | |
| 365 | * @return The parent node. | |
| 366 | */ | |
| 367 | private ObjectNode getDocumentRoot() { | |
| 368 | return this.documentRoot; | |
| 369 | } | |
| 370 | ||
| 371 | /** | |
| 372 | * Returns the compiled regular expression REGEX_PATTERN used to match | |
| 373 | * delimited references. | |
| 374 | * | |
| 375 | * @return A compiled regex for use with the Matcher. | |
| 376 | */ | |
| 377 | private Pattern getPattern() { | |
| 378 | return REGEX_PATTERN; | |
| 379 | } | |
| 380 | ||
| 381 | /** | |
| 382 | * Returns the list of references mapped to dereferenced values. | |
| 383 | * | |
| 384 | * @return | |
| 385 | */ | |
| 386 | private Map<String, String> getReferences() { | |
| 387 | if( this.references == null ) { | |
| 388 | this.references = createReferences(); | |
| 389 | } | |
| 390 | ||
| 391 | return this.references; | |
| 392 | } | |
| 393 | ||
| 394 | /** | |
| 395 | * Subclasses can override this method to insert their own map. | |
| 396 | * | |
| 397 | * @return An empty HashMap, never null. | |
| 398 | */ | |
| 399 | protected Map<String, String> createReferences() { | |
| 400 | return new HashMap<>(); | |
| 401 | } | |
| 402 | ||
| 403 | private class ResolverYAMLFactory extends YAMLFactory { | |
| 404 | ||
| 405 | @Override | |
| 406 | protected YAMLGenerator _createGenerator( | |
| 407 | final Writer out, final IOContext ctxt ) throws IOException { | |
| 408 | ||
| 409 | return new ResolverYAMLGenerator( | |
| 410 | ctxt, _generatorFeatures, _yamlGeneratorFeatures, _objectCodec, | |
| 411 | out, _version ); | |
| 412 | } | |
| 413 | } | |
| 414 | ||
| 415 | private class ResolverYAMLGenerator extends YAMLGenerator { | |
| 416 | ||
| 417 | public ResolverYAMLGenerator( | |
| 418 | final IOContext ctxt, | |
| 419 | final int jsonFeatures, | |
| 420 | final int yamlFeatures, | |
| 421 | final ObjectCodec codec, | |
| 422 | final Writer out, | |
| 423 | final DumperOptions.Version version ) throws IOException { | |
| 424 | ||
| 425 | super( ctxt, jsonFeatures, yamlFeatures, codec, out, version ); | |
| 426 | } | |
| 427 | ||
| 428 | @Override | |
| 429 | public void writeString( final String text ) | |
| 430 | throws IOException, JsonGenerationException { | |
| 431 | super.writeString( substitute( text ) ); | |
| 432 | } | |
| 433 | } | |
| 434 | ||
| 435 | private YAMLFactory getYAMLFactory() { | |
| 436 | return new ResolverYAMLFactory(); | |
| 437 | } | |
| 438 | ||
| 439 | private ObjectMapper getObjectMapper() { | |
| 440 | return new ObjectMapper( getYAMLFactory() ); | |
| 441 | } | |
| 442 | ||
| 443 | /** | |
| 444 | * Returns the character used to separate YAML paths within delimited | |
| 445 | * references. This will return only the first character of the command line | |
| 446 | * parameter, if the default is overridden. | |
| 447 | * | |
| 448 | * @return A period by default. | |
| 449 | */ | |
| 450 | private char getDelimitedSeparator() { | |
| 451 | return SEPARATOR.charAt( 0 ); | |
| 452 | } | |
| 453 | } | |
| 1 | 454 |
| 1 | /* | |
| 2 | * Copyright 2016 White Magic Software, Ltd. | |
| 3 | * | |
| 4 | * All rights reserved. | |
| 5 | * | |
| 6 | * Redistribution and use in source and binary forms, with or without | |
| 7 | * modification, are permitted provided that the following conditions are met: | |
| 8 | * | |
| 9 | * o Redistributions of source code must retain the above copyright | |
| 10 | * notice, this list of conditions and the following disclaimer. | |
| 11 | * | |
| 12 | * o Redistributions in binary form must reproduce the above copyright | |
| 13 | * notice, this list of conditions and the following disclaimer in the | |
| 14 | * documentation and/or other materials provided with the distribution. | |
| 15 | * | |
| 16 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | |
| 17 | * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | |
| 18 | * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | |
| 19 | * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT | |
| 20 | * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | |
| 21 | * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT | |
| 22 | * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | |
| 23 | * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | |
| 24 | * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | |
| 25 | * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | |
| 26 | * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | |
| 27 | */ | |
| 28 | package com.scrivenvar.definition.yaml; | |
| 29 | ||
| 30 | import com.fasterxml.jackson.databind.JsonNode; | |
| 31 | import com.scrivenvar.definition.VariableTreeItem; | |
| 32 | import java.io.IOException; | |
| 33 | import java.io.InputStream; | |
| 34 | import java.util.Map.Entry; | |
| 35 | import javafx.scene.control.TreeItem; | |
| 36 | import javafx.scene.control.TreeView; | |
| 37 | ||
| 38 | /** | |
| 39 | * Transforms a JsonNode hierarchy into a tree that can be displayed in a user | |
| 40 | * interface. | |
| 41 | * | |
| 42 | * @author White Magic Software, Ltd. | |
| 43 | */ | |
| 44 | public class YamlTreeAdapter { | |
| 45 | ||
| 46 | private YamlParser yamlParser; | |
| 47 | ||
| 48 | public YamlTreeAdapter( final YamlParser parser ) { | |
| 49 | setYamlParser( parser ); | |
| 50 | } | |
| 51 | ||
| 52 | /** | |
| 53 | * Converts a YAML document to a TreeView based on the document keys. Only the | |
| 54 | * first document in the stream is adapted. This does not close the stream. | |
| 55 | * | |
| 56 | * @param in Contains a YAML document. | |
| 57 | * @param name Root TreeItem node name. | |
| 58 | * | |
| 59 | * @return A TreeView populated with all the keys in the YAML document. | |
| 60 | * | |
| 61 | * @throws IOException Could not read from the stream. | |
| 62 | */ | |
| 63 | public TreeView<String> adapt( final InputStream in, final String name ) | |
| 64 | throws IOException { | |
| 65 | ||
| 66 | final JsonNode rootNode = getYamlParser().process( in ); | |
| 67 | final TreeItem<String> rootItem = createTreeItem( name ); | |
| 68 | ||
| 69 | rootItem.setExpanded( true ); | |
| 70 | adapt( rootNode, rootItem ); | |
| 71 | return new TreeView<>( rootItem ); | |
| 72 | } | |
| 73 | ||
| 74 | /** | |
| 75 | * Iterate over a given root node (at any level of the tree) and adapt each | |
| 76 | * leaf node. | |
| 77 | * | |
| 78 | * @param rootNode A JSON node (YAML node) to adapt. | |
| 79 | * @param rootItem The tree item to use as the root when processing the node. | |
| 80 | */ | |
| 81 | private void adapt( | |
| 82 | final JsonNode rootNode, final TreeItem<String> rootItem ) { | |
| 83 | ||
| 84 | rootNode.fields().forEachRemaining( | |
| 85 | (Entry<String, JsonNode> leaf) -> adapt( leaf, rootItem ) | |
| 86 | ); | |
| 87 | } | |
| 88 | ||
| 89 | /** | |
| 90 | * Recursively adapt each rootNode to a corresponding rootItem. | |
| 91 | * | |
| 92 | * @param rootNode The node to adapt. | |
| 93 | * @param rootItem The item to adapt using the node's key. | |
| 94 | */ | |
| 95 | private void adapt( | |
| 96 | final Entry<String, JsonNode> rootNode, final TreeItem<String> rootItem ) { | |
| 97 | ||
| 98 | final JsonNode leafNode = rootNode.getValue(); | |
| 99 | final String key = rootNode.getKey(); | |
| 100 | final TreeItem<String> leaf = createTreeItem( key ); | |
| 101 | ||
| 102 | if( leafNode.isValueNode() ) { | |
| 103 | leaf.getChildren().add( createTreeItem( rootNode.getValue().asText() ) ); | |
| 104 | } | |
| 105 | ||
| 106 | rootItem.getChildren().add( leaf ); | |
| 107 | ||
| 108 | if( leafNode.isObject() ) { | |
| 109 | adapt( leafNode, leaf ); | |
| 110 | } | |
| 111 | } | |
| 112 | ||
| 113 | /** | |
| 114 | * Creates a new tree item that can be added to the tree view. | |
| 115 | * | |
| 116 | * @param value The node's value. | |
| 117 | * | |
| 118 | * @return A new tree item node, never null. | |
| 119 | */ | |
| 120 | private TreeItem<String> createTreeItem( final String value ) { | |
| 121 | return new VariableTreeItem<>( value ); | |
| 122 | } | |
| 123 | ||
| 124 | private YamlParser getYamlParser() { | |
| 125 | return this.yamlParser; | |
| 126 | } | |
| 127 | ||
| 128 | private void setYamlParser( final YamlParser yamlParser ) { | |
| 129 | this.yamlParser = yamlParser; | |
| 130 | } | |
| 131 | } | |
| 1 | 132 |
| 30 | 30 | import com.scrivenvar.Messages; |
| 31 | 31 | import com.scrivenvar.controls.EscapeTextField; |
| 32 | import com.scrivenvar.editor.HyperlinkModel; | |
| 32 | import com.scrivenvar.editors.markdown.HyperlinkModel; | |
| 33 | 33 | import com.scrivenvar.service.events.impl.ButtonOrderPane; |
| 34 | 34 | import java.nio.file.Path; |
| 1 | /* | |
| 2 | * Copyright 2016 Karl Tauber and White Magic Software, Ltd. | |
| 3 | * | |
| 4 | * All rights reserved. | |
| 5 | * | |
| 6 | * Redistribution and use in source and binary forms, with or without | |
| 7 | * modification, are permitted provided that the following conditions are met: | |
| 8 | * | |
| 9 | * o Redistributions of source code must retain the above copyright | |
| 10 | * notice, this list of conditions and the following disclaimer. | |
| 11 | * | |
| 12 | * o Redistributions in binary form must reproduce the above copyright | |
| 13 | * notice, this list of conditions and the following disclaimer in the | |
| 14 | * documentation and/or other materials provided with the distribution. | |
| 15 | * | |
| 16 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | |
| 17 | * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | |
| 18 | * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | |
| 19 | * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT | |
| 20 | * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | |
| 21 | * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT | |
| 22 | * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | |
| 23 | * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | |
| 24 | * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | |
| 25 | * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | |
| 26 | * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | |
| 27 | */ | |
| 28 | package com.scrivenvar.editor; | |
| 29 | ||
| 30 | import com.scrivenvar.ui.AbstractPane; | |
| 31 | import java.nio.file.Path; | |
| 32 | import java.util.function.Consumer; | |
| 33 | import javafx.application.Platform; | |
| 34 | import javafx.beans.property.ObjectProperty; | |
| 35 | import javafx.beans.property.SimpleObjectProperty; | |
| 36 | import javafx.beans.value.ChangeListener; | |
| 37 | import javafx.event.Event; | |
| 38 | import javafx.scene.control.ScrollPane; | |
| 39 | import javafx.scene.input.InputEvent; | |
| 40 | import org.fxmisc.flowless.VirtualizedScrollPane; | |
| 41 | import org.fxmisc.richtext.StyleClassedTextArea; | |
| 42 | import org.fxmisc.undo.UndoManager; | |
| 43 | import org.fxmisc.wellbehaved.event.EventPattern; | |
| 44 | import org.fxmisc.wellbehaved.event.InputMap; | |
| 45 | import static org.fxmisc.wellbehaved.event.InputMap.consume; | |
| 46 | import org.fxmisc.wellbehaved.event.Nodes; | |
| 47 | ||
| 48 | /** | |
| 49 | * Represents common editing features for various types of text editors. | |
| 50 | * | |
| 51 | * @author White Magic Software, Ltd. | |
| 52 | */ | |
| 53 | public class EditorPane extends AbstractPane { | |
| 54 | ||
| 55 | private StyleClassedTextArea editor; | |
| 56 | private VirtualizedScrollPane<StyleClassedTextArea> scrollPane; | |
| 57 | private final ObjectProperty<Path> path = new SimpleObjectProperty<>(); | |
| 58 | ||
| 59 | /** | |
| 60 | * Set when entering variable edit mode; retrieved upon exiting. | |
| 61 | */ | |
| 62 | private InputMap<InputEvent> nodeMap; | |
| 63 | ||
| 64 | @Override | |
| 65 | public void requestFocus() { | |
| 66 | Platform.runLater( () -> getEditor().requestFocus() ); | |
| 67 | } | |
| 68 | ||
| 69 | public void undo() { | |
| 70 | getUndoManager().undo(); | |
| 71 | } | |
| 72 | ||
| 73 | public void redo() { | |
| 74 | getUndoManager().redo(); | |
| 75 | } | |
| 76 | ||
| 77 | public UndoManager getUndoManager() { | |
| 78 | return getEditor().getUndoManager(); | |
| 79 | } | |
| 80 | ||
| 81 | public String getText() { | |
| 82 | return getEditor().getText(); | |
| 83 | } | |
| 84 | ||
| 85 | public void setText( final String text ) { | |
| 86 | getEditor().deselect(); | |
| 87 | getEditor().replaceText( text ); | |
| 88 | getUndoManager().mark(); | |
| 89 | } | |
| 90 | ||
| 91 | /** | |
| 92 | * Call to hook into changes to the text area. | |
| 93 | * | |
| 94 | * @param listener Receives editor text change events. | |
| 95 | */ | |
| 96 | public void addTextChangeListener( final ChangeListener<? super String> listener ) { | |
| 97 | getEditor().textProperty().addListener( listener ); | |
| 98 | } | |
| 99 | ||
| 100 | /** | |
| 101 | * Call to listen for when the caret moves to another paragraph. | |
| 102 | * | |
| 103 | * @param listener Receives paragraph change events. | |
| 104 | */ | |
| 105 | public void addCaretParagraphListener( | |
| 106 | final ChangeListener<? super Integer> listener ) { | |
| 107 | getEditor().currentParagraphProperty().addListener( listener ); | |
| 108 | } | |
| 109 | ||
| 110 | /** | |
| 111 | * This method adds listeners to editor events. | |
| 112 | * | |
| 113 | * @param <T> The event type. | |
| 114 | * @param <U> The consumer type for the given event type. | |
| 115 | * @param event The event of interest. | |
| 116 | * @param consumer The method to call when the event happens. | |
| 117 | */ | |
| 118 | public <T extends Event, U extends T> void addEventListener( | |
| 119 | final EventPattern<? super T, ? extends U> event, | |
| 120 | final Consumer<? super U> consumer ) { | |
| 121 | Nodes.addInputMap( getEditor(), consume( event, consumer ) ); | |
| 122 | } | |
| 123 | ||
| 124 | /** | |
| 125 | * This method adds listeners to editor events that can be removed without | |
| 126 | * affecting the original listeners (i.e., the original lister is restored on | |
| 127 | * a call to removeEventListener). | |
| 128 | * | |
| 129 | * @param map The map of methods to events. | |
| 130 | */ | |
| 131 | @SuppressWarnings( "unchecked" ) | |
| 132 | public void addEventListener( final InputMap<InputEvent> map ) { | |
| 133 | this.nodeMap = (InputMap<InputEvent>)getInputMap(); | |
| 134 | Nodes.addInputMap( getEditor(), map ); | |
| 135 | } | |
| 136 | ||
| 137 | /** | |
| 138 | * This method removes listeners to editor events and restores the default | |
| 139 | * handler. | |
| 140 | * | |
| 141 | * @param map The map of methods to events. | |
| 142 | */ | |
| 143 | public void removeEventListener( final InputMap<InputEvent> map ) { | |
| 144 | Nodes.removeInputMap( getEditor(), map ); | |
| 145 | Nodes.addInputMap( getEditor(), this.nodeMap ); | |
| 146 | } | |
| 147 | ||
| 148 | /** | |
| 149 | * Returns the value for "org.fxmisc.wellbehaved.event.inputmap". | |
| 150 | * | |
| 151 | * @return An input map of input events. | |
| 152 | */ | |
| 153 | private Object getInputMap() { | |
| 154 | return getEditor().getProperties().get( getInputMapKey() ); | |
| 155 | } | |
| 156 | ||
| 157 | /** | |
| 158 | * Returns the hashmap key entry for the input map. | |
| 159 | * | |
| 160 | * @return "org.fxmisc.wellbehaved.event.inputmap" | |
| 161 | */ | |
| 162 | private String getInputMapKey() { | |
| 163 | return "org.fxmisc.wellbehaved.event.inputmap"; | |
| 164 | } | |
| 165 | ||
| 166 | public void scrollToTop() { | |
| 167 | getEditor().moveTo( 0 ); | |
| 168 | } | |
| 169 | ||
| 170 | private void setEditor( StyleClassedTextArea textArea ) { | |
| 171 | this.editor = textArea; | |
| 172 | } | |
| 173 | ||
| 174 | public synchronized StyleClassedTextArea getEditor() { | |
| 175 | if( this.editor == null ) { | |
| 176 | setEditor( createTextArea() ); | |
| 177 | } | |
| 178 | ||
| 179 | return this.editor; | |
| 180 | } | |
| 181 | ||
| 182 | /** | |
| 183 | * Returns the scroll pane that contains the text area. | |
| 184 | * | |
| 185 | * @return The scroll pane that contains the content to edit. | |
| 186 | */ | |
| 187 | public synchronized VirtualizedScrollPane<StyleClassedTextArea> getScrollPane() { | |
| 188 | if( this.scrollPane == null ) { | |
| 189 | this.scrollPane = createScrollPane(); | |
| 190 | } | |
| 191 | ||
| 192 | return this.scrollPane; | |
| 193 | } | |
| 194 | ||
| 195 | protected VirtualizedScrollPane<StyleClassedTextArea> createScrollPane() { | |
| 196 | final VirtualizedScrollPane<StyleClassedTextArea> pane = new VirtualizedScrollPane<>( getEditor() ); | |
| 197 | pane.setVbarPolicy( ScrollPane.ScrollBarPolicy.ALWAYS ); | |
| 198 | ||
| 199 | return pane; | |
| 200 | } | |
| 201 | ||
| 202 | protected StyleClassedTextArea createTextArea() { | |
| 203 | return new StyleClassedTextArea( false ); | |
| 204 | } | |
| 205 | ||
| 206 | public Path getPath() { | |
| 207 | return this.path.get(); | |
| 208 | } | |
| 209 | ||
| 210 | public void setPath( final Path path ) { | |
| 211 | this.path.set( path ); | |
| 212 | } | |
| 213 | ||
| 214 | public ObjectProperty<Path> pathProperty() { | |
| 215 | return this.path; | |
| 216 | } | |
| 217 | } | |
| 218 | 1 |
| 1 | /* | |
| 2 | * Copyright 2016 White Magic Software, Ltd. | |
| 3 | * | |
| 4 | * All rights reserved. | |
| 5 | * | |
| 6 | * Redistribution and use in source and binary forms, with or without | |
| 7 | * modification, are permitted provided that the following conditions are met: | |
| 8 | * | |
| 9 | * o Redistributions of source code must retain the above copyright | |
| 10 | * notice, this list of conditions and the following disclaimer. | |
| 11 | * | |
| 12 | * o Redistributions in binary form must reproduce the above copyright | |
| 13 | * notice, this list of conditions and the following disclaimer in the | |
| 14 | * documentation and/or other materials provided with the distribution. | |
| 15 | * | |
| 16 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | |
| 17 | * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | |
| 18 | * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | |
| 19 | * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT | |
| 20 | * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | |
| 21 | * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT | |
| 22 | * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | |
| 23 | * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | |
| 24 | * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | |
| 25 | * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | |
| 26 | * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | |
| 27 | */ | |
| 28 | package com.scrivenvar.editor; | |
| 29 | ||
| 30 | import com.vladsch.flexmark.ast.Link; | |
| 31 | ||
| 32 | /** | |
| 33 | * Represents the model for a hyperlink: text and url text. | |
| 34 | * | |
| 35 | * @author White Magic Software, Ltd. | |
| 36 | */ | |
| 37 | public class HyperlinkModel { | |
| 38 | ||
| 39 | private String text; | |
| 40 | private String url; | |
| 41 | private String title; | |
| 42 | ||
| 43 | /** | |
| 44 | * Constructs a new hyperlink model in Markdown format by default with no | |
| 45 | * title (i.e., tooltip). | |
| 46 | * | |
| 47 | * @param text The hyperlink text displayed (e.g., displayed to the user). | |
| 48 | * @param url The destination URL (e.g., when clicked). | |
| 49 | */ | |
| 50 | public HyperlinkModel( final String text, final String url ) { | |
| 51 | this( text, url, null ); | |
| 52 | } | |
| 53 | ||
| 54 | /** | |
| 55 | * Constructs a new hyperlink model for the given AST link. | |
| 56 | * | |
| 57 | * @param link A markdown link. | |
| 58 | */ | |
| 59 | public HyperlinkModel( final Link link ) { | |
| 60 | this( | |
| 61 | link.getText().toString(), | |
| 62 | link.getUrl().toString(), | |
| 63 | link.getTitle().toString() | |
| 64 | ); | |
| 65 | } | |
| 66 | ||
| 67 | /** | |
| 68 | * Constructs a new hyperlink model in Markdown format by default. | |
| 69 | * | |
| 70 | * @param text The hyperlink text displayed (e.g., displayed to the user). | |
| 71 | * @param url The destination URL (e.g., when clicked). | |
| 72 | * @param title The hyperlink title (e.g., shown as a tooltip). | |
| 73 | */ | |
| 74 | public HyperlinkModel( final String text, final String url, final String title ) { | |
| 75 | setText( text ); | |
| 76 | setUrl( url ); | |
| 77 | setTitle( title ); | |
| 78 | } | |
| 79 | ||
| 80 | /** | |
| 81 | * Returns the string in Markdown format by default. | |
| 82 | * | |
| 83 | * @return A markdown version of the hyperlink. | |
| 84 | */ | |
| 85 | @Override | |
| 86 | public String toString() { | |
| 87 | String format = "%s%s%s"; | |
| 88 | ||
| 89 | if( hasText() ) { | |
| 90 | format = "[%s]" + (hasTitle() ? "(%s \"%s\")" : "(%s%s)"); | |
| 91 | } | |
| 92 | ||
| 93 | // Becomes ""+URL+"" if no text is set. | |
| 94 | // Becomes [TITLE]+(URL)+"" if no title is set. | |
| 95 | // Becomes [TITLE]+(URL+ \"TITLE\") if title is set. | |
| 96 | return String.format( format, getText(), getUrl(), getTitle() ); | |
| 97 | } | |
| 98 | ||
| 99 | public final void setText( final String text ) { | |
| 100 | this.text = nullSafe( text ); | |
| 101 | } | |
| 102 | ||
| 103 | public final void setUrl( final String url ) { | |
| 104 | this.url = nullSafe( url ); | |
| 105 | } | |
| 106 | ||
| 107 | public final void setTitle( final String title ) { | |
| 108 | this.title = nullSafe( title ); | |
| 109 | } | |
| 110 | ||
| 111 | /** | |
| 112 | * Answers whether text has been set for the hyperlink. | |
| 113 | * | |
| 114 | * @return true This is a text link. | |
| 115 | */ | |
| 116 | public boolean hasText() { | |
| 117 | return !getText().isEmpty(); | |
| 118 | } | |
| 119 | ||
| 120 | /** | |
| 121 | * Answers whether a title (tooltip) has been set for the hyperlink. | |
| 122 | * | |
| 123 | * @return true There is a title. | |
| 124 | */ | |
| 125 | public boolean hasTitle() { | |
| 126 | return !getTitle().isEmpty(); | |
| 127 | } | |
| 128 | ||
| 129 | public String getText() { | |
| 130 | return this.text; | |
| 131 | } | |
| 132 | ||
| 133 | public String getUrl() { | |
| 134 | return this.url; | |
| 135 | } | |
| 136 | ||
| 137 | public String getTitle() { | |
| 138 | return this.title; | |
| 139 | } | |
| 140 | ||
| 141 | private String nullSafe( final String s ) { | |
| 142 | return s == null ? "" : s; | |
| 143 | } | |
| 144 | } | |
| 145 | 1 |
| 1 | /* | |
| 2 | * Copyright 2016 White Magic Software, Ltd. | |
| 3 | * | |
| 4 | * All rights reserved. | |
| 5 | * | |
| 6 | * Redistribution and use in source and binary forms, with or without | |
| 7 | * modification, are permitted provided that the following conditions are met: | |
| 8 | * | |
| 9 | * o Redistributions of source code must retain the above copyright | |
| 10 | * notice, this list of conditions and the following disclaimer. | |
| 11 | * | |
| 12 | * o Redistributions in binary form must reproduce the above copyright | |
| 13 | * notice, this list of conditions and the following disclaimer in the | |
| 14 | * documentation and/or other materials provided with the distribution. | |
| 15 | * | |
| 16 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | |
| 17 | * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | |
| 18 | * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | |
| 19 | * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT | |
| 20 | * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | |
| 21 | * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT | |
| 22 | * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | |
| 23 | * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | |
| 24 | * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | |
| 25 | * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | |
| 26 | * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | |
| 27 | */ | |
| 28 | package com.scrivenvar.editor; | |
| 29 | ||
| 30 | import com.vladsch.flexmark.ast.Link; | |
| 31 | import com.vladsch.flexmark.ast.Node; | |
| 32 | import com.vladsch.flexmark.ast.NodeVisitor; | |
| 33 | import com.vladsch.flexmark.ast.VisitHandler; | |
| 34 | ||
| 35 | /** | |
| 36 | * @author White Magic Software, Ltd. | |
| 37 | */ | |
| 38 | public class LinkVisitor { | |
| 39 | ||
| 40 | private NodeVisitor visitor; | |
| 41 | private Link link; | |
| 42 | private final int offset; | |
| 43 | ||
| 44 | /** | |
| 45 | * Creates a hyperlink given an offset into a paragraph and the markdown AST | |
| 46 | * link node. | |
| 47 | * | |
| 48 | * @param index Index into the paragraph that indicates the hyperlink to | |
| 49 | * change. | |
| 50 | */ | |
| 51 | public LinkVisitor( final int index ) { | |
| 52 | this.offset = index; | |
| 53 | } | |
| 54 | ||
| 55 | public Link process( final Node root ) { | |
| 56 | getVisitor().visit( root ); | |
| 57 | return getLink(); | |
| 58 | } | |
| 59 | ||
| 60 | /** | |
| 61 | * | |
| 62 | * @param link Not null. | |
| 63 | */ | |
| 64 | private void visit( final Link link ) { | |
| 65 | final int began = link.getStartOffset(); | |
| 66 | final int ended = link.getEndOffset(); | |
| 67 | final int index = getOffset(); | |
| 68 | ||
| 69 | if( index >= began && index <= ended ) { | |
| 70 | setLink( link ); | |
| 71 | } | |
| 72 | } | |
| 73 | ||
| 74 | private synchronized NodeVisitor getVisitor() { | |
| 75 | if( this.visitor == null ) { | |
| 76 | this.visitor = createVisitor(); | |
| 77 | } | |
| 78 | ||
| 79 | return this.visitor; | |
| 80 | } | |
| 81 | ||
| 82 | protected NodeVisitor createVisitor() { | |
| 83 | return new NodeVisitor( | |
| 84 | new VisitHandler<>( Link.class, LinkVisitor.this::visit ) ); | |
| 85 | } | |
| 86 | ||
| 87 | private Link getLink() { | |
| 88 | return this.link; | |
| 89 | } | |
| 90 | ||
| 91 | private void setLink( final Link link ) { | |
| 92 | this.link = link; | |
| 93 | } | |
| 94 | ||
| 95 | public int getOffset() { | |
| 96 | return this.offset; | |
| 97 | } | |
| 98 | } | |
| 99 | 1 |
| 1 | /* | |
| 2 | * Copyright 2016 Karl Tauber and White Magic Software, Ltd. | |
| 3 | * | |
| 4 | * All rights reserved. | |
| 5 | * | |
| 6 | * Redistribution and use in source and binary forms, with or without | |
| 7 | * modification, are permitted provided that the following conditions are met: | |
| 8 | * | |
| 9 | * o Redistributions of source code must retain the above copyright | |
| 10 | * notice, this list of conditions and the following disclaimer. | |
| 11 | * | |
| 12 | * o Redistributions in binary form must reproduce the above copyright | |
| 13 | * notice, this list of conditions and the following disclaimer in the | |
| 14 | * documentation and/or other materials provided with the distribution. | |
| 15 | * | |
| 16 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | |
| 17 | * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | |
| 18 | * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | |
| 19 | * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT | |
| 20 | * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | |
| 21 | * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT | |
| 22 | * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | |
| 23 | * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | |
| 24 | * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | |
| 25 | * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | |
| 26 | * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | |
| 27 | */ | |
| 28 | package com.scrivenvar.editor; | |
| 29 | ||
| 30 | import static com.scrivenvar.Constants.STYLESHEET_EDITOR; | |
| 31 | import com.scrivenvar.dialogs.ImageDialog; | |
| 32 | import com.scrivenvar.dialogs.LinkDialog; | |
| 33 | import com.scrivenvar.processors.MarkdownProcessor; | |
| 34 | import static com.scrivenvar.util.Utils.ltrim; | |
| 35 | import static com.scrivenvar.util.Utils.rtrim; | |
| 36 | import com.vladsch.flexmark.ast.Link; | |
| 37 | import com.vladsch.flexmark.ast.Node; | |
| 38 | import java.nio.file.Path; | |
| 39 | import java.util.regex.Matcher; | |
| 40 | import java.util.regex.Pattern; | |
| 41 | import javafx.beans.value.ObservableValue; | |
| 42 | import javafx.scene.control.Dialog; | |
| 43 | import javafx.scene.control.IndexRange; | |
| 44 | import static javafx.scene.input.KeyCode.ENTER; | |
| 45 | import javafx.scene.input.KeyEvent; | |
| 46 | import javafx.stage.Window; | |
| 47 | import org.fxmisc.richtext.StyleClassedTextArea; | |
| 48 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 49 | ||
| 50 | /** | |
| 51 | * Markdown editor pane. | |
| 52 | * | |
| 53 | * @author Karl Tauber and White Magic Software, Ltd. | |
| 54 | */ | |
| 55 | public class MarkdownEditorPane extends EditorPane { | |
| 56 | ||
| 57 | private static final Pattern AUTO_INDENT_PATTERN = Pattern.compile( | |
| 58 | "(\\s*[*+-]\\s+|\\s*[0-9]+\\.\\s+|\\s+)(.*)" ); | |
| 59 | ||
| 60 | public MarkdownEditorPane() { | |
| 61 | initEditor(); | |
| 62 | } | |
| 63 | ||
| 64 | private void initEditor() { | |
| 65 | final StyleClassedTextArea textArea = getEditor(); | |
| 66 | ||
| 67 | textArea.setWrapText( true ); | |
| 68 | textArea.getStyleClass().add( "markdown-editor" ); | |
| 69 | textArea.getStylesheets().add( STYLESHEET_EDITOR ); | |
| 70 | ||
| 71 | addEventListener( keyPressed( ENTER ), this::enterPressed ); | |
| 72 | ||
| 73 | // TODO: Wait for implementation that allows cutting lines, not paragraphs. | |
| 74 | // addEventListener( keyPressed( X, SHORTCUT_DOWN ), this::cutLine ); | |
| 75 | } | |
| 76 | ||
| 77 | public ObservableValue<String> markdownProperty() { | |
| 78 | return getEditor().textProperty(); | |
| 79 | } | |
| 80 | ||
| 81 | private void enterPressed( final KeyEvent e ) { | |
| 82 | final StyleClassedTextArea textArea = getEditor(); | |
| 83 | final String currentLine = textArea.getText( textArea.getCurrentParagraph() ); | |
| 84 | final Matcher matcher = AUTO_INDENT_PATTERN.matcher( currentLine ); | |
| 85 | ||
| 86 | String newText = "\n"; | |
| 87 | ||
| 88 | if( matcher.matches() ) { | |
| 89 | if( !matcher.group( 2 ).isEmpty() ) { | |
| 90 | // indent new line with same whitespace characters and list markers as current line | |
| 91 | newText = newText.concat( matcher.group( 1 ) ); | |
| 92 | } else { | |
| 93 | // current line contains only whitespace characters and list markers | |
| 94 | // --> empty current line | |
| 95 | final int caretPosition = textArea.getCaretPosition(); | |
| 96 | textArea.selectRange( caretPosition - currentLine.length(), caretPosition ); | |
| 97 | } | |
| 98 | } | |
| 99 | ||
| 100 | textArea.replaceSelection( newText ); | |
| 101 | } | |
| 102 | ||
| 103 | public void surroundSelection( final String leading, final String trailing ) { | |
| 104 | surroundSelection( leading, trailing, null ); | |
| 105 | } | |
| 106 | ||
| 107 | public void surroundSelection( String leading, String trailing, final String hint ) { | |
| 108 | final StyleClassedTextArea textArea = getEditor(); | |
| 109 | ||
| 110 | // Note: not using textArea.insertText() to insert leading and trailing | |
| 111 | // because this would add two changes to undo history | |
| 112 | final IndexRange selection = textArea.getSelection(); | |
| 113 | int start = selection.getStart(); | |
| 114 | int end = selection.getEnd(); | |
| 115 | ||
| 116 | final String selectedText = textArea.getSelectedText(); | |
| 117 | ||
| 118 | // remove leading and trailing whitespaces from selected text | |
| 119 | String trimmedSelectedText = selectedText.trim(); | |
| 120 | if( trimmedSelectedText.length() < selectedText.length() ) { | |
| 121 | start += selectedText.indexOf( trimmedSelectedText ); | |
| 122 | end = start + trimmedSelectedText.length(); | |
| 123 | } | |
| 124 | ||
| 125 | // remove leading whitespaces from leading text if selection starts at zero | |
| 126 | if( start == 0 ) { | |
| 127 | leading = ltrim( leading ); | |
| 128 | } | |
| 129 | ||
| 130 | // remove trailing whitespaces from trailing text if selection ends at text end | |
| 131 | if( end == textArea.getLength() ) { | |
| 132 | trailing = rtrim( trailing ); | |
| 133 | } | |
| 134 | ||
| 135 | // remove leading line separators from leading text | |
| 136 | // if there are line separators before the selected text | |
| 137 | if( leading.startsWith( "\n" ) ) { | |
| 138 | for( int i = start - 1; i >= 0 && leading.startsWith( "\n" ); i-- ) { | |
| 139 | if( !"\n".equals( textArea.getText( i, i + 1 ) ) ) { | |
| 140 | break; | |
| 141 | } | |
| 142 | leading = leading.substring( 1 ); | |
| 143 | } | |
| 144 | } | |
| 145 | ||
| 146 | // remove trailing line separators from trailing or leading text | |
| 147 | // if there are line separators after the selected text | |
| 148 | final boolean trailingIsEmpty = trailing.isEmpty(); | |
| 149 | String str = trailingIsEmpty ? leading : trailing; | |
| 150 | ||
| 151 | if( str.endsWith( "\n" ) ) { | |
| 152 | final int length = textArea.getLength(); | |
| 153 | ||
| 154 | for( int i = end; i < length && str.endsWith( "\n" ); i++ ) { | |
| 155 | if( !"\n".equals( textArea.getText( i, i + 1 ) ) ) { | |
| 156 | break; | |
| 157 | } | |
| 158 | ||
| 159 | str = str.substring( 0, str.length() - 1 ); | |
| 160 | } | |
| 161 | ||
| 162 | if( trailingIsEmpty ) { | |
| 163 | leading = str; | |
| 164 | } else { | |
| 165 | trailing = str; | |
| 166 | } | |
| 167 | } | |
| 168 | ||
| 169 | int selStart = start + leading.length(); | |
| 170 | int selEnd = end + leading.length(); | |
| 171 | ||
| 172 | // insert hint text if selection is empty | |
| 173 | if( hint != null && trimmedSelectedText.isEmpty() ) { | |
| 174 | trimmedSelectedText = hint; | |
| 175 | selEnd = selStart + hint.length(); | |
| 176 | } | |
| 177 | ||
| 178 | // prevent undo merging with previous text entered by user | |
| 179 | getUndoManager().preventMerge(); | |
| 180 | ||
| 181 | // replace text and update selection | |
| 182 | textArea.replaceText( start, end, leading + trimmedSelectedText + trailing ); | |
| 183 | textArea.selectRange( selStart, selEnd ); | |
| 184 | } | |
| 185 | ||
| 186 | /** | |
| 187 | * Returns one of: selected text, word under cursor, or parsed hyperlink from | |
| 188 | * the markdown AST. | |
| 189 | * | |
| 190 | * @return | |
| 191 | */ | |
| 192 | private HyperlinkModel getHyperlink() { | |
| 193 | final StyleClassedTextArea textArea = getEditor(); | |
| 194 | final String selectedText = textArea.getSelectedText(); | |
| 195 | ||
| 196 | // Get the current paragraph, convert to Markdown nodes. | |
| 197 | final MarkdownProcessor mp = new MarkdownProcessor( null ); | |
| 198 | final int p = textArea.getCurrentParagraph(); | |
| 199 | final String paragraph = textArea.getText( p ); | |
| 200 | final Node node = mp.toNode( paragraph ); | |
| 201 | final LinkVisitor visitor = new LinkVisitor( textArea.getCaretColumn() ); | |
| 202 | final Link link = visitor.process( node ); | |
| 203 | ||
| 204 | if( link != null ) { | |
| 205 | textArea.selectRange( p, link.getStartOffset(), p, link.getEndOffset() ); | |
| 206 | } | |
| 207 | ||
| 208 | final HyperlinkModel model = createHyperlinkModel( | |
| 209 | link, selectedText, "https://website.com" | |
| 210 | ); | |
| 211 | ||
| 212 | return model; | |
| 213 | } | |
| 214 | ||
| 215 | private HyperlinkModel createHyperlinkModel( | |
| 216 | final Link link, final String selection, final String url ) { | |
| 217 | ||
| 218 | return link == null | |
| 219 | ? new HyperlinkModel( selection, url ) | |
| 220 | : new HyperlinkModel( link ); | |
| 221 | } | |
| 222 | ||
| 223 | private Path getParentPath() { | |
| 224 | final Path parentPath = getPath(); | |
| 225 | return (parentPath != null) ? parentPath.getParent() : null; | |
| 226 | } | |
| 227 | ||
| 228 | private Dialog<String> createLinkDialog() { | |
| 229 | return new LinkDialog( getWindow(), getHyperlink(), getParentPath() ); | |
| 230 | } | |
| 231 | ||
| 232 | private Dialog<String> createImageDialog() { | |
| 233 | return new ImageDialog( getWindow(), getParentPath() ); | |
| 234 | } | |
| 235 | ||
| 236 | private void insertObject( final Dialog<String> dialog ) { | |
| 237 | dialog.showAndWait().ifPresent( result -> { | |
| 238 | getEditor().replaceSelection( result ); | |
| 239 | } ); | |
| 240 | } | |
| 241 | ||
| 242 | public void insertLink() { | |
| 243 | insertObject( createLinkDialog() ); | |
| 244 | } | |
| 245 | ||
| 246 | public void insertImage() { | |
| 247 | insertObject( createImageDialog() ); | |
| 248 | } | |
| 249 | ||
| 250 | private Window getWindow() { | |
| 251 | return getScrollPane().getScene().getWindow(); | |
| 252 | } | |
| 253 | } | |
| 254 | 1 |
| 1 | /* | |
| 2 | * Copyright 2016 White Magic Software, Ltd. | |
| 3 | * | |
| 4 | * All rights reserved. | |
| 5 | * | |
| 6 | * Redistribution and use in source and binary forms, with or without | |
| 7 | * modification, are permitted provided that the following conditions are met: | |
| 8 | * | |
| 9 | * o Redistributions of source code must retain the above copyright | |
| 10 | * notice, this list of conditions and the following disclaimer. | |
| 11 | * | |
| 12 | * o Redistributions in binary form must reproduce the above copyright | |
| 13 | * notice, this list of conditions and the following disclaimer in the | |
| 14 | * documentation and/or other materials provided with the distribution. | |
| 15 | * | |
| 16 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | |
| 17 | * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | |
| 18 | * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | |
| 19 | * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT | |
| 20 | * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | |
| 21 | * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT | |
| 22 | * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | |
| 23 | * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | |
| 24 | * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | |
| 25 | * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | |
| 26 | * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | |
| 27 | */ | |
| 28 | package com.scrivenvar.editor; | |
| 29 | ||
| 30 | import static com.scrivenvar.Constants.SEPARATOR; | |
| 31 | import com.scrivenvar.FileEditorTabPane; | |
| 32 | import com.scrivenvar.Services; | |
| 33 | import com.scrivenvar.decorators.VariableDecorator; | |
| 34 | import com.scrivenvar.decorators.YamlVariableDecorator; | |
| 35 | import com.scrivenvar.definition.DefinitionPane; | |
| 36 | import static com.scrivenvar.definition.Lists.getFirst; | |
| 37 | import static com.scrivenvar.definition.Lists.getLast; | |
| 38 | import com.scrivenvar.service.Settings; | |
| 39 | import com.scrivenvar.ui.VariableTreeItem; | |
| 40 | import static java.lang.Character.isSpaceChar; | |
| 41 | import static java.lang.Character.isWhitespace; | |
| 42 | import static java.lang.Math.min; | |
| 43 | import java.util.function.Consumer; | |
| 44 | import javafx.collections.ObservableList; | |
| 45 | import javafx.event.Event; | |
| 46 | import javafx.scene.control.IndexRange; | |
| 47 | import javafx.scene.control.TreeItem; | |
| 48 | import javafx.scene.input.InputEvent; | |
| 49 | import javafx.scene.input.KeyCode; | |
| 50 | import static javafx.scene.input.KeyCode.AT; | |
| 51 | import static javafx.scene.input.KeyCode.DIGIT2; | |
| 52 | import static javafx.scene.input.KeyCode.ENTER; | |
| 53 | import static javafx.scene.input.KeyCode.MINUS; | |
| 54 | import static javafx.scene.input.KeyCode.SPACE; | |
| 55 | import static javafx.scene.input.KeyCombination.CONTROL_DOWN; | |
| 56 | import static javafx.scene.input.KeyCombination.SHIFT_DOWN; | |
| 57 | import javafx.scene.input.KeyEvent; | |
| 58 | import org.fxmisc.richtext.StyledTextArea; | |
| 59 | import org.fxmisc.wellbehaved.event.EventPattern; | |
| 60 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 61 | import static org.fxmisc.wellbehaved.event.EventPattern.keyTyped; | |
| 62 | import org.fxmisc.wellbehaved.event.InputMap; | |
| 63 | import static org.fxmisc.wellbehaved.event.InputMap.consume; | |
| 64 | import static org.fxmisc.wellbehaved.event.InputMap.sequence; | |
| 65 | import static com.scrivenvar.definition.Lists.getFirst; | |
| 66 | import static com.scrivenvar.definition.Lists.getLast; | |
| 67 | import static java.lang.Character.isSpaceChar; | |
| 68 | import static java.lang.Character.isWhitespace; | |
| 69 | import static java.lang.Math.min; | |
| 70 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 71 | import static org.fxmisc.wellbehaved.event.EventPattern.keyTyped; | |
| 72 | import static org.fxmisc.wellbehaved.event.InputMap.consume; | |
| 73 | import static com.scrivenvar.definition.Lists.getFirst; | |
| 74 | import static com.scrivenvar.definition.Lists.getLast; | |
| 75 | import static java.lang.Character.isSpaceChar; | |
| 76 | import static java.lang.Character.isWhitespace; | |
| 77 | import static java.lang.Math.min; | |
| 78 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 79 | import static org.fxmisc.wellbehaved.event.EventPattern.keyTyped; | |
| 80 | import static org.fxmisc.wellbehaved.event.InputMap.consume; | |
| 81 | import static com.scrivenvar.definition.Lists.getFirst; | |
| 82 | import static com.scrivenvar.definition.Lists.getLast; | |
| 83 | import static java.lang.Character.isSpaceChar; | |
| 84 | import static java.lang.Character.isWhitespace; | |
| 85 | import static java.lang.Math.min; | |
| 86 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 87 | import static org.fxmisc.wellbehaved.event.EventPattern.keyTyped; | |
| 88 | import static org.fxmisc.wellbehaved.event.InputMap.consume; | |
| 89 | import static com.scrivenvar.definition.Lists.getFirst; | |
| 90 | import static com.scrivenvar.definition.Lists.getLast; | |
| 91 | import static java.lang.Character.isSpaceChar; | |
| 92 | import static java.lang.Character.isWhitespace; | |
| 93 | import static java.lang.Math.min; | |
| 94 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 95 | import static org.fxmisc.wellbehaved.event.EventPattern.keyTyped; | |
| 96 | import static org.fxmisc.wellbehaved.event.InputMap.consume; | |
| 97 | import static com.scrivenvar.definition.Lists.getFirst; | |
| 98 | import static com.scrivenvar.definition.Lists.getLast; | |
| 99 | import static java.lang.Character.isSpaceChar; | |
| 100 | import static java.lang.Character.isWhitespace; | |
| 101 | import static java.lang.Math.min; | |
| 102 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 103 | import static org.fxmisc.wellbehaved.event.EventPattern.keyTyped; | |
| 104 | import static org.fxmisc.wellbehaved.event.InputMap.consume; | |
| 105 | import static com.scrivenvar.definition.Lists.getFirst; | |
| 106 | import static com.scrivenvar.definition.Lists.getLast; | |
| 107 | import static java.lang.Character.isSpaceChar; | |
| 108 | import static java.lang.Character.isWhitespace; | |
| 109 | import static java.lang.Math.min; | |
| 110 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 111 | import static org.fxmisc.wellbehaved.event.EventPattern.keyTyped; | |
| 112 | import static org.fxmisc.wellbehaved.event.InputMap.consume; | |
| 113 | import static com.scrivenvar.definition.Lists.getFirst; | |
| 114 | import static com.scrivenvar.definition.Lists.getLast; | |
| 115 | import static java.lang.Character.isSpaceChar; | |
| 116 | import static java.lang.Character.isWhitespace; | |
| 117 | import static java.lang.Math.min; | |
| 118 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 119 | import static org.fxmisc.wellbehaved.event.EventPattern.keyTyped; | |
| 120 | import static org.fxmisc.wellbehaved.event.InputMap.consume; | |
| 121 | ||
| 122 | /** | |
| 123 | * Provides the logic for injecting variable names within the editor. | |
| 124 | * | |
| 125 | * @author White Magic Software, Ltd. | |
| 126 | */ | |
| 127 | public class VariableNameInjector { | |
| 128 | ||
| 129 | public static final int DEFAULT_MAX_VAR_LENGTH = 64; | |
| 130 | ||
| 131 | private static final int NO_DIFFERENCE = -1; | |
| 132 | ||
| 133 | private final Settings settings = Services.load( Settings.class ); | |
| 134 | ||
| 135 | /** | |
| 136 | * Used to capture keyboard events once the user presses @. | |
| 137 | */ | |
| 138 | private InputMap<InputEvent> keyboardMap; | |
| 139 | ||
| 140 | private FileEditorTabPane fileEditorPane; | |
| 141 | private DefinitionPane definitionPane; | |
| 142 | ||
| 143 | /** | |
| 144 | * Position of the variable in the text when in variable mode (0 by default). | |
| 145 | */ | |
| 146 | private int initialCaretPosition; | |
| 147 | ||
| 148 | public VariableNameInjector( | |
| 149 | final FileEditorTabPane editorPane, | |
| 150 | final DefinitionPane definitionPane ) { | |
| 151 | setFileEditorPane( editorPane ); | |
| 152 | setDefinitionPane( definitionPane ); | |
| 153 | ||
| 154 | initKeyboardEventListeners(); | |
| 155 | } | |
| 156 | ||
| 157 | /** | |
| 158 | * Traps keys for performing various short-cut tasks, such as @-mode variable | |
| 159 | * insertion and control+space for variable autocomplete. | |
| 160 | * | |
| 161 | * @ key is pressed, a new keyboard map is inserted in place of the current | |
| 162 | * map -- this class goes into "variable edit mode" (a.k.a. vMode). | |
| 163 | * | |
| 164 | * @see createKeyboardMap() | |
| 165 | */ | |
| 166 | private void initKeyboardEventListeners() { | |
| 167 | addEventListener( keyPressed( SPACE, CONTROL_DOWN ), this::autocomplete ); | |
| 168 | ||
| 169 | // @ key in Linux? | |
| 170 | addEventListener( keyPressed( DIGIT2, SHIFT_DOWN ), this::vMode ); | |
| 171 | // @ key in Windows. | |
| 172 | addEventListener( keyPressed( AT ), this::vMode ); | |
| 173 | } | |
| 174 | ||
| 175 | /** | |
| 176 | * The @ symbol is a short-cut to inserting a YAML variable reference. | |
| 177 | * | |
| 178 | * @param e Superfluous information about the key that was pressed. | |
| 179 | */ | |
| 180 | private void vMode( KeyEvent e ) { | |
| 181 | setInitialCaretPosition(); | |
| 182 | vModeStart(); | |
| 183 | vModeAutocomplete(); | |
| 184 | } | |
| 185 | ||
| 186 | /** | |
| 187 | * Receives key presses until the user completes the variable selection. This | |
| 188 | * allows the arrow keys to be used for selecting variables. | |
| 189 | * | |
| 190 | * @param e The key that was pressed. | |
| 191 | */ | |
| 192 | private void vModeKeyPressed( KeyEvent e ) { | |
| 193 | final KeyCode keyCode = e.getCode(); | |
| 194 | ||
| 195 | switch( keyCode ) { | |
| 196 | case BACK_SPACE: | |
| 197 | // Don't decorate the variable upon exiting vMode. | |
| 198 | vModeBackspace(); | |
| 199 | break; | |
| 200 | ||
| 201 | case ESCAPE: | |
| 202 | // Don't decorate the variable upon exiting vMode. | |
| 203 | vModeStop(); | |
| 204 | break; | |
| 205 | ||
| 206 | case ENTER: | |
| 207 | case PERIOD: | |
| 208 | case RIGHT: | |
| 209 | case END: | |
| 210 | // Stop at a leaf node, ENTER means accept. | |
| 211 | if( vModeConditionalComplete() && keyCode == ENTER ) { | |
| 212 | vModeStop(); | |
| 213 | ||
| 214 | // Decorate the variable upon exiting vMode. | |
| 215 | decorateVariable(); | |
| 216 | } | |
| 217 | break; | |
| 218 | ||
| 219 | case UP: | |
| 220 | cyclePathPrev(); | |
| 221 | break; | |
| 222 | ||
| 223 | case DOWN: | |
| 224 | cyclePathNext(); | |
| 225 | break; | |
| 226 | ||
| 227 | default: | |
| 228 | vModeFilterKeyPressed( e ); | |
| 229 | break; | |
| 230 | } | |
| 231 | ||
| 232 | e.consume(); | |
| 233 | } | |
| 234 | ||
| 235 | private void vModeBackspace() { | |
| 236 | deleteSelection(); | |
| 237 | ||
| 238 | // Break out of variable mode by back spacing to the original position. | |
| 239 | if( getCurrentCaretPosition() > getInitialCaretPosition() ) { | |
| 240 | vModeAutocomplete(); | |
| 241 | } else { | |
| 242 | vModeStop(); | |
| 243 | } | |
| 244 | } | |
| 245 | ||
| 246 | /** | |
| 247 | * Updates the text with the path selected (or typed) by the user. | |
| 248 | */ | |
| 249 | private void vModeAutocomplete() { | |
| 250 | final TreeItem<String> node = getCurrentNode(); | |
| 251 | ||
| 252 | if( !node.isLeaf() ) { | |
| 253 | final String word = getLastPathWord(); | |
| 254 | final String label = node.getValue(); | |
| 255 | final int delta = difference( label, word ); | |
| 256 | final String remainder = delta == NO_DIFFERENCE | |
| 257 | ? label | |
| 258 | : label.substring( delta ); | |
| 259 | ||
| 260 | final StyledTextArea textArea = getEditor(); | |
| 261 | final int posBegan = getCurrentCaretPosition(); | |
| 262 | final int posEnded = posBegan + remainder.length(); | |
| 263 | ||
| 264 | textArea.replaceSelection( remainder ); | |
| 265 | ||
| 266 | if( posEnded - posBegan > 0 ) { | |
| 267 | textArea.selectRange( posEnded, posBegan ); | |
| 268 | } | |
| 269 | ||
| 270 | expand( node ); | |
| 271 | } | |
| 272 | } | |
| 273 | ||
| 274 | /** | |
| 275 | * Only variable name keys can pass through the filter. This is called when | |
| 276 | * the user presses a key. | |
| 277 | * | |
| 278 | * @param e The key that was pressed. | |
| 279 | */ | |
| 280 | private void vModeFilterKeyPressed( final KeyEvent e ) { | |
| 281 | if( isVariableNameKey( e ) ) { | |
| 282 | typed( e.getText() ); | |
| 283 | } | |
| 284 | } | |
| 285 | ||
| 286 | /** | |
| 287 | * Performs an autocomplete depending on whether the user has finished typing | |
| 288 | * in a word. If there is a selected range, then this will complete the most | |
| 289 | * recent word and jump to the next child. | |
| 290 | * | |
| 291 | * @return true The auto-completed node was a terminal node. | |
| 292 | */ | |
| 293 | private boolean vModeConditionalComplete() { | |
| 294 | acceptPath(); | |
| 295 | ||
| 296 | final TreeItem<String> node = getCurrentNode(); | |
| 297 | final boolean terminal = isTerminal( node ); | |
| 298 | ||
| 299 | if( !terminal ) { | |
| 300 | typed( SEPARATOR ); | |
| 301 | } | |
| 302 | ||
| 303 | return terminal; | |
| 304 | } | |
| 305 | ||
| 306 | /** | |
| 307 | * Pressing control+space will find a node that matches the current word and | |
| 308 | * substitute the YAML variable reference. This is called when the user is not | |
| 309 | * editing in vMode. | |
| 310 | * | |
| 311 | * @param e Ignored -- it can only be Ctrl+Space. | |
| 312 | */ | |
| 313 | private void autocomplete( KeyEvent e ) { | |
| 314 | final String paragraph = getCaretParagraph(); | |
| 315 | final int[] boundaries = getWordBoundaries( paragraph ); | |
| 316 | final String word = paragraph.substring( boundaries[ 0 ], boundaries[ 1 ] ); | |
| 317 | ||
| 318 | final VariableTreeItem<String> leaf = findLeaf( word ); | |
| 319 | ||
| 320 | if( leaf != null ) { | |
| 321 | replaceText( boundaries[ 0 ], boundaries[ 1 ], leaf.toPath() ); | |
| 322 | decorateVariable(); | |
| 323 | expand( leaf ); | |
| 324 | } | |
| 325 | } | |
| 326 | ||
| 327 | /** | |
| 328 | * Called when autocomplete finishes on a valid leaf or when the user presses | |
| 329 | * Enter to finish manual autocomplete. | |
| 330 | */ | |
| 331 | private void decorateVariable() { | |
| 332 | // A little bit of duplication... | |
| 333 | final String paragraph = getCaretParagraph(); | |
| 334 | final int[] boundaries = getWordBoundaries( paragraph ); | |
| 335 | final String old = paragraph.substring( boundaries[ 0 ], boundaries[ 1 ] ); | |
| 336 | ||
| 337 | final String newVariable = getVariableDecorator().decorate( old ); | |
| 338 | ||
| 339 | final int posEnded = getCurrentCaretPosition(); | |
| 340 | final int posBegan = posEnded - old.length(); | |
| 341 | ||
| 342 | getEditor().replaceText( posBegan, posEnded, newVariable ); | |
| 343 | } | |
| 344 | ||
| 345 | /** | |
| 346 | * Updates the text at the given position within the current paragraph. | |
| 347 | * | |
| 348 | * @param posBegan The starting index in the paragraph text to replace. | |
| 349 | * @param posEnded The ending index in the paragraph text to replace. | |
| 350 | * @param text Overwrite the paragraph substring with this text. | |
| 351 | */ | |
| 352 | private void replaceText( | |
| 353 | final int posBegan, final int posEnded, final String text ) { | |
| 354 | final int p = getCurrentParagraph(); | |
| 355 | ||
| 356 | getEditor().replaceText( p, posBegan, p, posEnded, text ); | |
| 357 | } | |
| 358 | ||
| 359 | /** | |
| 360 | * Returns the caret's current paragraph position. | |
| 361 | * | |
| 362 | * @return A number greater than or equal to 0. | |
| 363 | */ | |
| 364 | private int getCurrentParagraph() { | |
| 365 | return getEditor().getCurrentParagraph(); | |
| 366 | } | |
| 367 | ||
| 368 | /** | |
| 369 | * Returns current word boundary indexes into the current paragraph, including | |
| 370 | * punctuation. | |
| 371 | * | |
| 372 | * @param p The paragraph wherein to hunt word boundaries. | |
| 373 | * @param offset The offset into the paragraph to begin scanning left and | |
| 374 | * right. | |
| 375 | * | |
| 376 | * @return The starting and ending index of the word closest to the caret. | |
| 377 | */ | |
| 378 | private int[] getWordBoundaries( final String p, final int offset ) { | |
| 379 | // Remove dashes, but retain hyphens. Retain same number of characters | |
| 380 | // to preserve relative indexes. | |
| 381 | final String paragraph = p.replace( "---", " " ).replace( "--", " " ); | |
| 382 | ||
| 383 | return getWordAt( paragraph, offset ); | |
| 384 | } | |
| 385 | ||
| 386 | /** | |
| 387 | * Helper method to get the word boundaries for the current paragraph. | |
| 388 | * | |
| 389 | * @param paragraph | |
| 390 | * | |
| 391 | * @return | |
| 392 | */ | |
| 393 | private int[] getWordBoundaries( final String paragraph ) { | |
| 394 | return getWordBoundaries( paragraph, getCurrentCaretColumn() ); | |
| 395 | } | |
| 396 | ||
| 397 | /** | |
| 398 | * Given an arbitrary offset into a string, this returns the word at that | |
| 399 | * index. The inputs and outputs include: | |
| 400 | * | |
| 401 | * <ul> | |
| 402 | * <li>surrounded by space: <code>hello | world!</code> ("");</li> | |
| 403 | * <li>end of word: <code>hello| world!</code> ("hello");</li> | |
| 404 | * <li>start of a word: <code>hello |world!</code> ("world!");</li> | |
| 405 | * <li>within a word: <code>hello wo|rld!</code> ("world!");</li> | |
| 406 | * <li>end of a paragraph: <code>hello world!|</code> ("world!");</li> | |
| 407 | * <li>start of a paragraph: <code>|hello world!</code> ("hello!"); or</li> | |
| 408 | * <li>after punctuation: <code>hello world!|</code> ("world!").</li> | |
| 409 | * </ul> | |
| 410 | * | |
| 411 | * @param p The string to scan for a word. | |
| 412 | * @param offset The offset within s to begin searching for the nearest word | |
| 413 | * boundary, must not be out of bounds of s. | |
| 414 | * | |
| 415 | * @return The word in s at the offset. | |
| 416 | * | |
| 417 | * @see getWordBegan( String, int ) | |
| 418 | * @see getWordEnded( String, int ) | |
| 419 | */ | |
| 420 | private int[] getWordAt( final String p, final int offset ) { | |
| 421 | return new int[]{ getWordBegan( p, offset ), getWordEnded( p, offset ) }; | |
| 422 | } | |
| 423 | ||
| 424 | /** | |
| 425 | * Returns the index into s where a word begins. | |
| 426 | * | |
| 427 | * @param s Never null. | |
| 428 | * @param offset Index into s to begin searching backwards for a word | |
| 429 | * boundary. | |
| 430 | * | |
| 431 | * @return The index where a word begins. | |
| 432 | */ | |
| 433 | private int getWordBegan( final String s, int offset ) { | |
| 434 | while( offset > 0 && isBoundary( s.charAt( offset - 1 ) ) ) { | |
| 435 | offset--; | |
| 436 | } | |
| 437 | ||
| 438 | return offset; | |
| 439 | } | |
| 440 | ||
| 441 | /** | |
| 442 | * Returns the index into s where a word ends. | |
| 443 | * | |
| 444 | * @param s Never null. | |
| 445 | * @param offset Index into s to begin searching forwards for a word boundary. | |
| 446 | * | |
| 447 | * @return The index where a word ends. | |
| 448 | */ | |
| 449 | private int getWordEnded( final String s, int offset ) { | |
| 450 | final int length = s.length(); | |
| 451 | ||
| 452 | while( offset < length && isBoundary( s.charAt( offset ) ) ) { | |
| 453 | offset++; | |
| 454 | } | |
| 455 | ||
| 456 | return offset; | |
| 457 | } | |
| 458 | ||
| 459 | /** | |
| 460 | * Returns true if the given character can be reasonably expected to be part | |
| 461 | * of a word, including punctuation marks. | |
| 462 | * | |
| 463 | * @param c The character to compare. | |
| 464 | * | |
| 465 | * @return false The character is a space character. | |
| 466 | */ | |
| 467 | private boolean isBoundary( final char c ) { | |
| 468 | return !isSpaceChar( c ); | |
| 469 | } | |
| 470 | ||
| 471 | /** | |
| 472 | * Returns the text for the paragraph that contains the caret. | |
| 473 | * | |
| 474 | * @return A non-null string, possibly empty. | |
| 475 | */ | |
| 476 | private String getCaretParagraph() { | |
| 477 | return getEditor().getText( getCurrentParagraph() ); | |
| 478 | } | |
| 479 | ||
| 480 | /** | |
| 481 | * Returns true if the node has children that can be selected (i.e., any | |
| 482 | * non-leaves). | |
| 483 | * | |
| 484 | * @param <T> The type that the TreeItem contains. | |
| 485 | * @param node The node to test for terminality. | |
| 486 | * | |
| 487 | * @return true The node has one branch and its a leaf. | |
| 488 | */ | |
| 489 | private <T> boolean isTerminal( final TreeItem<T> node ) { | |
| 490 | final ObservableList<TreeItem<T>> branches = node.getChildren(); | |
| 491 | ||
| 492 | return branches.size() == 1 && branches.get( 0 ).isLeaf(); | |
| 493 | } | |
| 494 | ||
| 495 | /** | |
| 496 | * Inserts text that the user typed at the current caret position, then | |
| 497 | * performs an autocomplete for the variable name. | |
| 498 | * | |
| 499 | * @param text The text to insert, never null. | |
| 500 | */ | |
| 501 | private void typed( final String text ) { | |
| 502 | getEditor().replaceSelection( text ); | |
| 503 | vModeAutocomplete(); | |
| 504 | } | |
| 505 | ||
| 506 | /** | |
| 507 | * Called when the user presses either End or Enter key. | |
| 508 | */ | |
| 509 | private void acceptPath() { | |
| 510 | final IndexRange range = getSelectionRange(); | |
| 511 | ||
| 512 | if( range != null ) { | |
| 513 | final int rangeEnd = range.getEnd(); | |
| 514 | final StyledTextArea textArea = getEditor(); | |
| 515 | textArea.deselect(); | |
| 516 | textArea.moveTo( rangeEnd ); | |
| 517 | } | |
| 518 | } | |
| 519 | ||
| 520 | /** | |
| 521 | * Replaces the entirety of the existing path (from the initial caret | |
| 522 | * position) with the given path. | |
| 523 | * | |
| 524 | * @param oldPath The path to replace. | |
| 525 | * @param newPath The replacement path. | |
| 526 | */ | |
| 527 | private void replacePath( final String oldPath, final String newPath ) { | |
| 528 | final StyledTextArea textArea = getEditor(); | |
| 529 | final int posBegan = getInitialCaretPosition(); | |
| 530 | final int posEnded = posBegan + oldPath.length(); | |
| 531 | ||
| 532 | textArea.deselect(); | |
| 533 | textArea.replaceText( posBegan, posEnded, newPath ); | |
| 534 | } | |
| 535 | ||
| 536 | /** | |
| 537 | * Called when the user presses the Backspace key. | |
| 538 | */ | |
| 539 | private void deleteSelection() { | |
| 540 | final StyledTextArea textArea = getEditor(); | |
| 541 | textArea.replaceSelection( "" ); | |
| 542 | textArea.deletePreviousChar(); | |
| 543 | } | |
| 544 | ||
| 545 | /** | |
| 546 | * Cycles the selected text through the nodes. | |
| 547 | * | |
| 548 | * @param direction true - next; false - previous | |
| 549 | */ | |
| 550 | private void cycleSelection( final boolean direction ) { | |
| 551 | final TreeItem<String> node = getCurrentNode(); | |
| 552 | ||
| 553 | // Find the sibling for the current selection and replace the current | |
| 554 | // selection with the sibling's value | |
| 555 | TreeItem< String> cycled = direction | |
| 556 | ? node.nextSibling() | |
| 557 | : node.previousSibling(); | |
| 558 | ||
| 559 | // When cycling at the end (or beginning) of the list, jump to the first | |
| 560 | // (or last) sibling depending on the cycle direction. | |
| 561 | if( cycled == null ) { | |
| 562 | cycled = direction ? getFirstSibling( node ) : getLastSibling( node ); | |
| 563 | } | |
| 564 | ||
| 565 | final String path = getCurrentPath(); | |
| 566 | final String cycledWord = cycled.getValue(); | |
| 567 | final String word = getLastPathWord(); | |
| 568 | final int index = path.indexOf( word ); | |
| 569 | final String cycledPath = path.substring( 0, index ) + cycledWord; | |
| 570 | ||
| 571 | expand( cycled ); | |
| 572 | replacePath( path, cycledPath ); | |
| 573 | } | |
| 574 | ||
| 575 | /** | |
| 576 | * Cycles to the next sibling of the currently selected tree node. | |
| 577 | */ | |
| 578 | private void cyclePathNext() { | |
| 579 | cycleSelection( true ); | |
| 580 | } | |
| 581 | ||
| 582 | /** | |
| 583 | * Cycles to the previous sibling of the currently selected tree node. | |
| 584 | */ | |
| 585 | private void cyclePathPrev() { | |
| 586 | cycleSelection( false ); | |
| 587 | } | |
| 588 | ||
| 589 | /** | |
| 590 | * Returns the variable name (or as much as has been typed so far). Returns | |
| 591 | * all the characters from the initial caret column to the the first | |
| 592 | * whitespace character. This will return a path that contains zero or more | |
| 593 | * separators. | |
| 594 | * | |
| 595 | * @return A non-null string, possibly empty. | |
| 596 | */ | |
| 597 | private String getCurrentPath() { | |
| 598 | final String s = extractTextChunk(); | |
| 599 | final int length = s.length(); | |
| 600 | ||
| 601 | int i = 0; | |
| 602 | ||
| 603 | while( i < length && !isWhitespace( s.charAt( i ) ) ) { | |
| 604 | i++; | |
| 605 | } | |
| 606 | ||
| 607 | return s.substring( 0, i ); | |
| 608 | } | |
| 609 | ||
| 610 | private <T> ObservableList<TreeItem<T>> getSiblings( | |
| 611 | final TreeItem<T> item ) { | |
| 612 | final TreeItem<T> parent = item.getParent(); | |
| 613 | return parent == null ? item.getChildren() : parent.getChildren(); | |
| 614 | } | |
| 615 | ||
| 616 | private <T> TreeItem<T> getFirstSibling( final TreeItem<T> item ) { | |
| 617 | return getFirst( getSiblings( item ), item ); | |
| 618 | } | |
| 619 | ||
| 620 | private <T> TreeItem<T> getLastSibling( final TreeItem<T> item ) { | |
| 621 | return getLast( getSiblings( item ), item ); | |
| 622 | } | |
| 623 | ||
| 624 | /** | |
| 625 | * Returns the caret position as an offset into the text. | |
| 626 | * | |
| 627 | * @return A value from 0 to the length of the text (minus one). | |
| 628 | */ | |
| 629 | private int getCurrentCaretPosition() { | |
| 630 | return getEditor().getCaretPosition(); | |
| 631 | } | |
| 632 | ||
| 633 | /** | |
| 634 | * Returns the caret position within the current paragraph. | |
| 635 | * | |
| 636 | * @return A value from 0 to the length of the current paragraph. | |
| 637 | */ | |
| 638 | private int getCurrentCaretColumn() { | |
| 639 | return getEditor().getCaretColumn(); | |
| 640 | } | |
| 641 | ||
| 642 | /** | |
| 643 | * Returns the last word from the path. | |
| 644 | * | |
| 645 | * @return The last token. | |
| 646 | */ | |
| 647 | private String getLastPathWord() { | |
| 648 | String path = getCurrentPath(); | |
| 649 | ||
| 650 | int i = path.indexOf( SEPARATOR ); | |
| 651 | ||
| 652 | while( i > 0 ) { | |
| 653 | path = path.substring( i + 1 ); | |
| 654 | i = path.indexOf( SEPARATOR ); | |
| 655 | } | |
| 656 | ||
| 657 | return path; | |
| 658 | } | |
| 659 | ||
| 660 | /** | |
| 661 | * Returns text from the initial caret position until some arbitrarily long | |
| 662 | * number of characters. The number of characters extracted will be | |
| 663 | * getMaxVarLength, or fewer, depending on how many characters remain to be | |
| 664 | * extracted. The result from this method is trimmed to the first whitespace | |
| 665 | * character. | |
| 666 | * | |
| 667 | * @return A chunk of text that includes all the words representing a path, | |
| 668 | * and then some. | |
| 669 | */ | |
| 670 | private String extractTextChunk() { | |
| 671 | final StyledTextArea textArea = getEditor(); | |
| 672 | final int textBegan = getInitialCaretPosition(); | |
| 673 | final int remaining = textArea.getLength() - textBegan; | |
| 674 | final int textEnded = min( remaining, getMaxVarLength() ); | |
| 675 | ||
| 676 | return textArea.getText( textBegan, textEnded ); | |
| 677 | } | |
| 678 | ||
| 679 | /** | |
| 680 | * Returns the node for the current path. | |
| 681 | */ | |
| 682 | private TreeItem<String> getCurrentNode() { | |
| 683 | return findNode( getCurrentPath() ); | |
| 684 | } | |
| 685 | ||
| 686 | /** | |
| 687 | * Finds the node that most closely matches the given path. | |
| 688 | * | |
| 689 | * @param path The path that represents a node. | |
| 690 | * | |
| 691 | * @return The node for the path, or the root node if the path could not be | |
| 692 | * found, but never null. | |
| 693 | */ | |
| 694 | private TreeItem<String> findNode( final String path ) { | |
| 695 | return getDefinitionPane().findNode( path ); | |
| 696 | } | |
| 697 | ||
| 698 | /** | |
| 699 | * Finds the first leaf having a value that starts with the given text. | |
| 700 | * | |
| 701 | * @param text The text to find in the definition tree. | |
| 702 | * | |
| 703 | * @return The leaf that starts with the given text, or null if not found. | |
| 704 | */ | |
| 705 | private VariableTreeItem<String> findLeaf( final String text ) { | |
| 706 | return getDefinitionPane().findLeaf( text ); | |
| 707 | } | |
| 708 | ||
| 709 | /** | |
| 710 | * Used to ignore typed keys in favour of trapping pressed keys. | |
| 711 | * | |
| 712 | * @param e The key that was typed. | |
| 713 | */ | |
| 714 | private void vModeKeyTyped( KeyEvent e ) { | |
| 715 | e.consume(); | |
| 716 | } | |
| 717 | ||
| 718 | /** | |
| 719 | * Used to lazily initialize the keyboard map. | |
| 720 | * | |
| 721 | * @return Mappings for keyTyped and keyPressed. | |
| 722 | */ | |
| 723 | protected InputMap<InputEvent> createKeyboardMap() { | |
| 724 | return sequence( | |
| 725 | consume( keyTyped(), this::vModeKeyTyped ), | |
| 726 | consume( keyPressed(), this::vModeKeyPressed ) | |
| 727 | ); | |
| 728 | } | |
| 729 | ||
| 730 | private InputMap<InputEvent> getKeyboardMap() { | |
| 731 | if( this.keyboardMap == null ) { | |
| 732 | this.keyboardMap = createKeyboardMap(); | |
| 733 | } | |
| 734 | ||
| 735 | return this.keyboardMap; | |
| 736 | } | |
| 737 | ||
| 738 | /** | |
| 739 | * Collapses the tree then expands and selects the given node. | |
| 740 | * | |
| 741 | * @param node The node to expand. | |
| 742 | */ | |
| 743 | private void expand( final TreeItem<String> node ) { | |
| 744 | final DefinitionPane pane = getDefinitionPane(); | |
| 745 | pane.collapse(); | |
| 746 | pane.expand( node ); | |
| 747 | pane.select( node ); | |
| 748 | } | |
| 749 | ||
| 750 | /** | |
| 751 | * Returns true iff the key code the user typed can be used as part of a YAML | |
| 752 | * variable name. | |
| 753 | * | |
| 754 | * @param keyEvent Keyboard key press event information. | |
| 755 | * | |
| 756 | * @return true The key is a value that can be inserted into the text. | |
| 757 | */ | |
| 758 | private boolean isVariableNameKey( final KeyEvent keyEvent ) { | |
| 759 | final KeyCode kc = keyEvent.getCode(); | |
| 760 | ||
| 761 | return (kc.isLetterKey() | |
| 762 | || kc.isDigitKey() | |
| 763 | || (keyEvent.isShiftDown() && kc == MINUS)) | |
| 764 | && !keyEvent.isControlDown(); | |
| 765 | } | |
| 766 | ||
| 767 | /** | |
| 768 | * Starts to capture user input events. | |
| 769 | */ | |
| 770 | private void vModeStart() { | |
| 771 | addEventListener( getKeyboardMap() ); | |
| 772 | } | |
| 773 | ||
| 774 | /** | |
| 775 | * Restores capturing of user input events to the previous event listener. | |
| 776 | * Also asks the processing chain to modify the variable text into a | |
| 777 | * machine-readable variable based on the format required by the file type. | |
| 778 | * For example, a Markdown file (.md) will substitute a $VAR$ name while an R | |
| 779 | * file (.Rmd, .Rxml) will use `r#xVAR`. | |
| 780 | */ | |
| 781 | private void vModeStop() { | |
| 782 | removeEventListener( getKeyboardMap() ); | |
| 783 | } | |
| 784 | ||
| 785 | private VariableDecorator getVariableDecorator() { | |
| 786 | return new YamlVariableDecorator(); | |
| 787 | } | |
| 788 | ||
| 789 | /** | |
| 790 | * Returns the index where the two strings diverge. | |
| 791 | * | |
| 792 | * @param s1 The string that could be a substring of s2, null allowed. | |
| 793 | * @param s2 The string that could be a substring of s1, null allowed. | |
| 794 | * | |
| 795 | * @return NO_DIFFERENCE if the strings are the same, otherwise the index | |
| 796 | * where they differ. | |
| 797 | */ | |
| 798 | @SuppressWarnings( "StringEquality" ) | |
| 799 | private int difference( final CharSequence s1, final CharSequence s2 ) { | |
| 800 | if( s1 == s2 ) { | |
| 801 | return NO_DIFFERENCE; | |
| 802 | } | |
| 803 | ||
| 804 | if( s1 == null || s2 == null ) { | |
| 805 | return 0; | |
| 806 | } | |
| 807 | ||
| 808 | int i = 0; | |
| 809 | final int limit = min( s1.length(), s2.length() ); | |
| 810 | ||
| 811 | while( i < limit && s1.charAt( i ) == s2.charAt( i ) ) { | |
| 812 | i++; | |
| 813 | } | |
| 814 | ||
| 815 | // If one string was shorter than the other, that's where they differ. | |
| 816 | return i; | |
| 817 | } | |
| 818 | ||
| 819 | /** | |
| 820 | * Delegates to the file editor pane, and, ultimately, to its text area. | |
| 821 | */ | |
| 822 | private <T extends Event, U extends T> void addEventListener( | |
| 823 | final EventPattern<? super T, ? extends U> event, | |
| 824 | final Consumer<? super U> consumer ) { | |
| 825 | getFileEditorPane().addEventListener( event, consumer ); | |
| 826 | } | |
| 827 | ||
| 828 | /** | |
| 829 | * Delegates to the file editor pane, and, ultimately, to its text area. | |
| 830 | * | |
| 831 | * @param map The map of methods to events. | |
| 832 | */ | |
| 833 | private void addEventListener( final InputMap<InputEvent> map ) { | |
| 834 | getFileEditorPane().addEventListener( map ); | |
| 835 | } | |
| 836 | ||
| 837 | private void removeEventListener( final InputMap<InputEvent> map ) { | |
| 838 | getFileEditorPane().removeEventListener( map ); | |
| 839 | } | |
| 840 | ||
| 841 | /** | |
| 842 | * Returns the position of the caret when variable mode editing was requested. | |
| 843 | * | |
| 844 | * @return The variable mode caret position. | |
| 845 | */ | |
| 846 | private int getInitialCaretPosition() { | |
| 847 | return this.initialCaretPosition; | |
| 848 | } | |
| 849 | ||
| 850 | /** | |
| 851 | * Sets the position of the caret when variable mode editing was requested. | |
| 852 | * Stores the current position because only the text that comes afterwards is | |
| 853 | * a suitable variable reference. | |
| 854 | * | |
| 855 | * @return The variable mode caret position. | |
| 856 | */ | |
| 857 | private void setInitialCaretPosition() { | |
| 858 | this.initialCaretPosition = getEditor().getCaretPosition(); | |
| 859 | } | |
| 860 | ||
| 861 | private StyledTextArea getEditor() { | |
| 862 | return getFileEditorPane().getEditor(); | |
| 863 | } | |
| 864 | ||
| 865 | public FileEditorTabPane getFileEditorPane() { | |
| 866 | return this.fileEditorPane; | |
| 867 | } | |
| 868 | ||
| 869 | private void setFileEditorPane( final FileEditorTabPane fileEditorPane ) { | |
| 870 | this.fileEditorPane = fileEditorPane; | |
| 871 | } | |
| 872 | ||
| 873 | private DefinitionPane getDefinitionPane() { | |
| 874 | return this.definitionPane; | |
| 875 | } | |
| 876 | ||
| 877 | private void setDefinitionPane( final DefinitionPane definitionPane ) { | |
| 878 | this.definitionPane = definitionPane; | |
| 879 | } | |
| 880 | ||
| 881 | private IndexRange getSelectionRange() { | |
| 882 | return getEditor().getSelection(); | |
| 883 | } | |
| 884 | ||
| 885 | /** | |
| 886 | * Don't look ahead too far when trying to find the end of a node. | |
| 887 | * | |
| 888 | * @return 512 by default. | |
| 889 | */ | |
| 890 | private int getMaxVarLength() { | |
| 891 | return getSettings().getSetting( | |
| 892 | "editor.variable.maxLength", DEFAULT_MAX_VAR_LENGTH ); | |
| 893 | } | |
| 894 | ||
| 895 | private Settings getSettings() { | |
| 896 | return this.settings; | |
| 897 | } | |
| 898 | } | |
| 899 | 1 |
| 1 | /* | |
| 2 | * Copyright 2016 Karl Tauber and White Magic Software, Ltd. | |
| 3 | * | |
| 4 | * All rights reserved. | |
| 5 | * | |
| 6 | * Redistribution and use in source and binary forms, with or without | |
| 7 | * modification, are permitted provided that the following conditions are met: | |
| 8 | * | |
| 9 | * o Redistributions of source code must retain the above copyright | |
| 10 | * notice, this list of conditions and the following disclaimer. | |
| 11 | * | |
| 12 | * o Redistributions in binary form must reproduce the above copyright | |
| 13 | * notice, this list of conditions and the following disclaimer in the | |
| 14 | * documentation and/or other materials provided with the distribution. | |
| 15 | * | |
| 16 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | |
| 17 | * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | |
| 18 | * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | |
| 19 | * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT | |
| 20 | * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | |
| 21 | * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT | |
| 22 | * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | |
| 23 | * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | |
| 24 | * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | |
| 25 | * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | |
| 26 | * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | |
| 27 | */ | |
| 28 | package com.scrivenvar.editors; | |
| 29 | ||
| 30 | import com.scrivenvar.AbstractPane; | |
| 31 | import java.nio.file.Path; | |
| 32 | import java.util.function.Consumer; | |
| 33 | import javafx.application.Platform; | |
| 34 | import javafx.beans.property.ObjectProperty; | |
| 35 | import javafx.beans.property.SimpleObjectProperty; | |
| 36 | import javafx.beans.value.ChangeListener; | |
| 37 | import javafx.event.Event; | |
| 38 | import javafx.scene.control.ScrollPane; | |
| 39 | import javafx.scene.input.InputEvent; | |
| 40 | import org.fxmisc.flowless.VirtualizedScrollPane; | |
| 41 | import org.fxmisc.richtext.StyleClassedTextArea; | |
| 42 | import org.fxmisc.undo.UndoManager; | |
| 43 | import org.fxmisc.wellbehaved.event.EventPattern; | |
| 44 | import org.fxmisc.wellbehaved.event.InputMap; | |
| 45 | import static org.fxmisc.wellbehaved.event.InputMap.consume; | |
| 46 | import org.fxmisc.wellbehaved.event.Nodes; | |
| 47 | ||
| 48 | /** | |
| 49 | * Represents common editing features for various types of text editors. | |
| 50 | * | |
| 51 | * @author White Magic Software, Ltd. | |
| 52 | */ | |
| 53 | public class EditorPane extends AbstractPane { | |
| 54 | ||
| 55 | private StyleClassedTextArea editor; | |
| 56 | private VirtualizedScrollPane<StyleClassedTextArea> scrollPane; | |
| 57 | private final ObjectProperty<Path> path = new SimpleObjectProperty<>(); | |
| 58 | ||
| 59 | /** | |
| 60 | * Set when entering variable edit mode; retrieved upon exiting. | |
| 61 | */ | |
| 62 | private InputMap<InputEvent> nodeMap; | |
| 63 | ||
| 64 | @Override | |
| 65 | public void requestFocus() { | |
| 66 | Platform.runLater( () -> getEditor().requestFocus() ); | |
| 67 | } | |
| 68 | ||
| 69 | public void undo() { | |
| 70 | getUndoManager().undo(); | |
| 71 | } | |
| 72 | ||
| 73 | public void redo() { | |
| 74 | getUndoManager().redo(); | |
| 75 | } | |
| 76 | ||
| 77 | public UndoManager getUndoManager() { | |
| 78 | return getEditor().getUndoManager(); | |
| 79 | } | |
| 80 | ||
| 81 | public String getText() { | |
| 82 | return getEditor().getText(); | |
| 83 | } | |
| 84 | ||
| 85 | public void setText( final String text ) { | |
| 86 | getEditor().deselect(); | |
| 87 | getEditor().replaceText( text ); | |
| 88 | getUndoManager().mark(); | |
| 89 | } | |
| 90 | ||
| 91 | /** | |
| 92 | * Call to hook into changes to the text area. | |
| 93 | * | |
| 94 | * @param listener Receives editor text change events. | |
| 95 | */ | |
| 96 | public void addTextChangeListener( final ChangeListener<? super String> listener ) { | |
| 97 | getEditor().textProperty().addListener( listener ); | |
| 98 | } | |
| 99 | ||
| 100 | /** | |
| 101 | * Call to listen for when the caret moves to another paragraph. | |
| 102 | * | |
| 103 | * @param listener Receives paragraph change events. | |
| 104 | */ | |
| 105 | public void addCaretParagraphListener( | |
| 106 | final ChangeListener<? super Integer> listener ) { | |
| 107 | getEditor().currentParagraphProperty().addListener( listener ); | |
| 108 | } | |
| 109 | ||
| 110 | /** | |
| 111 | * This method adds listeners to editor events. | |
| 112 | * | |
| 113 | * @param <T> The event type. | |
| 114 | * @param <U> The consumer type for the given event type. | |
| 115 | * @param event The event of interest. | |
| 116 | * @param consumer The method to call when the event happens. | |
| 117 | */ | |
| 118 | public <T extends Event, U extends T> void addEventListener( | |
| 119 | final EventPattern<? super T, ? extends U> event, | |
| 120 | final Consumer<? super U> consumer ) { | |
| 121 | Nodes.addInputMap( getEditor(), consume( event, consumer ) ); | |
| 122 | } | |
| 123 | ||
| 124 | /** | |
| 125 | * This method adds listeners to editor events that can be removed without | |
| 126 | * affecting the original listeners (i.e., the original lister is restored on | |
| 127 | * a call to removeEventListener). | |
| 128 | * | |
| 129 | * @param map The map of methods to events. | |
| 130 | */ | |
| 131 | @SuppressWarnings( "unchecked" ) | |
| 132 | public void addEventListener( final InputMap<InputEvent> map ) { | |
| 133 | this.nodeMap = (InputMap<InputEvent>)getInputMap(); | |
| 134 | Nodes.addInputMap( getEditor(), map ); | |
| 135 | } | |
| 136 | ||
| 137 | /** | |
| 138 | * This method removes listeners to editor events and restores the default | |
| 139 | * handler. | |
| 140 | * | |
| 141 | * @param map The map of methods to events. | |
| 142 | */ | |
| 143 | public void removeEventListener( final InputMap<InputEvent> map ) { | |
| 144 | Nodes.removeInputMap( getEditor(), map ); | |
| 145 | Nodes.addInputMap( getEditor(), this.nodeMap ); | |
| 146 | } | |
| 147 | ||
| 148 | /** | |
| 149 | * Returns the value for "org.fxmisc.wellbehaved.event.inputmap". | |
| 150 | * | |
| 151 | * @return An input map of input events. | |
| 152 | */ | |
| 153 | private Object getInputMap() { | |
| 154 | return getEditor().getProperties().get( getInputMapKey() ); | |
| 155 | } | |
| 156 | ||
| 157 | /** | |
| 158 | * Returns the hashmap key entry for the input map. | |
| 159 | * | |
| 160 | * @return "org.fxmisc.wellbehaved.event.inputmap" | |
| 161 | */ | |
| 162 | private String getInputMapKey() { | |
| 163 | return "org.fxmisc.wellbehaved.event.inputmap"; | |
| 164 | } | |
| 165 | ||
| 166 | public void scrollToTop() { | |
| 167 | getEditor().moveTo( 0 ); | |
| 168 | } | |
| 169 | ||
| 170 | private void setEditor( StyleClassedTextArea textArea ) { | |
| 171 | this.editor = textArea; | |
| 172 | } | |
| 173 | ||
| 174 | public synchronized StyleClassedTextArea getEditor() { | |
| 175 | if( this.editor == null ) { | |
| 176 | setEditor( createTextArea() ); | |
| 177 | } | |
| 178 | ||
| 179 | return this.editor; | |
| 180 | } | |
| 181 | ||
| 182 | /** | |
| 183 | * Returns the scroll pane that contains the text area. | |
| 184 | * | |
| 185 | * @return The scroll pane that contains the content to edit. | |
| 186 | */ | |
| 187 | public synchronized VirtualizedScrollPane<StyleClassedTextArea> getScrollPane() { | |
| 188 | if( this.scrollPane == null ) { | |
| 189 | this.scrollPane = createScrollPane(); | |
| 190 | } | |
| 191 | ||
| 192 | return this.scrollPane; | |
| 193 | } | |
| 194 | ||
| 195 | protected VirtualizedScrollPane<StyleClassedTextArea> createScrollPane() { | |
| 196 | final VirtualizedScrollPane<StyleClassedTextArea> pane | |
| 197 | = new VirtualizedScrollPane<>( getEditor() ); | |
| 198 | pane.setVbarPolicy( ScrollPane.ScrollBarPolicy.ALWAYS ); | |
| 199 | ||
| 200 | return pane; | |
| 201 | } | |
| 202 | ||
| 203 | protected StyleClassedTextArea createTextArea() { | |
| 204 | return new StyleClassedTextArea( false ); | |
| 205 | } | |
| 206 | ||
| 207 | public Path getPath() { | |
| 208 | return this.path.get(); | |
| 209 | } | |
| 210 | ||
| 211 | public void setPath( final Path path ) { | |
| 212 | this.path.set( path ); | |
| 213 | } | |
| 214 | ||
| 215 | public ObjectProperty<Path> pathProperty() { | |
| 216 | return this.path; | |
| 217 | } | |
| 218 | } | |
| 1 | 219 |
| 1 | /* | |
| 2 | * Copyright 2016 White Magic Software, Ltd. | |
| 3 | * | |
| 4 | * All rights reserved. | |
| 5 | * | |
| 6 | * Redistribution and use in source and binary forms, with or without | |
| 7 | * modification, are permitted provided that the following conditions are met: | |
| 8 | * | |
| 9 | * o Redistributions of source code must retain the above copyright | |
| 10 | * notice, this list of conditions and the following disclaimer. | |
| 11 | * | |
| 12 | * o Redistributions in binary form must reproduce the above copyright | |
| 13 | * notice, this list of conditions and the following disclaimer in the | |
| 14 | * documentation and/or other materials provided with the distribution. | |
| 15 | * | |
| 16 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | |
| 17 | * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | |
| 18 | * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | |
| 19 | * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT | |
| 20 | * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | |
| 21 | * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT | |
| 22 | * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | |
| 23 | * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | |
| 24 | * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | |
| 25 | * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | |
| 26 | * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | |
| 27 | */ | |
| 28 | package com.scrivenvar.editors; | |
| 29 | ||
| 30 | import com.scrivenvar.FileEditorTabPane; | |
| 31 | import com.scrivenvar.Services; | |
| 32 | import com.scrivenvar.decorators.VariableDecorator; | |
| 33 | import com.scrivenvar.decorators.YamlVariableDecorator; | |
| 34 | import com.scrivenvar.definition.DefinitionPane; | |
| 35 | import com.scrivenvar.definition.VariableTreeItem; | |
| 36 | import static com.scrivenvar.definition.yaml.YamlParser.SEPARATOR; | |
| 37 | import com.scrivenvar.service.Settings; | |
| 38 | import static com.scrivenvar.util.Lists.getFirst; | |
| 39 | import static com.scrivenvar.util.Lists.getLast; | |
| 40 | import static java.lang.Character.isSpaceChar; | |
| 41 | import static java.lang.Character.isWhitespace; | |
| 42 | import static java.lang.Math.min; | |
| 43 | import java.util.function.Consumer; | |
| 44 | import javafx.collections.ObservableList; | |
| 45 | import javafx.event.Event; | |
| 46 | import javafx.scene.control.IndexRange; | |
| 47 | import javafx.scene.control.TreeItem; | |
| 48 | import javafx.scene.input.InputEvent; | |
| 49 | import javafx.scene.input.KeyCode; | |
| 50 | import static javafx.scene.input.KeyCode.AT; | |
| 51 | import static javafx.scene.input.KeyCode.DIGIT2; | |
| 52 | import static javafx.scene.input.KeyCode.ENTER; | |
| 53 | import static javafx.scene.input.KeyCode.MINUS; | |
| 54 | import static javafx.scene.input.KeyCode.SPACE; | |
| 55 | import static javafx.scene.input.KeyCombination.CONTROL_DOWN; | |
| 56 | import static javafx.scene.input.KeyCombination.SHIFT_DOWN; | |
| 57 | import javafx.scene.input.KeyEvent; | |
| 58 | import org.fxmisc.richtext.StyledTextArea; | |
| 59 | import org.fxmisc.wellbehaved.event.EventPattern; | |
| 60 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 61 | import static org.fxmisc.wellbehaved.event.EventPattern.keyTyped; | |
| 62 | import org.fxmisc.wellbehaved.event.InputMap; | |
| 63 | import static org.fxmisc.wellbehaved.event.InputMap.consume; | |
| 64 | import static org.fxmisc.wellbehaved.event.InputMap.sequence; | |
| 65 | import static com.scrivenvar.util.Lists.getFirst; | |
| 66 | import static com.scrivenvar.util.Lists.getLast; | |
| 67 | import static java.lang.Character.isSpaceChar; | |
| 68 | import static java.lang.Character.isWhitespace; | |
| 69 | import static java.lang.Math.min; | |
| 70 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 71 | import static org.fxmisc.wellbehaved.event.EventPattern.keyTyped; | |
| 72 | import static org.fxmisc.wellbehaved.event.InputMap.consume; | |
| 73 | ||
| 74 | /** | |
| 75 | * Provides the logic for injecting variable names within the editor. | |
| 76 | * | |
| 77 | * @author White Magic Software, Ltd. | |
| 78 | */ | |
| 79 | public class VariableNameInjector { | |
| 80 | ||
| 81 | public static final int DEFAULT_MAX_VAR_LENGTH = 64; | |
| 82 | ||
| 83 | private static final int NO_DIFFERENCE = -1; | |
| 84 | ||
| 85 | private final Settings settings = Services.load( Settings.class ); | |
| 86 | ||
| 87 | /** | |
| 88 | * Used to capture keyboard events once the user presses @. | |
| 89 | */ | |
| 90 | private InputMap<InputEvent> keyboardMap; | |
| 91 | ||
| 92 | private FileEditorTabPane fileEditorPane; | |
| 93 | private DefinitionPane definitionPane; | |
| 94 | ||
| 95 | /** | |
| 96 | * Position of the variable in the text when in variable mode (0 by default). | |
| 97 | */ | |
| 98 | private int initialCaretPosition; | |
| 99 | ||
| 100 | public VariableNameInjector( | |
| 101 | final FileEditorTabPane editorPane, | |
| 102 | final DefinitionPane definitionPane ) { | |
| 103 | setFileEditorPane( editorPane ); | |
| 104 | setDefinitionPane( definitionPane ); | |
| 105 | ||
| 106 | initKeyboardEventListeners(); | |
| 107 | } | |
| 108 | ||
| 109 | /** | |
| 110 | * Traps keys for performing various short-cut tasks, such as @-mode variable | |
| 111 | * insertion and control+space for variable autocomplete. | |
| 112 | * | |
| 113 | * @ key is pressed, a new keyboard map is inserted in place of the current | |
| 114 | * map -- this class goes into "variable edit mode" (a.k.a. vMode). | |
| 115 | * | |
| 116 | * @see createKeyboardMap() | |
| 117 | */ | |
| 118 | private void initKeyboardEventListeners() { | |
| 119 | addEventListener( keyPressed( SPACE, CONTROL_DOWN ), this::autocomplete ); | |
| 120 | ||
| 121 | // @ key in Linux? | |
| 122 | addEventListener( keyPressed( DIGIT2, SHIFT_DOWN ), this::vMode ); | |
| 123 | // @ key in Windows. | |
| 124 | addEventListener( keyPressed( AT ), this::vMode ); | |
| 125 | } | |
| 126 | ||
| 127 | /** | |
| 128 | * The @ symbol is a short-cut to inserting a YAML variable reference. | |
| 129 | * | |
| 130 | * @param e Superfluous information about the key that was pressed. | |
| 131 | */ | |
| 132 | private void vMode( KeyEvent e ) { | |
| 133 | setInitialCaretPosition(); | |
| 134 | vModeStart(); | |
| 135 | vModeAutocomplete(); | |
| 136 | } | |
| 137 | ||
| 138 | /** | |
| 139 | * Receives key presses until the user completes the variable selection. This | |
| 140 | * allows the arrow keys to be used for selecting variables. | |
| 141 | * | |
| 142 | * @param e The key that was pressed. | |
| 143 | */ | |
| 144 | private void vModeKeyPressed( KeyEvent e ) { | |
| 145 | final KeyCode keyCode = e.getCode(); | |
| 146 | ||
| 147 | switch( keyCode ) { | |
| 148 | case BACK_SPACE: | |
| 149 | // Don't decorate the variable upon exiting vMode. | |
| 150 | vModeBackspace(); | |
| 151 | break; | |
| 152 | ||
| 153 | case ESCAPE: | |
| 154 | // Don't decorate the variable upon exiting vMode. | |
| 155 | vModeStop(); | |
| 156 | break; | |
| 157 | ||
| 158 | case ENTER: | |
| 159 | case PERIOD: | |
| 160 | case RIGHT: | |
| 161 | case END: | |
| 162 | // Stop at a leaf node, ENTER means accept. | |
| 163 | if( vModeConditionalComplete() && keyCode == ENTER ) { | |
| 164 | vModeStop(); | |
| 165 | ||
| 166 | // Decorate the variable upon exiting vMode. | |
| 167 | decorateVariable(); | |
| 168 | } | |
| 169 | break; | |
| 170 | ||
| 171 | case UP: | |
| 172 | cyclePathPrev(); | |
| 173 | break; | |
| 174 | ||
| 175 | case DOWN: | |
| 176 | cyclePathNext(); | |
| 177 | break; | |
| 178 | ||
| 179 | default: | |
| 180 | vModeFilterKeyPressed( e ); | |
| 181 | break; | |
| 182 | } | |
| 183 | ||
| 184 | e.consume(); | |
| 185 | } | |
| 186 | ||
| 187 | private void vModeBackspace() { | |
| 188 | deleteSelection(); | |
| 189 | ||
| 190 | // Break out of variable mode by back spacing to the original position. | |
| 191 | if( getCurrentCaretPosition() > getInitialCaretPosition() ) { | |
| 192 | vModeAutocomplete(); | |
| 193 | } else { | |
| 194 | vModeStop(); | |
| 195 | } | |
| 196 | } | |
| 197 | ||
| 198 | /** | |
| 199 | * Updates the text with the path selected (or typed) by the user. | |
| 200 | */ | |
| 201 | private void vModeAutocomplete() { | |
| 202 | final TreeItem<String> node = getCurrentNode(); | |
| 203 | ||
| 204 | if( !node.isLeaf() ) { | |
| 205 | final String word = getLastPathWord(); | |
| 206 | final String label = node.getValue(); | |
| 207 | final int delta = difference( label, word ); | |
| 208 | final String remainder = delta == NO_DIFFERENCE | |
| 209 | ? label | |
| 210 | : label.substring( delta ); | |
| 211 | ||
| 212 | final StyledTextArea textArea = getEditor(); | |
| 213 | final int posBegan = getCurrentCaretPosition(); | |
| 214 | final int posEnded = posBegan + remainder.length(); | |
| 215 | ||
| 216 | textArea.replaceSelection( remainder ); | |
| 217 | ||
| 218 | if( posEnded - posBegan > 0 ) { | |
| 219 | textArea.selectRange( posEnded, posBegan ); | |
| 220 | } | |
| 221 | ||
| 222 | expand( node ); | |
| 223 | } | |
| 224 | } | |
| 225 | ||
| 226 | /** | |
| 227 | * Only variable name keys can pass through the filter. This is called when | |
| 228 | * the user presses a key. | |
| 229 | * | |
| 230 | * @param e The key that was pressed. | |
| 231 | */ | |
| 232 | private void vModeFilterKeyPressed( final KeyEvent e ) { | |
| 233 | if( isVariableNameKey( e ) ) { | |
| 234 | typed( e.getText() ); | |
| 235 | } | |
| 236 | } | |
| 237 | ||
| 238 | /** | |
| 239 | * Performs an autocomplete depending on whether the user has finished typing | |
| 240 | * in a word. If there is a selected range, then this will complete the most | |
| 241 | * recent word and jump to the next child. | |
| 242 | * | |
| 243 | * @return true The auto-completed node was a terminal node. | |
| 244 | */ | |
| 245 | private boolean vModeConditionalComplete() { | |
| 246 | acceptPath(); | |
| 247 | ||
| 248 | final TreeItem<String> node = getCurrentNode(); | |
| 249 | final boolean terminal = isTerminal( node ); | |
| 250 | ||
| 251 | if( !terminal ) { | |
| 252 | typed( SEPARATOR ); | |
| 253 | } | |
| 254 | ||
| 255 | return terminal; | |
| 256 | } | |
| 257 | ||
| 258 | /** | |
| 259 | * Pressing control+space will find a node that matches the current word and | |
| 260 | * substitute the YAML variable reference. This is called when the user is not | |
| 261 | * editing in vMode. | |
| 262 | * | |
| 263 | * @param e Ignored -- it can only be Ctrl+Space. | |
| 264 | */ | |
| 265 | private void autocomplete( KeyEvent e ) { | |
| 266 | final String paragraph = getCaretParagraph(); | |
| 267 | final int[] boundaries = getWordBoundaries( paragraph ); | |
| 268 | final String word = paragraph.substring( boundaries[ 0 ], boundaries[ 1 ] ); | |
| 269 | ||
| 270 | final VariableTreeItem<String> leaf = findLeaf( word ); | |
| 271 | ||
| 272 | if( leaf != null ) { | |
| 273 | replaceText( boundaries[ 0 ], boundaries[ 1 ], leaf.toPath() ); | |
| 274 | decorateVariable(); | |
| 275 | expand( leaf ); | |
| 276 | } | |
| 277 | } | |
| 278 | ||
| 279 | /** | |
| 280 | * Called when autocomplete finishes on a valid leaf or when the user presses | |
| 281 | * Enter to finish manual autocomplete. | |
| 282 | */ | |
| 283 | private void decorateVariable() { | |
| 284 | // A little bit of duplication... | |
| 285 | final String paragraph = getCaretParagraph(); | |
| 286 | final int[] boundaries = getWordBoundaries( paragraph ); | |
| 287 | final String old = paragraph.substring( boundaries[ 0 ], boundaries[ 1 ] ); | |
| 288 | ||
| 289 | final String newVariable = getVariableDecorator().decorate( old ); | |
| 290 | ||
| 291 | final int posEnded = getCurrentCaretPosition(); | |
| 292 | final int posBegan = posEnded - old.length(); | |
| 293 | ||
| 294 | getEditor().replaceText( posBegan, posEnded, newVariable ); | |
| 295 | } | |
| 296 | ||
| 297 | /** | |
| 298 | * Updates the text at the given position within the current paragraph. | |
| 299 | * | |
| 300 | * @param posBegan The starting index in the paragraph text to replace. | |
| 301 | * @param posEnded The ending index in the paragraph text to replace. | |
| 302 | * @param text Overwrite the paragraph substring with this text. | |
| 303 | */ | |
| 304 | private void replaceText( | |
| 305 | final int posBegan, final int posEnded, final String text ) { | |
| 306 | final int p = getCurrentParagraph(); | |
| 307 | ||
| 308 | getEditor().replaceText( p, posBegan, p, posEnded, text ); | |
| 309 | } | |
| 310 | ||
| 311 | /** | |
| 312 | * Returns the caret's current paragraph position. | |
| 313 | * | |
| 314 | * @return A number greater than or equal to 0. | |
| 315 | */ | |
| 316 | private int getCurrentParagraph() { | |
| 317 | return getEditor().getCurrentParagraph(); | |
| 318 | } | |
| 319 | ||
| 320 | /** | |
| 321 | * Returns current word boundary indexes into the current paragraph, including | |
| 322 | * punctuation. | |
| 323 | * | |
| 324 | * @param p The paragraph wherein to hunt word boundaries. | |
| 325 | * @param offset The offset into the paragraph to begin scanning left and | |
| 326 | * right. | |
| 327 | * | |
| 328 | * @return The starting and ending index of the word closest to the caret. | |
| 329 | */ | |
| 330 | private int[] getWordBoundaries( final String p, final int offset ) { | |
| 331 | // Remove dashes, but retain hyphens. Retain same number of characters | |
| 332 | // to preserve relative indexes. | |
| 333 | final String paragraph = p.replace( "---", " " ).replace( "--", " " ); | |
| 334 | ||
| 335 | return getWordAt( paragraph, offset ); | |
| 336 | } | |
| 337 | ||
| 338 | /** | |
| 339 | * Helper method to get the word boundaries for the current paragraph. | |
| 340 | * | |
| 341 | * @param paragraph | |
| 342 | * | |
| 343 | * @return | |
| 344 | */ | |
| 345 | private int[] getWordBoundaries( final String paragraph ) { | |
| 346 | return getWordBoundaries( paragraph, getCurrentCaretColumn() ); | |
| 347 | } | |
| 348 | ||
| 349 | /** | |
| 350 | * Given an arbitrary offset into a string, this returns the word at that | |
| 351 | * index. The inputs and outputs include: | |
| 352 | * | |
| 353 | * <ul> | |
| 354 | * <li>surrounded by space: <code>hello | world!</code> ("");</li> | |
| 355 | * <li>end of word: <code>hello| world!</code> ("hello");</li> | |
| 356 | * <li>start of a word: <code>hello |world!</code> ("world!");</li> | |
| 357 | * <li>within a word: <code>hello wo|rld!</code> ("world!");</li> | |
| 358 | * <li>end of a paragraph: <code>hello world!|</code> ("world!");</li> | |
| 359 | * <li>start of a paragraph: <code>|hello world!</code> ("hello!"); or</li> | |
| 360 | * <li>after punctuation: <code>hello world!|</code> ("world!").</li> | |
| 361 | * </ul> | |
| 362 | * | |
| 363 | * @param p The string to scan for a word. | |
| 364 | * @param offset The offset within s to begin searching for the nearest word | |
| 365 | * boundary, must not be out of bounds of s. | |
| 366 | * | |
| 367 | * @return The word in s at the offset. | |
| 368 | * | |
| 369 | * @see getWordBegan( String, int ) | |
| 370 | * @see getWordEnded( String, int ) | |
| 371 | */ | |
| 372 | private int[] getWordAt( final String p, final int offset ) { | |
| 373 | return new int[]{ getWordBegan( p, offset ), getWordEnded( p, offset ) }; | |
| 374 | } | |
| 375 | ||
| 376 | /** | |
| 377 | * Returns the index into s where a word begins. | |
| 378 | * | |
| 379 | * @param s Never null. | |
| 380 | * @param offset Index into s to begin searching backwards for a word | |
| 381 | * boundary. | |
| 382 | * | |
| 383 | * @return The index where a word begins. | |
| 384 | */ | |
| 385 | private int getWordBegan( final String s, int offset ) { | |
| 386 | while( offset > 0 && isBoundary( s.charAt( offset - 1 ) ) ) { | |
| 387 | offset--; | |
| 388 | } | |
| 389 | ||
| 390 | return offset; | |
| 391 | } | |
| 392 | ||
| 393 | /** | |
| 394 | * Returns the index into s where a word ends. | |
| 395 | * | |
| 396 | * @param s Never null. | |
| 397 | * @param offset Index into s to begin searching forwards for a word boundary. | |
| 398 | * | |
| 399 | * @return The index where a word ends. | |
| 400 | */ | |
| 401 | private int getWordEnded( final String s, int offset ) { | |
| 402 | final int length = s.length(); | |
| 403 | ||
| 404 | while( offset < length && isBoundary( s.charAt( offset ) ) ) { | |
| 405 | offset++; | |
| 406 | } | |
| 407 | ||
| 408 | return offset; | |
| 409 | } | |
| 410 | ||
| 411 | /** | |
| 412 | * Returns true if the given character can be reasonably expected to be part | |
| 413 | * of a word, including punctuation marks. | |
| 414 | * | |
| 415 | * @param c The character to compare. | |
| 416 | * | |
| 417 | * @return false The character is a space character. | |
| 418 | */ | |
| 419 | private boolean isBoundary( final char c ) { | |
| 420 | return !isSpaceChar( c ); | |
| 421 | } | |
| 422 | ||
| 423 | /** | |
| 424 | * Returns the text for the paragraph that contains the caret. | |
| 425 | * | |
| 426 | * @return A non-null string, possibly empty. | |
| 427 | */ | |
| 428 | private String getCaretParagraph() { | |
| 429 | return getEditor().getText( getCurrentParagraph() ); | |
| 430 | } | |
| 431 | ||
| 432 | /** | |
| 433 | * Returns true if the node has children that can be selected (i.e., any | |
| 434 | * non-leaves). | |
| 435 | * | |
| 436 | * @param <T> The type that the TreeItem contains. | |
| 437 | * @param node The node to test for terminality. | |
| 438 | * | |
| 439 | * @return true The node has one branch and its a leaf. | |
| 440 | */ | |
| 441 | private <T> boolean isTerminal( final TreeItem<T> node ) { | |
| 442 | final ObservableList<TreeItem<T>> branches = node.getChildren(); | |
| 443 | ||
| 444 | return branches.size() == 1 && branches.get( 0 ).isLeaf(); | |
| 445 | } | |
| 446 | ||
| 447 | /** | |
| 448 | * Inserts text that the user typed at the current caret position, then | |
| 449 | * performs an autocomplete for the variable name. | |
| 450 | * | |
| 451 | * @param text The text to insert, never null. | |
| 452 | */ | |
| 453 | private void typed( final String text ) { | |
| 454 | getEditor().replaceSelection( text ); | |
| 455 | vModeAutocomplete(); | |
| 456 | } | |
| 457 | ||
| 458 | /** | |
| 459 | * Called when the user presses either End or Enter key. | |
| 460 | */ | |
| 461 | private void acceptPath() { | |
| 462 | final IndexRange range = getSelectionRange(); | |
| 463 | ||
| 464 | if( range != null ) { | |
| 465 | final int rangeEnd = range.getEnd(); | |
| 466 | final StyledTextArea textArea = getEditor(); | |
| 467 | textArea.deselect(); | |
| 468 | textArea.moveTo( rangeEnd ); | |
| 469 | } | |
| 470 | } | |
| 471 | ||
| 472 | /** | |
| 473 | * Replaces the entirety of the existing path (from the initial caret | |
| 474 | * position) with the given path. | |
| 475 | * | |
| 476 | * @param oldPath The path to replace. | |
| 477 | * @param newPath The replacement path. | |
| 478 | */ | |
| 479 | private void replacePath( final String oldPath, final String newPath ) { | |
| 480 | final StyledTextArea textArea = getEditor(); | |
| 481 | final int posBegan = getInitialCaretPosition(); | |
| 482 | final int posEnded = posBegan + oldPath.length(); | |
| 483 | ||
| 484 | textArea.deselect(); | |
| 485 | textArea.replaceText( posBegan, posEnded, newPath ); | |
| 486 | } | |
| 487 | ||
| 488 | /** | |
| 489 | * Called when the user presses the Backspace key. | |
| 490 | */ | |
| 491 | private void deleteSelection() { | |
| 492 | final StyledTextArea textArea = getEditor(); | |
| 493 | textArea.replaceSelection( "" ); | |
| 494 | textArea.deletePreviousChar(); | |
| 495 | } | |
| 496 | ||
| 497 | /** | |
| 498 | * Cycles the selected text through the nodes. | |
| 499 | * | |
| 500 | * @param direction true - next; false - previous | |
| 501 | */ | |
| 502 | private void cycleSelection( final boolean direction ) { | |
| 503 | final TreeItem<String> node = getCurrentNode(); | |
| 504 | ||
| 505 | // Find the sibling for the current selection and replace the current | |
| 506 | // selection with the sibling's value | |
| 507 | TreeItem< String> cycled = direction | |
| 508 | ? node.nextSibling() | |
| 509 | : node.previousSibling(); | |
| 510 | ||
| 511 | // When cycling at the end (or beginning) of the list, jump to the first | |
| 512 | // (or last) sibling depending on the cycle direction. | |
| 513 | if( cycled == null ) { | |
| 514 | cycled = direction ? getFirstSibling( node ) : getLastSibling( node ); | |
| 515 | } | |
| 516 | ||
| 517 | final String path = getCurrentPath(); | |
| 518 | final String cycledWord = cycled.getValue(); | |
| 519 | final String word = getLastPathWord(); | |
| 520 | final int index = path.indexOf( word ); | |
| 521 | final String cycledPath = path.substring( 0, index ) + cycledWord; | |
| 522 | ||
| 523 | expand( cycled ); | |
| 524 | replacePath( path, cycledPath ); | |
| 525 | } | |
| 526 | ||
| 527 | /** | |
| 528 | * Cycles to the next sibling of the currently selected tree node. | |
| 529 | */ | |
| 530 | private void cyclePathNext() { | |
| 531 | cycleSelection( true ); | |
| 532 | } | |
| 533 | ||
| 534 | /** | |
| 535 | * Cycles to the previous sibling of the currently selected tree node. | |
| 536 | */ | |
| 537 | private void cyclePathPrev() { | |
| 538 | cycleSelection( false ); | |
| 539 | } | |
| 540 | ||
| 541 | /** | |
| 542 | * Returns the variable name (or as much as has been typed so far). Returns | |
| 543 | * all the characters from the initial caret column to the the first | |
| 544 | * whitespace character. This will return a path that contains zero or more | |
| 545 | * separators. | |
| 546 | * | |
| 547 | * @return A non-null string, possibly empty. | |
| 548 | */ | |
| 549 | private String getCurrentPath() { | |
| 550 | final String s = extractTextChunk(); | |
| 551 | final int length = s.length(); | |
| 552 | ||
| 553 | int i = 0; | |
| 554 | ||
| 555 | while( i < length && !isWhitespace( s.charAt( i ) ) ) { | |
| 556 | i++; | |
| 557 | } | |
| 558 | ||
| 559 | return s.substring( 0, i ); | |
| 560 | } | |
| 561 | ||
| 562 | private <T> ObservableList<TreeItem<T>> getSiblings( | |
| 563 | final TreeItem<T> item ) { | |
| 564 | final TreeItem<T> parent = item.getParent(); | |
| 565 | return parent == null ? item.getChildren() : parent.getChildren(); | |
| 566 | } | |
| 567 | ||
| 568 | private <T> TreeItem<T> getFirstSibling( final TreeItem<T> item ) { | |
| 569 | return getFirst( getSiblings( item ), item ); | |
| 570 | } | |
| 571 | ||
| 572 | private <T> TreeItem<T> getLastSibling( final TreeItem<T> item ) { | |
| 573 | return getLast( getSiblings( item ), item ); | |
| 574 | } | |
| 575 | ||
| 576 | /** | |
| 577 | * Returns the caret position as an offset into the text. | |
| 578 | * | |
| 579 | * @return A value from 0 to the length of the text (minus one). | |
| 580 | */ | |
| 581 | private int getCurrentCaretPosition() { | |
| 582 | return getEditor().getCaretPosition(); | |
| 583 | } | |
| 584 | ||
| 585 | /** | |
| 586 | * Returns the caret position within the current paragraph. | |
| 587 | * | |
| 588 | * @return A value from 0 to the length of the current paragraph. | |
| 589 | */ | |
| 590 | private int getCurrentCaretColumn() { | |
| 591 | return getEditor().getCaretColumn(); | |
| 592 | } | |
| 593 | ||
| 594 | /** | |
| 595 | * Returns the last word from the path. | |
| 596 | * | |
| 597 | * @return The last token. | |
| 598 | */ | |
| 599 | private String getLastPathWord() { | |
| 600 | String path = getCurrentPath(); | |
| 601 | ||
| 602 | int i = path.indexOf( SEPARATOR ); | |
| 603 | ||
| 604 | while( i > 0 ) { | |
| 605 | path = path.substring( i + 1 ); | |
| 606 | i = path.indexOf( SEPARATOR ); | |
| 607 | } | |
| 608 | ||
| 609 | return path; | |
| 610 | } | |
| 611 | ||
| 612 | /** | |
| 613 | * Returns text from the initial caret position until some arbitrarily long | |
| 614 | * number of characters. The number of characters extracted will be | |
| 615 | * getMaxVarLength, or fewer, depending on how many characters remain to be | |
| 616 | * extracted. The result from this method is trimmed to the first whitespace | |
| 617 | * character. | |
| 618 | * | |
| 619 | * @return A chunk of text that includes all the words representing a path, | |
| 620 | * and then some. | |
| 621 | */ | |
| 622 | private String extractTextChunk() { | |
| 623 | final StyledTextArea textArea = getEditor(); | |
| 624 | final int textBegan = getInitialCaretPosition(); | |
| 625 | final int remaining = textArea.getLength() - textBegan; | |
| 626 | final int textEnded = min( remaining, getMaxVarLength() ); | |
| 627 | ||
| 628 | return textArea.getText( textBegan, textEnded ); | |
| 629 | } | |
| 630 | ||
| 631 | /** | |
| 632 | * Returns the node for the current path. | |
| 633 | */ | |
| 634 | private TreeItem<String> getCurrentNode() { | |
| 635 | return findNode( getCurrentPath() ); | |
| 636 | } | |
| 637 | ||
| 638 | /** | |
| 639 | * Finds the node that most closely matches the given path. | |
| 640 | * | |
| 641 | * @param path The path that represents a node. | |
| 642 | * | |
| 643 | * @return The node for the path, or the root node if the path could not be | |
| 644 | * found, but never null. | |
| 645 | */ | |
| 646 | private TreeItem<String> findNode( final String path ) { | |
| 647 | return getDefinitionPane().findNode( path ); | |
| 648 | } | |
| 649 | ||
| 650 | /** | |
| 651 | * Finds the first leaf having a value that starts with the given text. | |
| 652 | * | |
| 653 | * @param text The text to find in the definition tree. | |
| 654 | * | |
| 655 | * @return The leaf that starts with the given text, or null if not found. | |
| 656 | */ | |
| 657 | private VariableTreeItem<String> findLeaf( final String text ) { | |
| 658 | return getDefinitionPane().findLeaf( text ); | |
| 659 | } | |
| 660 | ||
| 661 | /** | |
| 662 | * Used to ignore typed keys in favour of trapping pressed keys. | |
| 663 | * | |
| 664 | * @param e The key that was typed. | |
| 665 | */ | |
| 666 | private void vModeKeyTyped( KeyEvent e ) { | |
| 667 | e.consume(); | |
| 668 | } | |
| 669 | ||
| 670 | /** | |
| 671 | * Used to lazily initialize the keyboard map. | |
| 672 | * | |
| 673 | * @return Mappings for keyTyped and keyPressed. | |
| 674 | */ | |
| 675 | protected InputMap<InputEvent> createKeyboardMap() { | |
| 676 | return sequence( | |
| 677 | consume( keyTyped(), this::vModeKeyTyped ), | |
| 678 | consume( keyPressed(), this::vModeKeyPressed ) | |
| 679 | ); | |
| 680 | } | |
| 681 | ||
| 682 | private InputMap<InputEvent> getKeyboardMap() { | |
| 683 | if( this.keyboardMap == null ) { | |
| 684 | this.keyboardMap = createKeyboardMap(); | |
| 685 | } | |
| 686 | ||
| 687 | return this.keyboardMap; | |
| 688 | } | |
| 689 | ||
| 690 | /** | |
| 691 | * Collapses the tree then expands and selects the given node. | |
| 692 | * | |
| 693 | * @param node The node to expand. | |
| 694 | */ | |
| 695 | private void expand( final TreeItem<String> node ) { | |
| 696 | final DefinitionPane pane = getDefinitionPane(); | |
| 697 | pane.collapse(); | |
| 698 | pane.expand( node ); | |
| 699 | pane.select( node ); | |
| 700 | } | |
| 701 | ||
| 702 | /** | |
| 703 | * Returns true iff the key code the user typed can be used as part of a YAML | |
| 704 | * variable name. | |
| 705 | * | |
| 706 | * @param keyEvent Keyboard key press event information. | |
| 707 | * | |
| 708 | * @return true The key is a value that can be inserted into the text. | |
| 709 | */ | |
| 710 | private boolean isVariableNameKey( final KeyEvent keyEvent ) { | |
| 711 | final KeyCode kc = keyEvent.getCode(); | |
| 712 | ||
| 713 | return (kc.isLetterKey() | |
| 714 | || kc.isDigitKey() | |
| 715 | || (keyEvent.isShiftDown() && kc == MINUS)) | |
| 716 | && !keyEvent.isControlDown(); | |
| 717 | } | |
| 718 | ||
| 719 | /** | |
| 720 | * Starts to capture user input events. | |
| 721 | */ | |
| 722 | private void vModeStart() { | |
| 723 | addEventListener( getKeyboardMap() ); | |
| 724 | } | |
| 725 | ||
| 726 | /** | |
| 727 | * Restores capturing of user input events to the previous event listener. | |
| 728 | * Also asks the processing chain to modify the variable text into a | |
| 729 | * machine-readable variable based on the format required by the file type. | |
| 730 | * For example, a Markdown file (.md) will substitute a $VAR$ name while an R | |
| 731 | * file (.Rmd, .Rxml) will use `r#xVAR`. | |
| 732 | */ | |
| 733 | private void vModeStop() { | |
| 734 | removeEventListener( getKeyboardMap() ); | |
| 735 | } | |
| 736 | ||
| 737 | private VariableDecorator getVariableDecorator() { | |
| 738 | return new YamlVariableDecorator(); | |
| 739 | } | |
| 740 | ||
| 741 | /** | |
| 742 | * Returns the index where the two strings diverge. | |
| 743 | * | |
| 744 | * @param s1 The string that could be a substring of s2, null allowed. | |
| 745 | * @param s2 The string that could be a substring of s1, null allowed. | |
| 746 | * | |
| 747 | * @return NO_DIFFERENCE if the strings are the same, otherwise the index | |
| 748 | * where they differ. | |
| 749 | */ | |
| 750 | @SuppressWarnings( "StringEquality" ) | |
| 751 | private int difference( final CharSequence s1, final CharSequence s2 ) { | |
| 752 | if( s1 == s2 ) { | |
| 753 | return NO_DIFFERENCE; | |
| 754 | } | |
| 755 | ||
| 756 | if( s1 == null || s2 == null ) { | |
| 757 | return 0; | |
| 758 | } | |
| 759 | ||
| 760 | int i = 0; | |
| 761 | final int limit = min( s1.length(), s2.length() ); | |
| 762 | ||
| 763 | while( i < limit && s1.charAt( i ) == s2.charAt( i ) ) { | |
| 764 | i++; | |
| 765 | } | |
| 766 | ||
| 767 | // If one string was shorter than the other, that's where they differ. | |
| 768 | return i; | |
| 769 | } | |
| 770 | ||
| 771 | /** | |
| 772 | * Delegates to the file editor pane, and, ultimately, to its text area. | |
| 773 | */ | |
| 774 | private <T extends Event, U extends T> void addEventListener( | |
| 775 | final EventPattern<? super T, ? extends U> event, | |
| 776 | final Consumer<? super U> consumer ) { | |
| 777 | getFileEditorPane().addEventListener( event, consumer ); | |
| 778 | } | |
| 779 | ||
| 780 | /** | |
| 781 | * Delegates to the file editor pane, and, ultimately, to its text area. | |
| 782 | * | |
| 783 | * @param map The map of methods to events. | |
| 784 | */ | |
| 785 | private void addEventListener( final InputMap<InputEvent> map ) { | |
| 786 | getFileEditorPane().addEventListener( map ); | |
| 787 | } | |
| 788 | ||
| 789 | private void removeEventListener( final InputMap<InputEvent> map ) { | |
| 790 | getFileEditorPane().removeEventListener( map ); | |
| 791 | } | |
| 792 | ||
| 793 | /** | |
| 794 | * Returns the position of the caret when variable mode editing was requested. | |
| 795 | * | |
| 796 | * @return The variable mode caret position. | |
| 797 | */ | |
| 798 | private int getInitialCaretPosition() { | |
| 799 | return this.initialCaretPosition; | |
| 800 | } | |
| 801 | ||
| 802 | /** | |
| 803 | * Sets the position of the caret when variable mode editing was requested. | |
| 804 | * Stores the current position because only the text that comes afterwards is | |
| 805 | * a suitable variable reference. | |
| 806 | * | |
| 807 | * @return The variable mode caret position. | |
| 808 | */ | |
| 809 | private void setInitialCaretPosition() { | |
| 810 | this.initialCaretPosition = getEditor().getCaretPosition(); | |
| 811 | } | |
| 812 | ||
| 813 | private StyledTextArea getEditor() { | |
| 814 | return getFileEditorPane().getEditor(); | |
| 815 | } | |
| 816 | ||
| 817 | public FileEditorTabPane getFileEditorPane() { | |
| 818 | return this.fileEditorPane; | |
| 819 | } | |
| 820 | ||
| 821 | private void setFileEditorPane( final FileEditorTabPane fileEditorPane ) { | |
| 822 | this.fileEditorPane = fileEditorPane; | |
| 823 | } | |
| 824 | ||
| 825 | private DefinitionPane getDefinitionPane() { | |
| 826 | return this.definitionPane; | |
| 827 | } | |
| 828 | ||
| 829 | private void setDefinitionPane( final DefinitionPane definitionPane ) { | |
| 830 | this.definitionPane = definitionPane; | |
| 831 | } | |
| 832 | ||
| 833 | private IndexRange getSelectionRange() { | |
| 834 | return getEditor().getSelection(); | |
| 835 | } | |
| 836 | ||
| 837 | /** | |
| 838 | * Don't look ahead too far when trying to find the end of a node. | |
| 839 | * | |
| 840 | * @return 512 by default. | |
| 841 | */ | |
| 842 | private int getMaxVarLength() { | |
| 843 | return getSettings().getSetting( | |
| 844 | "editor.variable.maxLength", DEFAULT_MAX_VAR_LENGTH ); | |
| 845 | } | |
| 846 | ||
| 847 | private Settings getSettings() { | |
| 848 | return this.settings; | |
| 849 | } | |
| 850 | } | |
| 1 | 851 |
| 1 | /* | |
| 2 | * Copyright 2016 White Magic Software, Ltd. | |
| 3 | * | |
| 4 | * All rights reserved. | |
| 5 | * | |
| 6 | * Redistribution and use in source and binary forms, with or without | |
| 7 | * modification, are permitted provided that the following conditions are met: | |
| 8 | * | |
| 9 | * o Redistributions of source code must retain the above copyright | |
| 10 | * notice, this list of conditions and the following disclaimer. | |
| 11 | * | |
| 12 | * o Redistributions in binary form must reproduce the above copyright | |
| 13 | * notice, this list of conditions and the following disclaimer in the | |
| 14 | * documentation and/or other materials provided with the distribution. | |
| 15 | * | |
| 16 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | |
| 17 | * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | |
| 18 | * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | |
| 19 | * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT | |
| 20 | * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | |
| 21 | * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT | |
| 22 | * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | |
| 23 | * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | |
| 24 | * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | |
| 25 | * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | |
| 26 | * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | |
| 27 | */ | |
| 28 | package com.scrivenvar.editors.markdown; | |
| 29 | ||
| 30 | import com.vladsch.flexmark.ast.Link; | |
| 31 | ||
| 32 | /** | |
| 33 | * Represents the model for a hyperlink: text and url text. | |
| 34 | * | |
| 35 | * @author White Magic Software, Ltd. | |
| 36 | */ | |
| 37 | public class HyperlinkModel { | |
| 38 | ||
| 39 | private String text; | |
| 40 | private String url; | |
| 41 | private String title; | |
| 42 | ||
| 43 | /** | |
| 44 | * Constructs a new hyperlink model in Markdown format by default with no | |
| 45 | * title (i.e., tooltip). | |
| 46 | * | |
| 47 | * @param text The hyperlink text displayed (e.g., displayed to the user). | |
| 48 | * @param url The destination URL (e.g., when clicked). | |
| 49 | */ | |
| 50 | public HyperlinkModel( final String text, final String url ) { | |
| 51 | this( text, url, null ); | |
| 52 | } | |
| 53 | ||
| 54 | /** | |
| 55 | * Constructs a new hyperlink model for the given AST link. | |
| 56 | * | |
| 57 | * @param link A markdown link. | |
| 58 | */ | |
| 59 | public HyperlinkModel( final Link link ) { | |
| 60 | this( | |
| 61 | link.getText().toString(), | |
| 62 | link.getUrl().toString(), | |
| 63 | link.getTitle().toString() | |
| 64 | ); | |
| 65 | } | |
| 66 | ||
| 67 | /** | |
| 68 | * Constructs a new hyperlink model in Markdown format by default. | |
| 69 | * | |
| 70 | * @param text The hyperlink text displayed (e.g., displayed to the user). | |
| 71 | * @param url The destination URL (e.g., when clicked). | |
| 72 | * @param title The hyperlink title (e.g., shown as a tooltip). | |
| 73 | */ | |
| 74 | public HyperlinkModel( final String text, final String url, final String title ) { | |
| 75 | setText( text ); | |
| 76 | setUrl( url ); | |
| 77 | setTitle( title ); | |
| 78 | } | |
| 79 | ||
| 80 | /** | |
| 81 | * Returns the string in Markdown format by default. | |
| 82 | * | |
| 83 | * @return A markdown version of the hyperlink. | |
| 84 | */ | |
| 85 | @Override | |
| 86 | public String toString() { | |
| 87 | String format = "%s%s%s"; | |
| 88 | ||
| 89 | if( hasText() ) { | |
| 90 | format = "[%s]" + (hasTitle() ? "(%s \"%s\")" : "(%s%s)"); | |
| 91 | } | |
| 92 | ||
| 93 | // Becomes ""+URL+"" if no text is set. | |
| 94 | // Becomes [TITLE]+(URL)+"" if no title is set. | |
| 95 | // Becomes [TITLE]+(URL+ \"TITLE\") if title is set. | |
| 96 | return String.format( format, getText(), getUrl(), getTitle() ); | |
| 97 | } | |
| 98 | ||
| 99 | public final void setText( final String text ) { | |
| 100 | this.text = nullSafe( text ); | |
| 101 | } | |
| 102 | ||
| 103 | public final void setUrl( final String url ) { | |
| 104 | this.url = nullSafe( url ); | |
| 105 | } | |
| 106 | ||
| 107 | public final void setTitle( final String title ) { | |
| 108 | this.title = nullSafe( title ); | |
| 109 | } | |
| 110 | ||
| 111 | /** | |
| 112 | * Answers whether text has been set for the hyperlink. | |
| 113 | * | |
| 114 | * @return true This is a text link. | |
| 115 | */ | |
| 116 | public boolean hasText() { | |
| 117 | return !getText().isEmpty(); | |
| 118 | } | |
| 119 | ||
| 120 | /** | |
| 121 | * Answers whether a title (tooltip) has been set for the hyperlink. | |
| 122 | * | |
| 123 | * @return true There is a title. | |
| 124 | */ | |
| 125 | public boolean hasTitle() { | |
| 126 | return !getTitle().isEmpty(); | |
| 127 | } | |
| 128 | ||
| 129 | public String getText() { | |
| 130 | return this.text; | |
| 131 | } | |
| 132 | ||
| 133 | public String getUrl() { | |
| 134 | return this.url; | |
| 135 | } | |
| 136 | ||
| 137 | public String getTitle() { | |
| 138 | return this.title; | |
| 139 | } | |
| 140 | ||
| 141 | private String nullSafe( final String s ) { | |
| 142 | return s == null ? "" : s; | |
| 143 | } | |
| 144 | } | |
| 1 | 145 |
| 1 | /* | |
| 2 | * Copyright 2016 White Magic Software, Ltd. | |
| 3 | * | |
| 4 | * All rights reserved. | |
| 5 | * | |
| 6 | * Redistribution and use in source and binary forms, with or without | |
| 7 | * modification, are permitted provided that the following conditions are met: | |
| 8 | * | |
| 9 | * o Redistributions of source code must retain the above copyright | |
| 10 | * notice, this list of conditions and the following disclaimer. | |
| 11 | * | |
| 12 | * o Redistributions in binary form must reproduce the above copyright | |
| 13 | * notice, this list of conditions and the following disclaimer in the | |
| 14 | * documentation and/or other materials provided with the distribution. | |
| 15 | * | |
| 16 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | |
| 17 | * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | |
| 18 | * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | |
| 19 | * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT | |
| 20 | * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | |
| 21 | * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT | |
| 22 | * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | |
| 23 | * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | |
| 24 | * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | |
| 25 | * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | |
| 26 | * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | |
| 27 | */ | |
| 28 | package com.scrivenvar.editors.markdown; | |
| 29 | ||
| 30 | import com.vladsch.flexmark.ast.Link; | |
| 31 | import com.vladsch.flexmark.ast.Node; | |
| 32 | import com.vladsch.flexmark.ast.NodeVisitor; | |
| 33 | import com.vladsch.flexmark.ast.VisitHandler; | |
| 34 | ||
| 35 | /** | |
| 36 | * @author White Magic Software, Ltd. | |
| 37 | */ | |
| 38 | public class LinkVisitor { | |
| 39 | ||
| 40 | private NodeVisitor visitor; | |
| 41 | private Link link; | |
| 42 | private final int offset; | |
| 43 | ||
| 44 | /** | |
| 45 | * Creates a hyperlink given an offset into a paragraph and the markdown AST | |
| 46 | * link node. | |
| 47 | * | |
| 48 | * @param index Index into the paragraph that indicates the hyperlink to | |
| 49 | * change. | |
| 50 | */ | |
| 51 | public LinkVisitor( final int index ) { | |
| 52 | this.offset = index; | |
| 53 | } | |
| 54 | ||
| 55 | public Link process( final Node root ) { | |
| 56 | getVisitor().visit( root ); | |
| 57 | return getLink(); | |
| 58 | } | |
| 59 | ||
| 60 | /** | |
| 61 | * | |
| 62 | * @param link Not null. | |
| 63 | */ | |
| 64 | private void visit( final Link link ) { | |
| 65 | final int began = link.getStartOffset(); | |
| 66 | final int ended = link.getEndOffset(); | |
| 67 | final int index = getOffset(); | |
| 68 | ||
| 69 | if( index >= began && index <= ended ) { | |
| 70 | setLink( link ); | |
| 71 | } | |
| 72 | } | |
| 73 | ||
| 74 | private synchronized NodeVisitor getVisitor() { | |
| 75 | if( this.visitor == null ) { | |
| 76 | this.visitor = createVisitor(); | |
| 77 | } | |
| 78 | ||
| 79 | return this.visitor; | |
| 80 | } | |
| 81 | ||
| 82 | protected NodeVisitor createVisitor() { | |
| 83 | return new NodeVisitor( | |
| 84 | new VisitHandler<>( Link.class, LinkVisitor.this::visit ) ); | |
| 85 | } | |
| 86 | ||
| 87 | private Link getLink() { | |
| 88 | return this.link; | |
| 89 | } | |
| 90 | ||
| 91 | private void setLink( final Link link ) { | |
| 92 | this.link = link; | |
| 93 | } | |
| 94 | ||
| 95 | public int getOffset() { | |
| 96 | return this.offset; | |
| 97 | } | |
| 98 | } | |
| 1 | 99 |
| 1 | /* | |
| 2 | * Copyright 2016 Karl Tauber and White Magic Software, Ltd. | |
| 3 | * | |
| 4 | * All rights reserved. | |
| 5 | * | |
| 6 | * Redistribution and use in source and binary forms, with or without | |
| 7 | * modification, are permitted provided that the following conditions are met: | |
| 8 | * | |
| 9 | * o Redistributions of source code must retain the above copyright | |
| 10 | * notice, this list of conditions and the following disclaimer. | |
| 11 | * | |
| 12 | * o Redistributions in binary form must reproduce the above copyright | |
| 13 | * notice, this list of conditions and the following disclaimer in the | |
| 14 | * documentation and/or other materials provided with the distribution. | |
| 15 | * | |
| 16 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | |
| 17 | * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | |
| 18 | * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | |
| 19 | * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT | |
| 20 | * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | |
| 21 | * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT | |
| 22 | * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | |
| 23 | * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | |
| 24 | * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | |
| 25 | * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | |
| 26 | * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | |
| 27 | */ | |
| 28 | package com.scrivenvar.editors.markdown; | |
| 29 | ||
| 30 | import com.scrivenvar.dialogs.ImageDialog; | |
| 31 | import com.scrivenvar.dialogs.LinkDialog; | |
| 32 | import com.scrivenvar.editors.EditorPane; | |
| 33 | import com.scrivenvar.processors.MarkdownProcessor; | |
| 34 | import static com.scrivenvar.util.Utils.ltrim; | |
| 35 | import static com.scrivenvar.util.Utils.rtrim; | |
| 36 | import com.vladsch.flexmark.ast.Link; | |
| 37 | import com.vladsch.flexmark.ast.Node; | |
| 38 | import java.nio.file.Path; | |
| 39 | import java.util.regex.Matcher; | |
| 40 | import java.util.regex.Pattern; | |
| 41 | import javafx.beans.value.ObservableValue; | |
| 42 | import javafx.scene.control.Dialog; | |
| 43 | import javafx.scene.control.IndexRange; | |
| 44 | import static javafx.scene.input.KeyCode.ENTER; | |
| 45 | import javafx.scene.input.KeyEvent; | |
| 46 | import javafx.stage.Window; | |
| 47 | import org.fxmisc.richtext.StyleClassedTextArea; | |
| 48 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 49 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 50 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 51 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 52 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 53 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 54 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 55 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 56 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 57 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 58 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 59 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 60 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 61 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 62 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 63 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 64 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 65 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 66 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 67 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 68 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 69 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 70 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 71 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 72 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 73 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 74 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 75 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 76 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 77 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 78 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 79 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 80 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 81 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 82 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 83 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 84 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 85 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 86 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 87 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 88 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 89 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 90 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 91 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 92 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 93 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 94 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 95 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 96 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 97 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 98 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 99 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 100 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 101 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 102 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 103 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 104 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 105 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 106 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 107 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 108 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 109 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 110 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 111 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 112 | import static com.scrivenvar.Constants.STYLESHEET_MARKDOWN; | |
| 113 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 114 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 115 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 116 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 117 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 118 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 119 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 120 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 121 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 122 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 123 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 124 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 125 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 126 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 127 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 128 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 129 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 130 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 131 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 132 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 133 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 134 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 135 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 136 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 137 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 138 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 139 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 140 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 141 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 142 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 143 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 144 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 145 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 146 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 147 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 148 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 149 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 150 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 151 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 152 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 153 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 154 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 155 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 156 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 157 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 158 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 159 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 160 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 161 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 162 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 163 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 164 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 165 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 166 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 167 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 168 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 169 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 170 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 171 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 172 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 173 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 174 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 175 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 176 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 177 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 178 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 179 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 180 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 181 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 182 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 183 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 184 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 185 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 186 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 187 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 188 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 189 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 190 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 191 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 192 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 193 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 194 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 195 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 196 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 197 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 198 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 199 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 200 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 201 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 202 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 203 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 204 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 205 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 206 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 207 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 208 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 209 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 210 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 211 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 212 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 213 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 214 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 215 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 216 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 217 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 218 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 219 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 220 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 221 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 222 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 223 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 224 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 225 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 226 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 227 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 228 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 229 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 230 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 231 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 232 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 233 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 234 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 235 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 236 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 237 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 238 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 239 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 240 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 241 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 242 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 243 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 244 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 245 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 246 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 247 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 248 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 249 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 250 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 251 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 252 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 253 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 254 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 255 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 256 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 257 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 258 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 259 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 260 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 261 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 262 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 263 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 264 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 265 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 266 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 267 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 268 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 269 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 270 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 271 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 272 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 273 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 274 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 275 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 276 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 277 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 278 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 279 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 280 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 281 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 282 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 283 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 284 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 285 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 286 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 287 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 288 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 289 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 290 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 291 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 292 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 293 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 294 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 295 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 296 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 297 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 298 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 299 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 300 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 301 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 302 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 303 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 304 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 305 | ||
| 306 | /** | |
| 307 | * Markdown editor pane. | |
| 308 | * | |
| 309 | * @author Karl Tauber and White Magic Software, Ltd. | |
| 310 | */ | |
| 311 | public class MarkdownEditorPane extends EditorPane { | |
| 312 | ||
| 313 | private static final Pattern AUTO_INDENT_PATTERN = Pattern.compile( | |
| 314 | "(\\s*[*+-]\\s+|\\s*[0-9]+\\.\\s+|\\s+)(.*)" ); | |
| 315 | ||
| 316 | public MarkdownEditorPane() { | |
| 317 | initEditor(); | |
| 318 | } | |
| 319 | ||
| 320 | private void initEditor() { | |
| 321 | final StyleClassedTextArea textArea = getEditor(); | |
| 322 | ||
| 323 | textArea.setWrapText( true ); | |
| 324 | textArea.getStyleClass().add( "markdown-editor" ); | |
| 325 | textArea.getStylesheets().add(STYLESHEET_MARKDOWN ); | |
| 326 | ||
| 327 | addEventListener( keyPressed( ENTER ), this::enterPressed ); | |
| 328 | ||
| 329 | // TODO: Wait for implementation that allows cutting lines, not paragraphs. | |
| 330 | // addEventListener( keyPressed( X, SHORTCUT_DOWN ), this::cutLine ); | |
| 331 | } | |
| 332 | ||
| 333 | public ObservableValue<String> markdownProperty() { | |
| 334 | return getEditor().textProperty(); | |
| 335 | } | |
| 336 | ||
| 337 | private void enterPressed( final KeyEvent e ) { | |
| 338 | final StyleClassedTextArea textArea = getEditor(); | |
| 339 | final String currentLine = textArea.getText( textArea.getCurrentParagraph() ); | |
| 340 | final Matcher matcher = AUTO_INDENT_PATTERN.matcher( currentLine ); | |
| 341 | ||
| 342 | String newText = "\n"; | |
| 343 | ||
| 344 | if( matcher.matches() ) { | |
| 345 | if( !matcher.group( 2 ).isEmpty() ) { | |
| 346 | // indent new line with same whitespace characters and list markers as current line | |
| 347 | newText = newText.concat( matcher.group( 1 ) ); | |
| 348 | } else { | |
| 349 | // current line contains only whitespace characters and list markers | |
| 350 | // --> empty current line | |
| 351 | final int caretPosition = textArea.getCaretPosition(); | |
| 352 | textArea.selectRange( caretPosition - currentLine.length(), caretPosition ); | |
| 353 | } | |
| 354 | } | |
| 355 | ||
| 356 | textArea.replaceSelection( newText ); | |
| 357 | } | |
| 358 | ||
| 359 | public void surroundSelection( final String leading, final String trailing ) { | |
| 360 | surroundSelection( leading, trailing, null ); | |
| 361 | } | |
| 362 | ||
| 363 | public void surroundSelection( String leading, String trailing, final String hint ) { | |
| 364 | final StyleClassedTextArea textArea = getEditor(); | |
| 365 | ||
| 366 | // Note: not using textArea.insertText() to insert leading and trailing | |
| 367 | // because this would add two changes to undo history | |
| 368 | final IndexRange selection = textArea.getSelection(); | |
| 369 | int start = selection.getStart(); | |
| 370 | int end = selection.getEnd(); | |
| 371 | ||
| 372 | final String selectedText = textArea.getSelectedText(); | |
| 373 | ||
| 374 | // remove leading and trailing whitespaces from selected text | |
| 375 | String trimmedSelectedText = selectedText.trim(); | |
| 376 | if( trimmedSelectedText.length() < selectedText.length() ) { | |
| 377 | start += selectedText.indexOf( trimmedSelectedText ); | |
| 378 | end = start + trimmedSelectedText.length(); | |
| 379 | } | |
| 380 | ||
| 381 | // remove leading whitespaces from leading text if selection starts at zero | |
| 382 | if( start == 0 ) { | |
| 383 | leading = ltrim( leading ); | |
| 384 | } | |
| 385 | ||
| 386 | // remove trailing whitespaces from trailing text if selection ends at text end | |
| 387 | if( end == textArea.getLength() ) { | |
| 388 | trailing = rtrim( trailing ); | |
| 389 | } | |
| 390 | ||
| 391 | // remove leading line separators from leading text | |
| 392 | // if there are line separators before the selected text | |
| 393 | if( leading.startsWith( "\n" ) ) { | |
| 394 | for( int i = start - 1; i >= 0 && leading.startsWith( "\n" ); i-- ) { | |
| 395 | if( !"\n".equals( textArea.getText( i, i + 1 ) ) ) { | |
| 396 | break; | |
| 397 | } | |
| 398 | leading = leading.substring( 1 ); | |
| 399 | } | |
| 400 | } | |
| 401 | ||
| 402 | // remove trailing line separators from trailing or leading text | |
| 403 | // if there are line separators after the selected text | |
| 404 | final boolean trailingIsEmpty = trailing.isEmpty(); | |
| 405 | String str = trailingIsEmpty ? leading : trailing; | |
| 406 | ||
| 407 | if( str.endsWith( "\n" ) ) { | |
| 408 | final int length = textArea.getLength(); | |
| 409 | ||
| 410 | for( int i = end; i < length && str.endsWith( "\n" ); i++ ) { | |
| 411 | if( !"\n".equals( textArea.getText( i, i + 1 ) ) ) { | |
| 412 | break; | |
| 413 | } | |
| 414 | ||
| 415 | str = str.substring( 0, str.length() - 1 ); | |
| 416 | } | |
| 417 | ||
| 418 | if( trailingIsEmpty ) { | |
| 419 | leading = str; | |
| 420 | } else { | |
| 421 | trailing = str; | |
| 422 | } | |
| 423 | } | |
| 424 | ||
| 425 | int selStart = start + leading.length(); | |
| 426 | int selEnd = end + leading.length(); | |
| 427 | ||
| 428 | // insert hint text if selection is empty | |
| 429 | if( hint != null && trimmedSelectedText.isEmpty() ) { | |
| 430 | trimmedSelectedText = hint; | |
| 431 | selEnd = selStart + hint.length(); | |
| 432 | } | |
| 433 | ||
| 434 | // prevent undo merging with previous text entered by user | |
| 435 | getUndoManager().preventMerge(); | |
| 436 | ||
| 437 | // replace text and update selection | |
| 438 | textArea.replaceText( start, end, leading + trimmedSelectedText + trailing ); | |
| 439 | textArea.selectRange( selStart, selEnd ); | |
| 440 | } | |
| 441 | ||
| 442 | /** | |
| 443 | * Returns one of: selected text, word under cursor, or parsed hyperlink from | |
| 444 | * the markdown AST. | |
| 445 | * | |
| 446 | * @return | |
| 447 | */ | |
| 448 | private HyperlinkModel getHyperlink() { | |
| 449 | final StyleClassedTextArea textArea = getEditor(); | |
| 450 | final String selectedText = textArea.getSelectedText(); | |
| 451 | ||
| 452 | // Get the current paragraph, convert to Markdown nodes. | |
| 453 | final MarkdownProcessor mp = new MarkdownProcessor( null ); | |
| 454 | final int p = textArea.getCurrentParagraph(); | |
| 455 | final String paragraph = textArea.getText( p ); | |
| 456 | final Node node = mp.toNode( paragraph ); | |
| 457 | final LinkVisitor visitor = new LinkVisitor( textArea.getCaretColumn() ); | |
| 458 | final Link link = visitor.process( node ); | |
| 459 | ||
| 460 | if( link != null ) { | |
| 461 | textArea.selectRange( p, link.getStartOffset(), p, link.getEndOffset() ); | |
| 462 | } | |
| 463 | ||
| 464 | final HyperlinkModel model = createHyperlinkModel( | |
| 465 | link, selectedText, "https://website.com" | |
| 466 | ); | |
| 467 | ||
| 468 | return model; | |
| 469 | } | |
| 470 | ||
| 471 | private HyperlinkModel createHyperlinkModel( | |
| 472 | final Link link, final String selection, final String url ) { | |
| 473 | ||
| 474 | return link == null | |
| 475 | ? new HyperlinkModel( selection, url ) | |
| 476 | : new HyperlinkModel( link ); | |
| 477 | } | |
| 478 | ||
| 479 | private Path getParentPath() { | |
| 480 | final Path parentPath = getPath(); | |
| 481 | return (parentPath != null) ? parentPath.getParent() : null; | |
| 482 | } | |
| 483 | ||
| 484 | private Dialog<String> createLinkDialog() { | |
| 485 | return new LinkDialog( getWindow(), getHyperlink(), getParentPath() ); | |
| 486 | } | |
| 487 | ||
| 488 | private Dialog<String> createImageDialog() { | |
| 489 | return new ImageDialog( getWindow(), getParentPath() ); | |
| 490 | } | |
| 491 | ||
| 492 | private void insertObject( final Dialog<String> dialog ) { | |
| 493 | dialog.showAndWait().ifPresent( result -> { | |
| 494 | getEditor().replaceSelection( result ); | |
| 495 | } ); | |
| 496 | } | |
| 497 | ||
| 498 | public void insertLink() { | |
| 499 | insertObject( createLinkDialog() ); | |
| 500 | } | |
| 501 | ||
| 502 | public void insertImage() { | |
| 503 | insertObject( createImageDialog() ); | |
| 504 | } | |
| 505 | ||
| 506 | private Window getWindow() { | |
| 507 | return getScrollPane().getScene().getWindow(); | |
| 508 | } | |
| 509 | } | |
| 1 | 510 |
| 36 | 36 | |
| 37 | 37 | /** |
| 38 | * Calls the superclass to construct the instance. | |
| 38 | * Constructs a new instance using a comparate that will be compared with | |
| 39 | * the comparator during the test. | |
| 39 | 40 | * |
| 40 | * @param comparate Not null. | |
| 41 | * @param comparate The string to compare against the comparator. | |
| 41 | 42 | */ |
| 42 | 43 | public StartsPredicate( final String comparate ) { |
| ... | ||
| 49 | 50 | * @param comparator A non-null string, possibly empty. |
| 50 | 51 | * |
| 51 | * @return true The strings are equal, ignoring case. | |
| 52 | * @return true The comparator starts with the comparate, ignoring case. | |
| 52 | 53 | */ |
| 53 | 54 | @Override |
| 28 | 28 | package com.scrivenvar.preview; |
| 29 | 29 | |
| 30 | import static com.scrivenvar.Constants.CARET_POSITION; | |
| 30 | import static com.scrivenvar.Constants.CARET_POSITION_BASE; | |
| 31 | import static com.scrivenvar.Constants.STYLESHEET_PREVIEW; | |
| 31 | 32 | import java.nio.file.Path; |
| 32 | 33 | import javafx.beans.value.ObservableValue; |
| ... | ||
| 99 | 100 | + "<html>" |
| 100 | 101 | + "<head>" |
| 101 | + "<link rel='stylesheet' href='" + getClass().getResource( "webview.css" ) + "'>" | |
| 102 | + "<link rel='stylesheet' href='" + getClass().getResource( STYLESHEET_PREVIEW ) + "'>" | |
| 102 | 103 | + getBase() |
| 103 | 104 | + "</head>" |
| 104 | 105 | + "<body>" |
| 105 | 106 | + html |
| 106 | 107 | + "</body>" |
| 107 | 108 | + "</html>" ); |
| 109 | } | |
| 110 | ||
| 111 | /** | |
| 112 | * Clears out the HTML content from the preview. | |
| 113 | */ | |
| 114 | public void clear() { | |
| 115 | update( "" ); | |
| 108 | 116 | } |
| 109 | 117 | |
| ... | ||
| 122 | 130 | private String getScrollScript() { |
| 123 | 131 | return "" |
| 124 | + "var e = document.getElementById('" + CARET_POSITION + "');" | |
| 132 | + "var e = document.getElementById('" + CARET_POSITION_BASE + "');" | |
| 125 | 133 | + "if( e != null ) { " |
| 126 | 134 | + " Element.prototype.topOffset = function () {" |
| ... | ||
| 157 | 165 | this.path = path; |
| 158 | 166 | } |
| 159 | ||
| 167 | ||
| 160 | 168 | /** |
| 161 | 169 | * Content to embed in a panel. |
| 162 | * | |
| 170 | * | |
| 163 | 171 | * @return The content to display to the user. |
| 164 | 172 | */ |
| 28 | 28 | package com.scrivenvar.processors; |
| 29 | 29 | |
| 30 | import static com.scrivenvar.Constants.MD_CARET_POSITION; | |
| 30 | import static com.scrivenvar.Constants.CARET_POSITION_MD; | |
| 31 | 31 | import static java.lang.Character.isLetter; |
| 32 | 32 | |
| 33 | 33 | /** |
| 34 | * Responsible for inserting the magic CARET POSITION into the markdown so | |
| 35 | * that, upon rendering into HTML, the HTML pane can scroll to the correct | |
| 36 | * position (relative to the caret position in the editor). | |
| 34 | * Responsible for inserting the magic CARET POSITION into the markdown so that, | |
| 35 | * upon rendering into HTML, the HTML pane can scroll to the correct position | |
| 36 | * (relative to the caret position in the editor). | |
| 37 | 37 | * |
| 38 | 38 | * @author White Magic Software, Ltd. |
| ... | ||
| 73 | 73 | offset++; |
| 74 | 74 | } |
| 75 | ||
| 75 | ||
| 76 | 76 | // TODO: Ensure that the caret position is outside of an element, |
| 77 | 77 | // so that a caret inserted in the image doesn't corrupt it. Such as: |
| 78 | 78 | // |
| 79 | 79 | //  |
| 80 | ||
| 81 | 80 | // Insert the caret position into the Markdown text, but don't interfere |
| 82 | 81 | // with the Markdown iteself. |
| 83 | 82 | return new StringBuilder( t ).replace( |
| 84 | offset, offset, MD_CARET_POSITION ).toString(); | |
| 83 | offset, offset, CARET_POSITION_MD ).toString(); | |
| 85 | 84 | } |
| 86 | 85 | |
| 28 | 28 | package com.scrivenvar.processors; |
| 29 | 29 | |
| 30 | import static com.scrivenvar.Constants.CARET_POSITION; | |
| 31 | import static com.scrivenvar.Constants.MD_CARET_POSITION; | |
| 30 | import static com.scrivenvar.Constants.CARET_POSITION_HTML; | |
| 31 | import static com.scrivenvar.Constants.CARET_POSITION_MD; | |
| 32 | 32 | |
| 33 | 33 | /** |
| 34 | 34 | * Responsible for replacing the caret position marker with an HTML element |
| 35 | 35 | * suitable to use as a reference for scrolling a view port. |
| 36 | 36 | * |
| 37 | 37 | * @author White Magic Software, Ltd. |
| 38 | 38 | */ |
| 39 | 39 | public class MarkdownCaretReplacementProcessor extends AbstractProcessor<String> { |
| 40 | 40 | private static final int INDEX_NOT_FOUND = -1; |
| 41 | ||
| 42 | private static final String HTML_ELEMENT | |
| 43 | = "<span id='" + CARET_POSITION + "'></span>"; | |
| 44 | 41 | |
| 45 | 42 | public MarkdownCaretReplacementProcessor( final Processor<String> processor ) { |
| ... | ||
| 57 | 54 | @Override |
| 58 | 55 | public String processLink( final String t ) { |
| 59 | return replace( t, MD_CARET_POSITION, HTML_ELEMENT ); | |
| 56 | return replace(t, CARET_POSITION_MD, CARET_POSITION_HTML ); | |
| 60 | 57 | } |
| 61 | 58 | |
| 42 | 42 | * |
| 43 | 43 | * @param t The value to pass along to each link in the chain. |
| 44 | * @return The value after having been processed by each link. | |
| 45 | 44 | */ |
| 46 | 45 | public void processChain( T t ); |
| 28 | 28 | package com.scrivenvar.service; |
| 29 | 29 | |
| 30 | import java.util.Iterator; | |
| 30 | 31 | import java.util.List; |
| 31 | 32 | |
| 32 | 33 | /** |
| 33 | 34 | * Defines how settings and options can be retrieved. |
| 34 | * | |
| 35 | * | |
| 35 | 36 | * @author White Magic Software, Ltd. |
| 36 | 37 | */ |
| 37 | 38 | public interface Settings extends Service { |
| 38 | 39 | |
| 39 | 40 | /** |
| 40 | 41 | * Returns a setting property or its default value. |
| 41 | 42 | * |
| 42 | 43 | * @param property The property key name to obtain its value. |
| 43 | * @param defaultValue The default value to return iff the property cannot | |
| 44 | * be found. | |
| 44 | * @param defaultValue The default value to return iff the property cannot be | |
| 45 | * found. | |
| 45 | 46 | * |
| 46 | 47 | * @return The property value for the given property key. |
| 47 | 48 | */ |
| 48 | 49 | public String getSetting( String property, String defaultValue ); |
| 49 | ||
| 50 | ||
| 50 | 51 | /** |
| 51 | 52 | * Returns a setting property or its default value. |
| 52 | 53 | * |
| 53 | 54 | * @param property The property key name to obtain its value. |
| 54 | * @param defaultValue The default value to return iff the property cannot | |
| 55 | * be found. | |
| 55 | * @param defaultValue The default value to return iff the property cannot be | |
| 56 | * found. | |
| 56 | 57 | * |
| 57 | 58 | * @return The property value for the given property key. |
| 58 | 59 | */ |
| 59 | 60 | public int getSetting( String property, int defaultValue ); |
| 60 | 61 | |
| 61 | 62 | /** |
| 62 | 63 | * Returns a setting property or its default value. |
| 63 | 64 | * |
| 64 | 65 | * @param property The property key name to obtain its value. |
| 65 | * @param defaults The default values to return iff the property cannot | |
| 66 | * be found. | |
| 66 | * @param defaults The default values to return iff the property cannot be | |
| 67 | * found. | |
| 67 | 68 | * |
| 68 | 69 | * @return The property values for the given property key. |
| 69 | 70 | */ |
| 70 | 71 | public List<Object> getSettingList( String property, List<String> defaults ); |
| 71 | 72 | |
| 73 | /** | |
| 74 | * Returns a list of property names that begin with the given prefix. The | |
| 75 | * prefix is included in any matching results. This will return keys that | |
| 76 | * either match the prefix or start with the prefix followed by a dot ('.'). | |
| 77 | * For example, a prefix value of <code>the.property.name</code> will likely | |
| 78 | * return the expected results, but <code>the.property.name.</code> (note the | |
| 79 | * extraneous period) will probably not. | |
| 80 | * | |
| 81 | * @param prefix The prefix to compare against each property name. | |
| 82 | * | |
| 83 | * @return The list of property names that have the given prefix. | |
| 84 | */ | |
| 85 | public Iterator<String> getKeys( final String prefix ); | |
| 72 | 86 | |
| 73 | 87 | /** |
| ... | ||
| 80 | 94 | */ |
| 81 | 95 | public List<String> getStringSettingList( String property, List<String> defaults ); |
| 96 | ||
| 97 | /** | |
| 98 | * Converts the generic list of property objects into strings. | |
| 99 | * | |
| 100 | * @param property The property value to coerce. | |
| 101 | * | |
| 102 | * @return The list of properties coerced from objects to strings. | |
| 103 | */ | |
| 104 | public List<String> getStringSettingList( String property ); | |
| 82 | 105 | } |
| 83 | 106 | |
| 27 | 27 | package com.scrivenvar.service.impl; |
| 28 | 28 | |
| 29 | import static com.scrivenvar.Constants.PREFS_ROOT; | |
| 30 | import static com.scrivenvar.Constants.PREFS_ROOT_OPTIONS; | |
| 31 | import static com.scrivenvar.Constants.PREFS_ROOT_STATE; | |
| 29 | 32 | import com.scrivenvar.service.Options; |
| 30 | 33 | import java.util.prefs.Preferences; |
| ... | ||
| 40 | 43 | |
| 41 | 44 | public DefaultOptions() { |
| 42 | setPreferences( getRootPreferences().node( "options" ) ); | |
| 45 | setPreferences( getRootPreferences().node( PREFS_ROOT_OPTIONS ) ); | |
| 43 | 46 | } |
| 44 | 47 | |
| ... | ||
| 58 | 61 | |
| 59 | 62 | private Preferences getRootPreferences() { |
| 60 | return userRoot().node( "application" ); | |
| 63 | return userRoot().node( PREFS_ROOT ); | |
| 61 | 64 | } |
| 62 | 65 | |
| 63 | 66 | @Override |
| 64 | 67 | public Preferences getState() { |
| 65 | return getRootPreferences().node( "state" ); | |
| 68 | return getRootPreferences().node( PREFS_ROOT_STATE ); | |
| 66 | 69 | } |
| 67 | 70 | |
| 34 | 34 | import java.net.URL; |
| 35 | 35 | import java.util.ArrayList; |
| 36 | import java.util.Iterator; | |
| 36 | 37 | import java.util.List; |
| 37 | 38 | import java.util.Objects; |
| ... | ||
| 51 | 52 | public DefaultSettings() |
| 52 | 53 | throws ConfigurationException, URISyntaxException, IOException { |
| 53 | setProperties(createProperties()); | |
| 54 | setProperties( createProperties() ); | |
| 54 | 55 | } |
| 55 | 56 | |
| ... | ||
| 63 | 64 | */ |
| 64 | 65 | @Override |
| 65 | public String getSetting(final String property, final String defaultValue) { | |
| 66 | return getSettings().getString(property, defaultValue); | |
| 66 | public String getSetting( final String property, final String defaultValue ) { | |
| 67 | return getSettings().getString( property, defaultValue ); | |
| 67 | 68 | } |
| 68 | 69 | |
| ... | ||
| 76 | 77 | */ |
| 77 | 78 | @Override |
| 78 | public int getSetting(final String property, final int defaultValue) { | |
| 79 | return getSettings().getInt(property, defaultValue); | |
| 79 | public int getSetting( final String property, final int defaultValue ) { | |
| 80 | return getSettings().getInt( property, defaultValue ); | |
| 80 | 81 | } |
| 81 | 82 | |
| 83 | /** | |
| 84 | * Returns a list of objects for a given setting. | |
| 85 | * | |
| 86 | * @param property The setting key name. | |
| 87 | * @param defaults The default values to return, which may be null. | |
| 88 | * | |
| 89 | * @return A list, possibly empty, never null. | |
| 90 | */ | |
| 82 | 91 | @Override |
| 83 | public List<Object> getSettingList(final String property, List<String> defaults) { | |
| 84 | if (defaults == null) { | |
| 92 | public List<Object> getSettingList( final String property, List<String> defaults ) { | |
| 93 | if( defaults == null ) { | |
| 85 | 94 | defaults = new ArrayList<>(); |
| 86 | 95 | } |
| 87 | ||
| 88 | return getSettings().getList(property, defaults); | |
| 96 | ||
| 97 | return getSettings().getList( property, defaults ); | |
| 89 | 98 | } |
| 90 | 99 | |
| ... | ||
| 99 | 108 | @Override |
| 100 | 109 | public List<String> getStringSettingList( |
| 101 | final String property, final List<String> defaults) { | |
| 102 | final List<Object> settings = getSettingList(property, defaults); | |
| 110 | final String property, final List<String> defaults ) { | |
| 111 | final List<Object> settings = getSettingList( property, defaults ); | |
| 103 | 112 | |
| 104 | 113 | return settings.stream() |
| 105 | .map(object -> Objects.toString(object, null)) | |
| 106 | .collect(Collectors.toList()); | |
| 114 | .map( object -> Objects.toString( object, null ) ) | |
| 115 | .collect( Collectors.toList() ); | |
| 116 | } | |
| 117 | ||
| 118 | /** | |
| 119 | * Convert a list of property objects into strings, with no default value. | |
| 120 | * | |
| 121 | * @param property The property value to coerce. | |
| 122 | * | |
| 123 | * @return The list of properties coerced from objects to strings. | |
| 124 | */ | |
| 125 | @Override | |
| 126 | public List<String> getStringSettingList( final String property ) { | |
| 127 | return getStringSettingList( property, null ); | |
| 128 | } | |
| 129 | ||
| 130 | /** | |
| 131 | * Returns a list of property names that begin with the given prefix. | |
| 132 | * | |
| 133 | * @param prefix The prefix to compare against each property name. | |
| 134 | * | |
| 135 | * @return The list of property names that have the given prefix. | |
| 136 | */ | |
| 137 | @Override | |
| 138 | public Iterator<String> getKeys( final String prefix ) { | |
| 139 | return getSettings().getKeys( prefix ); | |
| 107 | 140 | } |
| 108 | 141 | |
| 109 | 142 | private PropertiesConfiguration createProperties() |
| 110 | 143 | throws ConfigurationException { |
| 111 | 144 | final URL url = getPropertySource(); |
| 112 | 145 | |
| 113 | 146 | return url == null |
| 114 | 147 | ? new PropertiesConfiguration() |
| 115 | : new PropertiesConfiguration(url); | |
| 148 | : new PropertiesConfiguration( url ); | |
| 116 | 149 | } |
| 117 | 150 | |
| 118 | 151 | private URL getPropertySource() { |
| 119 | return getClass().getResource(getSettingsFilename()); | |
| 152 | return getClass().getResource( getSettingsFilename() ); | |
| 120 | 153 | } |
| 121 | 154 | |
| 122 | 155 | private String getSettingsFilename() { |
| 123 | 156 | return SETTINGS_NAME; |
| 124 | 157 | } |
| 125 | 158 | |
| 126 | private void setProperties(final PropertiesConfiguration configuration) { | |
| 159 | private void setProperties( final PropertiesConfiguration configuration ) { | |
| 127 | 160 | this.properties = configuration; |
| 128 | 161 | } |
| 1 | /* | |
| 2 | * Copyright 2016 White Magic Software, Ltd. | |
| 3 | * | |
| 4 | * All rights reserved. | |
| 5 | * | |
| 6 | * Redistribution and use in source and binary forms, with or without | |
| 7 | * modification, are permitted provided that the following conditions are met: | |
| 8 | * | |
| 9 | * o Redistributions of source code must retain the above copyright | |
| 10 | * notice, this list of conditions and the following disclaimer. | |
| 11 | * | |
| 12 | * o Redistributions in binary form must reproduce the above copyright | |
| 13 | * notice, this list of conditions and the following disclaimer in the | |
| 14 | * documentation and/or other materials provided with the distribution. | |
| 15 | * | |
| 16 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | |
| 17 | * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | |
| 18 | * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | |
| 19 | * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT | |
| 20 | * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | |
| 21 | * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT | |
| 22 | * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | |
| 23 | * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | |
| 24 | * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | |
| 25 | * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | |
| 26 | * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | |
| 27 | */ | |
| 28 | package com.scrivenvar.test; | |
| 29 | ||
| 30 | import com.scrivenvar.definition.DefinitionPane; | |
| 31 | import static javafx.application.Application.launch; | |
| 32 | import javafx.scene.control.TreeItem; | |
| 33 | import javafx.scene.control.TreeView; | |
| 34 | import javafx.stage.Stage; | |
| 35 | ||
| 36 | /** | |
| 37 | * TestDefinitionPane application for debugging. | |
| 38 | */ | |
| 39 | public final class TestDefinitionPane extends TestHarness { | |
| 40 | /** | |
| 41 | * Application entry point. | |
| 42 | * | |
| 43 | * @param stage The primary application stage. | |
| 44 | * | |
| 45 | * @throws Exception Could not read configuration file. | |
| 46 | */ | |
| 47 | @Override | |
| 48 | public void start( final Stage stage ) throws Exception { | |
| 49 | super.start( stage ); | |
| 50 | ||
| 51 | TreeView<String> root = createTreeView(); | |
| 52 | DefinitionPane pane = createDefinitionPane( root ); | |
| 53 | ||
| 54 | test( pane, "language.ai.", "article" ); | |
| 55 | test( pane, "language.ai", "ai" ); | |
| 56 | test( pane, "l", "location" ); | |
| 57 | test( pane, "la", "language" ); | |
| 58 | test( pane, "c.p.n", "name" ); | |
| 59 | test( pane, "c.p.n.", "First" ); | |
| 60 | test( pane, "...", "c" ); | |
| 61 | test( pane, "foo", "c" ); | |
| 62 | test( pane, "foo.bar", "c" ); | |
| 63 | test( pane, "", "c" ); | |
| 64 | test( pane, "c", "protagonist" ); | |
| 65 | test( pane, "c.", "protagonist" ); | |
| 66 | test( pane, "c.p", "protagonist" ); | |
| 67 | test( pane, "c.protagonist", "protagonist" ); | |
| 68 | ||
| 69 | System.exit( 0 ); | |
| 70 | } | |
| 71 | ||
| 72 | private void test( DefinitionPane pane, String path, String value ) { | |
| 73 | System.out.println( "---------------------------" ); | |
| 74 | System.out.println( "Find Path: '" + path + "'" ); | |
| 75 | final TreeItem<String> node = pane.findNode( path ); | |
| 76 | System.out.println( "Path Node: " + node ); | |
| 77 | System.out.println( "Node Val : " + node.getValue() ); | |
| 78 | } | |
| 79 | ||
| 80 | public static void main( String[] args ) { | |
| 81 | launch( args ); | |
| 82 | } | |
| 83 | } | |
| 1 | 84 |
| 1 | /* | |
| 2 | * Copyright 2016 White Magic Software, Ltd. | |
| 3 | * | |
| 4 | * All rights reserved. | |
| 5 | * | |
| 6 | * Redistribution and use in source and binary forms, with or without | |
| 7 | * modification, are permitted provided that the following conditions are met: | |
| 8 | * | |
| 9 | * o Redistributions of source code must retain the above copyright | |
| 10 | * notice, this list of conditions and the following disclaimer. | |
| 11 | * | |
| 12 | * o Redistributions in binary form must reproduce the above copyright | |
| 13 | * notice, this list of conditions and the following disclaimer in the | |
| 14 | * documentation and/or other materials provided with the distribution. | |
| 15 | * | |
| 16 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | |
| 17 | * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | |
| 18 | * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | |
| 19 | * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT | |
| 20 | * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | |
| 21 | * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT | |
| 22 | * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | |
| 23 | * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | |
| 24 | * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | |
| 25 | * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | |
| 26 | * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | |
| 27 | */ | |
| 28 | package com.scrivenvar.test; | |
| 29 | ||
| 30 | import static com.scrivenvar.Messages.get; | |
| 31 | import com.scrivenvar.definition.DefinitionPane; | |
| 32 | import com.scrivenvar.definition.yaml.YamlParser; | |
| 33 | import com.scrivenvar.definition.yaml.YamlTreeAdapter; | |
| 34 | import java.io.IOException; | |
| 35 | import java.io.InputStream; | |
| 36 | import javafx.application.Application; | |
| 37 | import javafx.scene.Scene; | |
| 38 | import javafx.scene.control.TreeView; | |
| 39 | import javafx.scene.layout.BorderPane; | |
| 40 | import javafx.stage.Stage; | |
| 41 | import org.fxmisc.flowless.VirtualizedScrollPane; | |
| 42 | import org.fxmisc.richtext.StyleClassedTextArea; | |
| 43 | ||
| 44 | /** | |
| 45 | * TestDefinitionPane application for debugging and head-banging. | |
| 46 | */ | |
| 47 | public abstract class TestHarness extends Application { | |
| 48 | ||
| 49 | private static Application app; | |
| 50 | private Scene scene; | |
| 51 | ||
| 52 | /** | |
| 53 | * Application entry point. | |
| 54 | * | |
| 55 | * @param stage The primary application stage. | |
| 56 | * | |
| 57 | * @throws Exception Could not read configuration file. | |
| 58 | */ | |
| 59 | @Override | |
| 60 | public void start( final Stage stage ) throws Exception { | |
| 61 | initApplication(); | |
| 62 | initScene(); | |
| 63 | initStage( stage ); | |
| 64 | } | |
| 65 | ||
| 66 | protected TreeView<String> createTreeView() throws IOException { | |
| 67 | return new YamlTreeAdapter( new YamlParser() ).adapt( | |
| 68 | asStream( "/com/scrivenvar/variables.yaml" ), | |
| 69 | get( "Pane.defintion.node.root.title" ) | |
| 70 | ); | |
| 71 | } | |
| 72 | ||
| 73 | protected DefinitionPane createDefinitionPane( TreeView<String> root ) { | |
| 74 | return new DefinitionPane( root ); | |
| 75 | } | |
| 76 | ||
| 77 | private void initApplication() { | |
| 78 | app = this; | |
| 79 | } | |
| 80 | ||
| 81 | private void initScene() { | |
| 82 | final StyleClassedTextArea editor = new StyleClassedTextArea( false ); | |
| 83 | final VirtualizedScrollPane<StyleClassedTextArea> scrollPane = new VirtualizedScrollPane<>( editor ); | |
| 84 | ||
| 85 | final BorderPane borderPane = new BorderPane(); | |
| 86 | borderPane.setPrefSize( 1024, 800 ); | |
| 87 | borderPane.setCenter( scrollPane ); | |
| 88 | ||
| 89 | setScene( new Scene( borderPane ) ); | |
| 90 | } | |
| 91 | ||
| 92 | private void initStage( Stage stage ) { | |
| 93 | stage.setScene( getScene() ); | |
| 94 | } | |
| 95 | ||
| 96 | private Scene getScene() { | |
| 97 | return this.scene; | |
| 98 | } | |
| 99 | ||
| 100 | private void setScene( Scene scene ) { | |
| 101 | this.scene = scene; | |
| 102 | } | |
| 103 | ||
| 104 | private static Application getApplication() { | |
| 105 | return app; | |
| 106 | } | |
| 107 | ||
| 108 | public static void showDocument( String uri ) { | |
| 109 | getApplication().getHostServices().showDocument( uri ); | |
| 110 | } | |
| 111 | ||
| 112 | protected InputStream asStream( String resource ) { | |
| 113 | return getClass().getResourceAsStream( resource ); | |
| 114 | } | |
| 115 | } | |
| 1 | 116 |
| 1 | /* | |
| 2 | * Copyright 2016 White Magic Software, Ltd. | |
| 3 | * | |
| 4 | * All rights reserved. | |
| 5 | * | |
| 6 | * Redistribution and use in source and binary forms, with or without | |
| 7 | * modification, are permitted provided that the following conditions are met: | |
| 8 | * | |
| 9 | * o Redistributions of source code must retain the above copyright | |
| 10 | * notice, this list of conditions and the following disclaimer. | |
| 11 | * | |
| 12 | * o Redistributions in binary form must reproduce the above copyright | |
| 13 | * notice, this list of conditions and the following disclaimer in the | |
| 14 | * documentation and/or other materials provided with the distribution. | |
| 15 | * | |
| 16 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | |
| 17 | * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | |
| 18 | * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | |
| 19 | * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT | |
| 20 | * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | |
| 21 | * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT | |
| 22 | * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | |
| 23 | * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | |
| 24 | * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | |
| 25 | * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | |
| 26 | * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | |
| 27 | */ | |
| 28 | package com.scrivenvar.test; | |
| 29 | ||
| 30 | import com.scrivenvar.definition.VariableTreeItem; | |
| 31 | import java.util.Collection; | |
| 32 | import java.util.HashMap; | |
| 33 | import java.util.Map; | |
| 34 | import static java.util.concurrent.ThreadLocalRandom.current; | |
| 35 | import java.util.concurrent.TimeUnit; | |
| 36 | import static java.util.concurrent.TimeUnit.DAYS; | |
| 37 | import static java.util.concurrent.TimeUnit.HOURS; | |
| 38 | import static java.util.concurrent.TimeUnit.MILLISECONDS; | |
| 39 | import static java.util.concurrent.TimeUnit.MINUTES; | |
| 40 | import static java.util.concurrent.TimeUnit.NANOSECONDS; | |
| 41 | import static java.util.concurrent.TimeUnit.SECONDS; | |
| 42 | import static javafx.application.Application.launch; | |
| 43 | import javafx.scene.control.TreeItem; | |
| 44 | import javafx.scene.control.TreeView; | |
| 45 | import javafx.stage.Stage; | |
| 46 | import org.ahocorasick.trie.*; | |
| 47 | import org.ahocorasick.trie.Trie.TrieBuilder; | |
| 48 | import static org.apache.commons.lang.RandomStringUtils.randomNumeric; | |
| 49 | import org.apache.commons.lang.StringUtils; | |
| 50 | ||
| 51 | /** | |
| 52 | * Tests substituting variable definitions with their values in a swath of text. | |
| 53 | * | |
| 54 | * @author White Magic Software, Ltd. | |
| 55 | */ | |
| 56 | public class TestVariableNameProcessor extends TestHarness { | |
| 57 | ||
| 58 | private final static int TEXT_SIZE = 1000000; | |
| 59 | private final static int MATCHES_DIVISOR = 1000; | |
| 60 | ||
| 61 | private final static StringBuilder SOURCE | |
| 62 | = new StringBuilder( randomNumeric( TEXT_SIZE ) ); | |
| 63 | ||
| 64 | private final static boolean DEBUG = false; | |
| 65 | ||
| 66 | public TestVariableNameProcessor() { | |
| 67 | } | |
| 68 | ||
| 69 | @Override | |
| 70 | public void start( final Stage stage ) throws Exception { | |
| 71 | super.start( stage ); | |
| 72 | ||
| 73 | final TreeView<String> treeView = createTreeView(); | |
| 74 | final Map<String, String> definitions = new HashMap<>(); | |
| 75 | ||
| 76 | populate( treeView.getRoot(), definitions ); | |
| 77 | injectVariables( definitions ); | |
| 78 | ||
| 79 | final String text = SOURCE.toString(); | |
| 80 | ||
| 81 | show( text ); | |
| 82 | ||
| 83 | long duration = System.nanoTime(); | |
| 84 | ||
| 85 | // TODO: Test replaceEach (with intercoluated variables) and replaceEachRepeatedly | |
| 86 | // (without intercoluation). | |
| 87 | final String result = testBorAhoCorasick( text, definitions ); | |
| 88 | ||
| 89 | duration = System.nanoTime() - duration; | |
| 90 | ||
| 91 | show( result ); | |
| 92 | System.out.println( elapsed( duration ) ); | |
| 93 | ||
| 94 | System.exit( 0 ); | |
| 95 | } | |
| 96 | ||
| 97 | private void show( final String s ) { | |
| 98 | if( DEBUG ) { | |
| 99 | System.out.printf( "%s\n\n", s ); | |
| 100 | } | |
| 101 | } | |
| 102 | ||
| 103 | private String testBorAhoCorasick( | |
| 104 | final String text, | |
| 105 | final Map<String, String> definitions ) { | |
| 106 | // Create a buffer sufficiently large that re-allocations are minimized. | |
| 107 | final StringBuilder sb = new StringBuilder( text.length() << 1 ); | |
| 108 | ||
| 109 | final TrieBuilder builder = Trie.builder(); | |
| 110 | builder.onlyWholeWords(); | |
| 111 | builder.removeOverlaps(); | |
| 112 | ||
| 113 | final String[] keys = keys( definitions ); | |
| 114 | ||
| 115 | for( final String key : keys ) { | |
| 116 | builder.addKeyword( key ); | |
| 117 | } | |
| 118 | ||
| 119 | final Trie trie = builder.build(); | |
| 120 | final Collection<Emit> emits = trie.parseText( text ); | |
| 121 | ||
| 122 | int prevIndex = 0; | |
| 123 | ||
| 124 | for( final Emit emit : emits ) { | |
| 125 | final int matchIndex = emit.getStart(); | |
| 126 | ||
| 127 | sb.append( text.substring( prevIndex, matchIndex ) ); | |
| 128 | sb.append( definitions.get( emit.getKeyword() ) ); | |
| 129 | prevIndex = emit.getEnd() + 1; | |
| 130 | } | |
| 131 | ||
| 132 | // Add the remainder of the string (contains no more matches). | |
| 133 | sb.append( text.substring( prevIndex ) ); | |
| 134 | ||
| 135 | return sb.toString(); | |
| 136 | } | |
| 137 | ||
| 138 | private String testStringUtils( | |
| 139 | final String text, final Map<String, String> definitions ) { | |
| 140 | final String[] keys = keys( definitions ); | |
| 141 | final String[] values = values( definitions ); | |
| 142 | ||
| 143 | return StringUtils.replaceEach( text, keys, values ); | |
| 144 | } | |
| 145 | ||
| 146 | private String[] keys( final Map<String, String> definitions ) { | |
| 147 | final int size = definitions.size(); | |
| 148 | return definitions.keySet().toArray( new String[ size ] ); | |
| 149 | } | |
| 150 | ||
| 151 | private String[] values( final Map<String, String> definitions ) { | |
| 152 | final int size = definitions.size(); | |
| 153 | return definitions.values().toArray( new String[ size ] ); | |
| 154 | } | |
| 155 | ||
| 156 | /** | |
| 157 | * Decomposes a period of time into days, hours, minutes, seconds, | |
| 158 | * milliseconds, and nanoseconds. | |
| 159 | * | |
| 160 | * @param duration Time in nanoseconds. | |
| 161 | * | |
| 162 | * @return A non-null, comma-separated string (without newline). | |
| 163 | */ | |
| 164 | public String elapsed( long duration ) { | |
| 165 | final TimeUnit scale = NANOSECONDS; | |
| 166 | ||
| 167 | long days = scale.toDays( duration ); | |
| 168 | duration -= DAYS.toMillis( days ); | |
| 169 | long hours = scale.toHours( duration ); | |
| 170 | duration -= HOURS.toMillis( hours ); | |
| 171 | long minutes = scale.toMinutes( duration ); | |
| 172 | duration -= MINUTES.toMillis( minutes ); | |
| 173 | long seconds = scale.toSeconds( duration ); | |
| 174 | duration -= SECONDS.toMillis( seconds ); | |
| 175 | long millis = scale.toMillis( duration ); | |
| 176 | duration -= MILLISECONDS.toMillis( seconds ); | |
| 177 | long nanos = scale.toNanos( duration ); | |
| 178 | ||
| 179 | return String.format( | |
| 180 | "%d days, %d hours, %d minutes, %d seconds, %d millis, %d nanos", | |
| 181 | days, hours, minutes, seconds, millis, nanos | |
| 182 | ); | |
| 183 | } | |
| 184 | ||
| 185 | private void injectVariables( final Map<String, String> definitions ) { | |
| 186 | for( int i = (SOURCE.length() / MATCHES_DIVISOR) + 1; i > 0; i-- ) { | |
| 187 | final int r = current().nextInt( 1, SOURCE.length() ); | |
| 188 | SOURCE.insert( r, randomKey( definitions ) ); | |
| 189 | } | |
| 190 | } | |
| 191 | ||
| 192 | private String randomKey( final Map<String, String> map ) { | |
| 193 | final Object[] keys = map.keySet().toArray(); | |
| 194 | final int r = current().nextInt( keys.length ); | |
| 195 | return keys[ r ].toString(); | |
| 196 | } | |
| 197 | ||
| 198 | private void populate( final TreeItem<String> parent, final Map<String, String> map ) { | |
| 199 | for( final TreeItem<String> child : parent.getChildren() ) { | |
| 200 | if( child.isLeaf() ) { | |
| 201 | final String key = asDefinition( ((VariableTreeItem<String>)child).toPath() ); | |
| 202 | final String value = child.getValue(); | |
| 203 | ||
| 204 | map.put( key, value ); | |
| 205 | } else { | |
| 206 | populate( child, map ); | |
| 207 | } | |
| 208 | } | |
| 209 | } | |
| 210 | ||
| 211 | private String asDefinition( final String key ) { | |
| 212 | return "$" + key + "$"; | |
| 213 | } | |
| 214 | ||
| 215 | public static void main( String[] args ) { | |
| 216 | launch( args ); | |
| 217 | } | |
| 218 | } | |
| 1 | 219 |
| 1 | /* | |
| 2 | * Copyright 2016 White Magic Software, Ltd. | |
| 3 | * | |
| 4 | * All rights reserved. | |
| 5 | * | |
| 6 | * Redistribution and use in source and binary forms, with or without | |
| 7 | * modification, are permitted provided that the following conditions are met: | |
| 8 | * | |
| 9 | * o Redistributions of source code must retain the above copyright | |
| 10 | * notice, this list of conditions and the following disclaimer. | |
| 11 | * | |
| 12 | * o Redistributions in binary form must reproduce the above copyright | |
| 13 | * notice, this list of conditions and the following disclaimer in the | |
| 14 | * documentation and/or other materials provided with the distribution. | |
| 15 | * | |
| 16 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | |
| 17 | * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | |
| 18 | * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | |
| 19 | * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT | |
| 20 | * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | |
| 21 | * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT | |
| 22 | * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | |
| 23 | * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | |
| 24 | * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | |
| 25 | * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | |
| 26 | * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | |
| 27 | */ | |
| 28 | package com.scrivenvar.ui; | |
| 29 | ||
| 30 | import com.scrivenvar.Services; | |
| 31 | import com.scrivenvar.service.Options; | |
| 32 | import java.util.prefs.Preferences; | |
| 33 | import org.tbee.javafx.scene.layout.fxml.MigPane; | |
| 34 | ||
| 35 | /** | |
| 36 | * Provides options to all subclasses. | |
| 37 | * | |
| 38 | * @author White Magic Software, Ltd. | |
| 39 | */ | |
| 40 | public abstract class AbstractPane extends MigPane { | |
| 41 | ||
| 42 | private final Options options = Services.load( Options.class ); | |
| 43 | ||
| 44 | protected Options getOptions() { | |
| 45 | return this.options; | |
| 46 | } | |
| 47 | ||
| 48 | protected Preferences getState() { | |
| 49 | return getOptions().getState(); | |
| 50 | } | |
| 51 | } | |
| 52 | 1 |
| 1 | /* | |
| 2 | * Copyright 2016 White Magic Software, Ltd. | |
| 3 | * | |
| 4 | * All rights reserved. | |
| 5 | * | |
| 6 | * Redistribution and use in source and binary forms, with or without | |
| 7 | * modification, are permitted provided that the following conditions are met: | |
| 8 | * | |
| 9 | * o Redistributions of source code must retain the above copyright | |
| 10 | * notice, this list of conditions and the following disclaimer. | |
| 11 | * | |
| 12 | * o Redistributions in binary form must reproduce the above copyright | |
| 13 | * notice, this list of conditions and the following disclaimer in the | |
| 14 | * documentation and/or other materials provided with the distribution. | |
| 15 | * | |
| 16 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | |
| 17 | * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | |
| 18 | * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | |
| 19 | * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT | |
| 20 | * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | |
| 21 | * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT | |
| 22 | * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | |
| 23 | * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | |
| 24 | * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | |
| 25 | * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | |
| 26 | * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | |
| 27 | */ | |
| 28 | package com.scrivenvar.ui; | |
| 29 | ||
| 30 | import static com.scrivenvar.Constants.SEPARATOR; | |
| 31 | import com.scrivenvar.decorators.YamlVariableDecorator; | |
| 32 | import com.scrivenvar.decorators.VariableDecorator; | |
| 33 | import static com.scrivenvar.editor.VariableNameInjector.DEFAULT_MAX_VAR_LENGTH; | |
| 34 | import java.util.HashMap; | |
| 35 | import java.util.Map; | |
| 36 | import java.util.Stack; | |
| 37 | import javafx.scene.control.TreeItem; | |
| 38 | ||
| 39 | /** | |
| 40 | * Provides behaviour afforded to variable names and their corresponding value. | |
| 41 | * | |
| 42 | * @author White Magic Software, Ltd. | |
| 43 | * @param <T> The type of TreeItem (usually String). | |
| 44 | */ | |
| 45 | public class VariableTreeItem<T> extends TreeItem<T> { | |
| 46 | ||
| 47 | private final static int DEFAULT_MAP_SIZE = 1000; | |
| 48 | ||
| 49 | private final static VariableDecorator VARIABLE_DECORATOR = | |
| 50 | new YamlVariableDecorator(); | |
| 51 | ||
| 52 | /** | |
| 53 | * Flattened tree. | |
| 54 | */ | |
| 55 | private Map<String, String> map; | |
| 56 | ||
| 57 | /** | |
| 58 | * Constructs a new item with a default value. | |
| 59 | * | |
| 60 | * @param value Passed up to superclass. | |
| 61 | */ | |
| 62 | public VariableTreeItem( final T value ) { | |
| 63 | super( value ); | |
| 64 | } | |
| 65 | ||
| 66 | /** | |
| 67 | * Finds a leaf starting at the current node with text that matches the given | |
| 68 | * value. | |
| 69 | * | |
| 70 | * @param text The text to match against each leaf in the tree. | |
| 71 | * | |
| 72 | * @return The leaf that has a value starting with the given text. | |
| 73 | */ | |
| 74 | public VariableTreeItem<T> findLeaf( final String text ) { | |
| 75 | final Stack<VariableTreeItem<T>> stack = new Stack<>(); | |
| 76 | final VariableTreeItem<T> root = this; | |
| 77 | ||
| 78 | stack.push( root ); | |
| 79 | ||
| 80 | boolean found = false; | |
| 81 | VariableTreeItem<T> node = null; | |
| 82 | ||
| 83 | while( !found && !stack.isEmpty() ) { | |
| 84 | node = stack.pop(); | |
| 85 | ||
| 86 | if( node.valueStartsWith( text ) ) { | |
| 87 | found = true; | |
| 88 | } else { | |
| 89 | for( final TreeItem<T> child : node.getChildren() ) { | |
| 90 | stack.push( (VariableTreeItem<T>)child ); | |
| 91 | } | |
| 92 | ||
| 93 | // No match found, yet. | |
| 94 | node = null; | |
| 95 | } | |
| 96 | } | |
| 97 | ||
| 98 | return (VariableTreeItem<T>)node; | |
| 99 | } | |
| 100 | ||
| 101 | /** | |
| 102 | * Returns true if this node is a leaf and its value starts with the given | |
| 103 | * text. | |
| 104 | * | |
| 105 | * @param s The text to compare against the node value. | |
| 106 | * | |
| 107 | * @return true Node is a leaf and its value starts with the given value. | |
| 108 | */ | |
| 109 | private boolean valueStartsWith( final String s ) { | |
| 110 | return isLeaf() && getValue().toString().startsWith( s ); | |
| 111 | } | |
| 112 | ||
| 113 | /** | |
| 114 | * Returns the path for this node, with nodes made distinct using the | |
| 115 | * separator character. This uses two loops: one for pushing nodes onto a | |
| 116 | * stack and one for popping them off to create the path in desired order. | |
| 117 | * | |
| 118 | * @return A non-null string, possibly empty. | |
| 119 | */ | |
| 120 | public String toPath() { | |
| 121 | final Stack<TreeItem<T>> stack = new Stack<>(); | |
| 122 | TreeItem<T> node = this; | |
| 123 | ||
| 124 | while( node.getParent() != null ) { | |
| 125 | stack.push( node ); | |
| 126 | node = node.getParent(); | |
| 127 | } | |
| 128 | ||
| 129 | final StringBuilder sb = new StringBuilder( DEFAULT_MAX_VAR_LENGTH ); | |
| 130 | ||
| 131 | while( !stack.isEmpty() ) { | |
| 132 | node = stack.pop(); | |
| 133 | ||
| 134 | if( !node.isLeaf() ) { | |
| 135 | sb.append( node.getValue() ); | |
| 136 | ||
| 137 | // This will add a superfluous separator, but instead of peeking at | |
| 138 | // the stack all the time, the last separator will be removed outside | |
| 139 | // the loop (one operation executed once). | |
| 140 | sb.append( SEPARATOR ); | |
| 141 | } | |
| 142 | } | |
| 143 | ||
| 144 | // Remove the trailing SEPARATOR. | |
| 145 | if( sb.length() > 0 ) { | |
| 146 | sb.setLength( sb.length() - 1 ); | |
| 147 | } | |
| 148 | ||
| 149 | return sb.toString(); | |
| 150 | } | |
| 151 | ||
| 152 | /** | |
| 153 | * Returns the hierarchy, flattened to key-value pairs. | |
| 154 | * | |
| 155 | * @return A map of this tree's key-value pairs. | |
| 156 | */ | |
| 157 | public Map<String, String> getMap() { | |
| 158 | if( this.map == null ) { | |
| 159 | this.map = new HashMap<>( DEFAULT_MAP_SIZE ); | |
| 160 | populate( this, this.map ); | |
| 161 | } | |
| 162 | ||
| 163 | return this.map; | |
| 164 | } | |
| 165 | ||
| 166 | private void populate( final TreeItem<T> parent, final Map<String, String> map ) { | |
| 167 | for( final TreeItem<T> child : parent.getChildren() ) { | |
| 168 | if( child.isLeaf() ) { | |
| 169 | @SuppressWarnings( "unchecked" ) | |
| 170 | final String key = toVariable( ((VariableTreeItem<String>)child).toPath() ); | |
| 171 | final String value = child.getValue().toString(); | |
| 172 | ||
| 173 | map.put( key, value ); | |
| 174 | } else { | |
| 175 | populate( child, map ); | |
| 176 | } | |
| 177 | } | |
| 178 | } | |
| 179 | ||
| 180 | /** | |
| 181 | * Converts the name of the key to a simple variable by enclosing it with | |
| 182 | * dollar symbols. | |
| 183 | * | |
| 184 | * @param key The key name to change to a variable. | |
| 185 | * | |
| 186 | * @return $key$ | |
| 187 | */ | |
| 188 | public String toVariable( final String key ) { | |
| 189 | return VARIABLE_DECORATOR.decorate( key ); | |
| 190 | } | |
| 191 | } | |
| 192 | 1 |
| 1 | /* | |
| 2 | * Copyright 2016 White Magic Software, Ltd. | |
| 3 | * | |
| 4 | * All rights reserved. | |
| 5 | * | |
| 6 | * Redistribution and use in source and binary forms, with or without | |
| 7 | * modification, are permitted provided that the following conditions are met: | |
| 8 | * | |
| 9 | * o Redistributions of source code must retain the above copyright | |
| 10 | * notice, this list of conditions and the following disclaimer. | |
| 11 | * | |
| 12 | * o Redistributions in binary form must reproduce the above copyright | |
| 13 | * notice, this list of conditions and the following disclaimer in the | |
| 14 | * documentation and/or other materials provided with the distribution. | |
| 15 | * | |
| 16 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | |
| 17 | * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | |
| 18 | * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | |
| 19 | * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT | |
| 20 | * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | |
| 21 | * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT | |
| 22 | * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | |
| 23 | * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | |
| 24 | * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | |
| 25 | * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | |
| 26 | * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | |
| 27 | */ | |
| 28 | package com.scrivenvar.util; | |
| 29 | ||
| 30 | import java.util.List; | |
| 31 | ||
| 32 | /** | |
| 33 | * Convenience class that provides a clearer API for obtaining list elements. | |
| 34 | * | |
| 35 | * @author White Magic Software, Ltd. | |
| 36 | */ | |
| 37 | public final class Lists { | |
| 38 | ||
| 39 | private Lists() { | |
| 40 | } | |
| 41 | ||
| 42 | /** | |
| 43 | * Returns the first item in the given list, or null if not found. | |
| 44 | * | |
| 45 | * @param <T> The generic list type. | |
| 46 | * @param list The list that may have a first item. | |
| 47 | * | |
| 48 | * @return null if the list is null or there is no first item. | |
| 49 | */ | |
| 50 | public static <T> T getFirst( final List<T> list ) { | |
| 51 | return getFirst( list, null ); | |
| 52 | } | |
| 53 | ||
| 54 | /** | |
| 55 | * Returns the last item in the given list, or null if not found. | |
| 56 | * | |
| 57 | * @param <T> The generic list type. | |
| 58 | * @param list The list that may have a last item. | |
| 59 | * | |
| 60 | * @return null if the list is null or there is no last item. | |
| 61 | */ | |
| 62 | public static <T> T getLast( final List<T> list ) { | |
| 63 | return getLast( list, null ); | |
| 64 | } | |
| 65 | ||
| 66 | /** | |
| 67 | * Returns the first item in the given list, or t if not found. | |
| 68 | * | |
| 69 | * @param <T> The generic list type. | |
| 70 | * @param list The list that may have a first item. | |
| 71 | * @param t The default return value. | |
| 72 | * | |
| 73 | * @return null if the list is null or there is no first item. | |
| 74 | */ | |
| 75 | public static <T> T getFirst( final List<T> list, final T t ) { | |
| 76 | return isEmpty( list ) ? t : list.get( 0 ); | |
| 77 | } | |
| 78 | ||
| 79 | /** | |
| 80 | * Returns the last item in the given list, or t if not found. | |
| 81 | * | |
| 82 | * @param <T> The generic list type. | |
| 83 | * @param list The list that may have a last item. | |
| 84 | * @param t The default return value. | |
| 85 | * | |
| 86 | * @return null if the list is null or there is no last item. | |
| 87 | */ | |
| 88 | public static <T> T getLast( final List<T> list, final T t ) { | |
| 89 | return isEmpty( list ) ? t : list.get( list.size() - 1 ); | |
| 90 | } | |
| 91 | ||
| 92 | /** | |
| 93 | * Returns true if the given list is null or empty. | |
| 94 | * | |
| 95 | * @param <T> The generic list type. | |
| 96 | * @param list The list that has a last item. | |
| 97 | * | |
| 98 | * @return true The list is empty. | |
| 99 | */ | |
| 100 | public static <T> boolean isEmpty( final List<T> list ) { | |
| 101 | return list == null || list.isEmpty(); | |
| 102 | } | |
| 103 | } | |
| 1 | 104 |
| 50 | 50 | private boolean runLaterPending; |
| 51 | 51 | |
| 52 | public StageState( Stage stage, Preferences state ) { | |
| 52 | public StageState( final Stage stage, final Preferences state ) { | |
| 53 | 53 | this.stage = stage; |
| 54 | 54 | this.state = state; |
| ... | ||
| 65 | 65 | |
| 66 | 66 | private void save() { |
| 67 | Rectangle bounds = isNormalState() ? getStageBounds() : normalBounds; | |
| 67 | final Rectangle bounds = isNormalState() ? getStageBounds() : normalBounds; | |
| 68 | ||
| 68 | 69 | if( bounds != null ) { |
| 69 | 70 | state.putDouble( "windowX", bounds.getX() ); |
| 70 | 71 | state.putDouble( "windowY", bounds.getY() ); |
| 71 | 72 | state.putDouble( "windowWidth", bounds.getWidth() ); |
| 72 | 73 | state.putDouble( "windowHeight", bounds.getHeight() ); |
| 73 | 74 | } |
| 75 | ||
| 74 | 76 | state.putBoolean( "windowMaximized", stage.isMaximized() ); |
| 75 | 77 | state.putBoolean( "windowFullScreen", stage.isFullScreen() ); |
| 76 | 78 | } |
| 77 | 79 | |
| 78 | 80 | private void restore() { |
| 79 | double x = state.getDouble( "windowX", Double.NaN ); | |
| 80 | double y = state.getDouble( "windowY", Double.NaN ); | |
| 81 | double w = state.getDouble( "windowWidth", Double.NaN ); | |
| 82 | double h = state.getDouble( "windowHeight", Double.NaN ); | |
| 83 | boolean maximized = state.getBoolean( "windowMaximized", false ); | |
| 84 | boolean fullScreen = state.getBoolean( "windowFullScreen", false ); | |
| 81 | final double x = state.getDouble( "windowX", Double.NaN ); | |
| 82 | final double y = state.getDouble( "windowY", Double.NaN ); | |
| 83 | final double w = state.getDouble( "windowWidth", Double.NaN ); | |
| 84 | final double h = state.getDouble( "windowHeight", Double.NaN ); | |
| 85 | final boolean maximized = state.getBoolean( "windowMaximized", false ); | |
| 86 | final boolean fullScreen = state.getBoolean( "windowFullScreen", false ); | |
| 85 | 87 | |
| 86 | 88 | if( !Double.isNaN( x ) && !Double.isNaN( y ) ) { |
| ... | ||
| 97 | 99 | stage.setFullScreen( fullScreen ); |
| 98 | 100 | } |
| 101 | ||
| 99 | 102 | if( maximized != stage.isMaximized() ) { |
| 100 | 103 | stage.setMaximized( maximized ); |
| ... | ||
| 111 | 114 | return; |
| 112 | 115 | } |
| 116 | ||
| 113 | 117 | runLaterPending = true; |
| 114 | 118 | |
| 1 | /* | |
| 2 | * Copyright 2016 White Magic Software, Ltd. | |
| 3 | * | |
| 4 | * All rights reserved. | |
| 5 | * | |
| 6 | * Redistribution and use in source and binary forms, with or without | |
| 7 | * modification, are permitted provided that the following conditions are met: | |
| 8 | * | |
| 9 | * o Redistributions of source code must retain the above copyright | |
| 10 | * notice, this list of conditions and the following disclaimer. | |
| 11 | * | |
| 12 | * o Redistributions in binary form must reproduce the above copyright | |
| 13 | * notice, this list of conditions and the following disclaimer in the | |
| 14 | * documentation and/or other materials provided with the distribution. | |
| 15 | * | |
| 16 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | |
| 17 | * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | |
| 18 | * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | |
| 19 | * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT | |
| 20 | * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | |
| 21 | * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT | |
| 22 | * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | |
| 23 | * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | |
| 24 | * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | |
| 25 | * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | |
| 26 | * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | |
| 27 | */ | |
| 28 | package com.scrivenvar.yaml; | |
| 29 | ||
| 30 | import com.fasterxml.jackson.core.JsonGenerationException; | |
| 31 | import com.fasterxml.jackson.core.ObjectCodec; | |
| 32 | import com.fasterxml.jackson.core.io.IOContext; | |
| 33 | import com.fasterxml.jackson.databind.JsonNode; | |
| 34 | import com.fasterxml.jackson.databind.ObjectMapper; | |
| 35 | import com.fasterxml.jackson.databind.node.ObjectNode; | |
| 36 | import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; | |
| 37 | import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator; | |
| 38 | import static com.scrivenvar.Constants.SEPARATOR; | |
| 39 | import com.scrivenvar.decorators.VariableDecorator; | |
| 40 | import com.scrivenvar.decorators.YamlVariableDecorator; | |
| 41 | import java.io.IOException; | |
| 42 | import java.io.InputStream; | |
| 43 | import java.io.Writer; | |
| 44 | import java.security.InvalidParameterException; | |
| 45 | import java.text.MessageFormat; | |
| 46 | import java.util.HashMap; | |
| 47 | import java.util.Map; | |
| 48 | import java.util.Map.Entry; | |
| 49 | import java.util.regex.Matcher; | |
| 50 | import java.util.regex.Pattern; | |
| 51 | import org.yaml.snakeyaml.DumperOptions; | |
| 52 | ||
| 53 | /** | |
| 54 | * <p> | |
| 55 | * This program loads a YAML document into memory, scans for variable | |
| 56 | * declarations, then substitutes any self-referential values back into the | |
| 57 | * document. Its output is the given YAML document without any variables. | |
| 58 | * Variables in the YAML document are denoted using a bracketed dollar symbol | |
| 59 | * syntax. For example: $field.name$. Some nomenclature to keep from going | |
| 60 | * squirrely, consider: | |
| 61 | * </p> | |
| 62 | * | |
| 63 | * <pre> | |
| 64 | * root: | |
| 65 | * node: | |
| 66 | * name: $field.name$ | |
| 67 | * field: | |
| 68 | * name: Alan Turing | |
| 69 | * </pre> | |
| 70 | * | |
| 71 | * The various components of the given YAML are called: | |
| 72 | * | |
| 73 | * <ul> | |
| 74 | * <li><code>$field.name$</code> - delimited reference</li> | |
| 75 | * <li><code>field.name</code> - reference</li> | |
| 76 | * <li><code>name</code> - YAML field</li> | |
| 77 | * <li><code>Alan Turing</code> - (dereferenced) field value</li> | |
| 78 | * </ul> | |
| 79 | * | |
| 80 | * @author White Magic Software, Ltd. | |
| 81 | */ | |
| 82 | public class YamlParser { | |
| 83 | ||
| 84 | private final static int GROUP_DELIMITED = 1; | |
| 85 | private final static int GROUP_REFERENCE = 2; | |
| 86 | ||
| 87 | private final static VariableDecorator VARIABLE_DECORATOR | |
| 88 | = new YamlVariableDecorator(); | |
| 89 | ||
| 90 | /** | |
| 91 | * Compiled version of DEFAULT_REGEX. | |
| 92 | */ | |
| 93 | private final static Pattern REGEX_PATTERN | |
| 94 | = Pattern.compile( YamlVariableDecorator.REGEX ); | |
| 95 | ||
| 96 | /** | |
| 97 | * Should be JsonPointer.SEPARATOR, but Jackson YAML uses magic values. | |
| 98 | */ | |
| 99 | private final static char SEPARATOR_YAML = '/'; | |
| 100 | ||
| 101 | /** | |
| 102 | * Start of the Universe (the YAML document node that contains all others). | |
| 103 | */ | |
| 104 | private ObjectNode documentRoot; | |
| 105 | ||
| 106 | /** | |
| 107 | * Map of references to dereferenced field values. | |
| 108 | */ | |
| 109 | private Map<String, String> references; | |
| 110 | ||
| 111 | public YamlParser() { | |
| 112 | } | |
| 113 | ||
| 114 | /** | |
| 115 | * Returns the given string with all the delimited references swapped with | |
| 116 | * their recursively resolved values. | |
| 117 | * | |
| 118 | * @param text The text to parse with zero or more delimited references to | |
| 119 | * replace. | |
| 120 | * | |
| 121 | * @return The substituted value. | |
| 122 | * | |
| 123 | * @throws InvalidParameterException The text has no associated value. | |
| 124 | */ | |
| 125 | public String substitute( String text ) { | |
| 126 | final Matcher matcher = patternMatch( text ); | |
| 127 | final Map<String, String> map = getReferences(); | |
| 128 | ||
| 129 | while( matcher.find() ) { | |
| 130 | final String key = matcher.group( GROUP_DELIMITED ); | |
| 131 | final String value = map.get( key ); | |
| 132 | ||
| 133 | if( value == null ) { | |
| 134 | missing( text ); | |
| 135 | } else { | |
| 136 | text = text.replace( key, value ); | |
| 137 | } | |
| 138 | } | |
| 139 | ||
| 140 | return text; | |
| 141 | } | |
| 142 | ||
| 143 | /** | |
| 144 | * Returns all the strings with their values resolved in a flat hierarchy. | |
| 145 | * This copies all the keys and resolved values into a new map. | |
| 146 | * | |
| 147 | * @return The new map created with all values having been resolved, | |
| 148 | * recursively. | |
| 149 | * | |
| 150 | * @throws InvalidParameterException A key in the map has no associated value. | |
| 151 | */ | |
| 152 | public Map<String, String> createResolvedMap() { | |
| 153 | final Map<String, String> map = new HashMap<>( 1024 ); | |
| 154 | ||
| 155 | resolve( getDocumentRoot(), "", map ); | |
| 156 | ||
| 157 | return map; | |
| 158 | } | |
| 159 | ||
| 160 | /** | |
| 161 | * Iterate over a given root node (at any level of the tree) and adapt each | |
| 162 | * leaf node. | |
| 163 | * | |
| 164 | * @param rootNode A JSON node (YAML node) to adapt. | |
| 165 | */ | |
| 166 | private void resolve( | |
| 167 | final JsonNode rootNode, final String path, final Map<String, String> map ) { | |
| 168 | ||
| 169 | rootNode.fields().forEachRemaining( | |
| 170 | (Entry<String, JsonNode> leaf) -> resolve( leaf, path, map ) | |
| 171 | ); | |
| 172 | } | |
| 173 | ||
| 174 | /** | |
| 175 | * Recursively adapt each rootNode to a corresponding rootItem. | |
| 176 | * | |
| 177 | * @param rootNode The node to adapt. | |
| 178 | */ | |
| 179 | private void resolve( | |
| 180 | final Entry<String, JsonNode> rootNode, final String path, final Map<String, String> map ) { | |
| 181 | final JsonNode leafNode = rootNode.getValue(); | |
| 182 | final String key = rootNode.getKey(); | |
| 183 | ||
| 184 | if( leafNode.isValueNode() ) { | |
| 185 | final String value = rootNode.getValue().asText(); | |
| 186 | ||
| 187 | map.put( VARIABLE_DECORATOR.decorate( path + key ), substitute( value ) ); | |
| 188 | } | |
| 189 | ||
| 190 | if( leafNode.isObject() ) { | |
| 191 | resolve( leafNode, path + key + SEPARATOR, map ); | |
| 192 | } | |
| 193 | } | |
| 194 | ||
| 195 | /** | |
| 196 | * Reads the first document from the given stream of YAML data and returns a | |
| 197 | * corresponding object that represents the YAML hierarchy. The calling class | |
| 198 | * is responsible for closing the stream. Calling classes should use | |
| 199 | * <code>JsonNode.fields()</code> to walk through the YAML tree of fields. | |
| 200 | * | |
| 201 | * @param in The input stream containing YAML content. | |
| 202 | * | |
| 203 | * @return An object hierarchy to represent the content. | |
| 204 | * | |
| 205 | * @throws IOException Could not read the stream. | |
| 206 | */ | |
| 207 | public JsonNode process( final InputStream in ) throws IOException { | |
| 208 | ||
| 209 | final ObjectNode root = (ObjectNode)getObjectMapper().readTree( in ); | |
| 210 | setDocumentRoot( root ); | |
| 211 | process( root ); | |
| 212 | return getDocumentRoot(); | |
| 213 | } | |
| 214 | ||
| 215 | /** | |
| 216 | * Iterate over a given root node (at any level of the tree) and process each | |
| 217 | * leaf node. | |
| 218 | * | |
| 219 | * @param root A node to process. | |
| 220 | */ | |
| 221 | private void process( final JsonNode root ) { | |
| 222 | root.fields().forEachRemaining( this::process ); | |
| 223 | } | |
| 224 | ||
| 225 | /** | |
| 226 | * Process the given field, which is a named node. This is where the | |
| 227 | * application does the up-front work of mapping references to their fully | |
| 228 | * recursively dereferenced values. | |
| 229 | * | |
| 230 | * @param field The named node. | |
| 231 | */ | |
| 232 | private void process( final Entry<String, JsonNode> field ) { | |
| 233 | final JsonNode node = field.getValue(); | |
| 234 | ||
| 235 | if( node.isObject() ) { | |
| 236 | process( node ); | |
| 237 | } else { | |
| 238 | final JsonNode fieldValue = field.getValue(); | |
| 239 | ||
| 240 | // Only basic data types can be parsed into variable values. For | |
| 241 | // node structures, YAML has a built-in mechanism. | |
| 242 | if( fieldValue.isValueNode() ) { | |
| 243 | try { | |
| 244 | resolve( fieldValue.asText() ); | |
| 245 | } catch( StackOverflowError e ) { | |
| 246 | throw new IllegalArgumentException( | |
| 247 | "Unresolvable: " + node.textValue() + " = " + fieldValue ); | |
| 248 | } | |
| 249 | } | |
| 250 | } | |
| 251 | } | |
| 252 | ||
| 253 | /** | |
| 254 | * Inserts the delimited references and field values into the cache. This will | |
| 255 | * overwrite existing references. | |
| 256 | * | |
| 257 | * @param fieldValue YAML field containing zero or more delimited references. | |
| 258 | * If it contains a delimited reference, the parameter is modified with the | |
| 259 | * dereferenced value before it is returned. | |
| 260 | * | |
| 261 | * @return fieldValue without delimited references. | |
| 262 | */ | |
| 263 | private String resolve( String fieldValue ) { | |
| 264 | final Matcher matcher = patternMatch( fieldValue ); | |
| 265 | ||
| 266 | while( matcher.find() ) { | |
| 267 | final String delimited = matcher.group( GROUP_DELIMITED ); | |
| 268 | final String reference = matcher.group( GROUP_REFERENCE ); | |
| 269 | final String dereference = resolve( lookup( reference ) ); | |
| 270 | ||
| 271 | fieldValue = fieldValue.replace( delimited, dereference ); | |
| 272 | ||
| 273 | // This will perform some superfluous calls by overwriting existing | |
| 274 | // items in the delimited reference map. | |
| 275 | put( delimited, dereference ); | |
| 276 | } | |
| 277 | ||
| 278 | return fieldValue; | |
| 279 | } | |
| 280 | ||
| 281 | /** | |
| 282 | * Inserts a key/value pair into the references map. The map retains | |
| 283 | * references and dereferenced values found in the YAML. If the reference | |
| 284 | * already exists, this will overwrite with a new value. | |
| 285 | * | |
| 286 | * @param delimited The variable name. | |
| 287 | * @param dereferenced The resolved value. | |
| 288 | */ | |
| 289 | private void put( String delimited, String dereferenced ) { | |
| 290 | if( dereferenced.isEmpty() ) { | |
| 291 | missing( delimited ); | |
| 292 | } else { | |
| 293 | getReferences().put( delimited, dereferenced ); | |
| 294 | } | |
| 295 | } | |
| 296 | ||
| 297 | /** | |
| 298 | * Writes the modified YAML document to standard output. | |
| 299 | */ | |
| 300 | private void writeDocument() throws IOException { | |
| 301 | getObjectMapper().writeValue( System.out, getDocumentRoot() ); | |
| 302 | } | |
| 303 | ||
| 304 | /** | |
| 305 | * Called when a delimited reference is dereferenced to an empty string. This | |
| 306 | * should produce a warning for the user. | |
| 307 | * | |
| 308 | * @param delimited Delimited reference with no derived value. | |
| 309 | */ | |
| 310 | private void missing( final String delimited ) { | |
| 311 | throw new InvalidParameterException( | |
| 312 | MessageFormat.format( "Missing value for '{0}'.", delimited ) ); | |
| 313 | } | |
| 314 | ||
| 315 | /** | |
| 316 | * Returns a REGEX_PATTERN matcher for the given text. | |
| 317 | * | |
| 318 | * @param text The text that contains zero or more instances of a | |
| 319 | * REGEX_PATTERN that can be found using the regular expression. | |
| 320 | */ | |
| 321 | private Matcher patternMatch( String text ) { | |
| 322 | return getPattern().matcher( text ); | |
| 323 | } | |
| 324 | ||
| 325 | /** | |
| 326 | * Finds the YAML value for a reference. | |
| 327 | * | |
| 328 | * @param reference References a value in the YAML document. | |
| 329 | * | |
| 330 | * @return The dereferenced value. | |
| 331 | */ | |
| 332 | private String lookup( final String reference ) { | |
| 333 | return getDocumentRoot().at( asPath( reference ) ).asText(); | |
| 334 | } | |
| 335 | ||
| 336 | /** | |
| 337 | * Converts a reference (not delimited) to a path that can be used to find a | |
| 338 | * value that should exist inside the YAML document. | |
| 339 | * | |
| 340 | * @param reference The reference to convert to a YAML document path. | |
| 341 | * | |
| 342 | * @return The reference with a leading slash and its separator characters | |
| 343 | * converted to slashes. | |
| 344 | */ | |
| 345 | private String asPath( final String reference ) { | |
| 346 | return SEPARATOR_YAML + reference.replace( getDelimitedSeparator(), SEPARATOR_YAML ); | |
| 347 | } | |
| 348 | ||
| 349 | /** | |
| 350 | * Sets the parent node for the entire YAML document tree. | |
| 351 | * | |
| 352 | * @param documentRoot The parent node. | |
| 353 | */ | |
| 354 | private void setDocumentRoot( ObjectNode documentRoot ) { | |
| 355 | this.documentRoot = documentRoot; | |
| 356 | } | |
| 357 | ||
| 358 | /** | |
| 359 | * Returns the parent node for the entire YAML document tree. | |
| 360 | * | |
| 361 | * @return The parent node. | |
| 362 | */ | |
| 363 | private ObjectNode getDocumentRoot() { | |
| 364 | return this.documentRoot; | |
| 365 | } | |
| 366 | ||
| 367 | /** | |
| 368 | * Returns the compiled regular expression REGEX_PATTERN used to match | |
| 369 | * delimited references. | |
| 370 | * | |
| 371 | * @return A compiled regex for use with the Matcher. | |
| 372 | */ | |
| 373 | private Pattern getPattern() { | |
| 374 | return REGEX_PATTERN; | |
| 375 | } | |
| 376 | ||
| 377 | /** | |
| 378 | * Returns the list of references mapped to dereferenced values. | |
| 379 | * | |
| 380 | * @return | |
| 381 | */ | |
| 382 | private Map<String, String> getReferences() { | |
| 383 | if( this.references == null ) { | |
| 384 | this.references = createReferences(); | |
| 385 | } | |
| 386 | ||
| 387 | return this.references; | |
| 388 | } | |
| 389 | ||
| 390 | /** | |
| 391 | * Subclasses can override this method to insert their own map. | |
| 392 | * | |
| 393 | * @return An empty HashMap, never null. | |
| 394 | */ | |
| 395 | protected Map<String, String> createReferences() { | |
| 396 | return new HashMap<>(); | |
| 397 | } | |
| 398 | ||
| 399 | private class ResolverYAMLFactory extends YAMLFactory { | |
| 400 | ||
| 401 | @Override | |
| 402 | protected YAMLGenerator _createGenerator( | |
| 403 | final Writer out, final IOContext ctxt ) throws IOException { | |
| 404 | ||
| 405 | return new ResolverYAMLGenerator( | |
| 406 | ctxt, _generatorFeatures, _yamlGeneratorFeatures, _objectCodec, | |
| 407 | out, _version ); | |
| 408 | } | |
| 409 | } | |
| 410 | ||
| 411 | private class ResolverYAMLGenerator extends YAMLGenerator { | |
| 412 | ||
| 413 | public ResolverYAMLGenerator( | |
| 414 | final IOContext ctxt, | |
| 415 | final int jsonFeatures, | |
| 416 | final int yamlFeatures, | |
| 417 | final ObjectCodec codec, | |
| 418 | final Writer out, | |
| 419 | final DumperOptions.Version version ) throws IOException { | |
| 420 | ||
| 421 | super( ctxt, jsonFeatures, yamlFeatures, codec, out, version ); | |
| 422 | } | |
| 423 | ||
| 424 | @Override | |
| 425 | public void writeString( final String text ) | |
| 426 | throws IOException, JsonGenerationException { | |
| 427 | super.writeString( substitute( text ) ); | |
| 428 | } | |
| 429 | } | |
| 430 | ||
| 431 | private YAMLFactory getYAMLFactory() { | |
| 432 | return new ResolverYAMLFactory(); | |
| 433 | } | |
| 434 | ||
| 435 | private ObjectMapper getObjectMapper() { | |
| 436 | return new ObjectMapper( getYAMLFactory() ); | |
| 437 | } | |
| 438 | ||
| 439 | /** | |
| 440 | * Returns the character used to separate YAML paths within delimited | |
| 441 | * references. This will return only the first character of the command line | |
| 442 | * parameter, if the default is overridden. | |
| 443 | * | |
| 444 | * @return A period by default. | |
| 445 | */ | |
| 446 | private char getDelimitedSeparator() { | |
| 447 | return SEPARATOR.charAt( 0 ); | |
| 448 | } | |
| 449 | } | |
| 450 | 1 |
| 1 | /* | |
| 2 | * Copyright 2016 White Magic Software, Ltd. | |
| 3 | * | |
| 4 | * All rights reserved. | |
| 5 | * | |
| 6 | * Redistribution and use in source and binary forms, with or without | |
| 7 | * modification, are permitted provided that the following conditions are met: | |
| 8 | * | |
| 9 | * o Redistributions of source code must retain the above copyright | |
| 10 | * notice, this list of conditions and the following disclaimer. | |
| 11 | * | |
| 12 | * o Redistributions in binary form must reproduce the above copyright | |
| 13 | * notice, this list of conditions and the following disclaimer in the | |
| 14 | * documentation and/or other materials provided with the distribution. | |
| 15 | * | |
| 16 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | |
| 17 | * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | |
| 18 | * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | |
| 19 | * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT | |
| 20 | * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | |
| 21 | * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT | |
| 22 | * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | |
| 23 | * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | |
| 24 | * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | |
| 25 | * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | |
| 26 | * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | |
| 27 | */ | |
| 28 | package com.scrivenvar.yaml; | |
| 29 | ||
| 30 | import com.fasterxml.jackson.databind.JsonNode; | |
| 31 | import com.scrivenvar.ui.VariableTreeItem; | |
| 32 | import java.io.IOException; | |
| 33 | import java.io.InputStream; | |
| 34 | import java.util.Map.Entry; | |
| 35 | import javafx.scene.control.TreeItem; | |
| 36 | import javafx.scene.control.TreeView; | |
| 37 | ||
| 38 | /** | |
| 39 | * Transforms a JsonNode hierarchy into a tree that can be displayed in a user | |
| 40 | * interface. | |
| 41 | * | |
| 42 | * @author White Magic Software, Ltd. | |
| 43 | */ | |
| 44 | public class YamlTreeAdapter { | |
| 45 | ||
| 46 | private YamlParser yamlParser; | |
| 47 | ||
| 48 | public YamlTreeAdapter( final YamlParser parser ) { | |
| 49 | setYamlParser( parser ); | |
| 50 | } | |
| 51 | ||
| 52 | /** | |
| 53 | * Converts a YAML document to a TreeView based on the document keys. Only the | |
| 54 | * first document in the stream is adapted. This does not close the stream. | |
| 55 | * | |
| 56 | * @param in Contains a YAML document. | |
| 57 | * @param name Name of the root TreeItem. | |
| 58 | * | |
| 59 | * @return A TreeView populated with all the keys in the YAML document. | |
| 60 | * | |
| 61 | * @throws IOException Could not read from the stream. | |
| 62 | */ | |
| 63 | public TreeView<String> adapt( final InputStream in, final String name ) | |
| 64 | throws IOException { | |
| 65 | ||
| 66 | final JsonNode rootNode = getYamlParser().process( in ); | |
| 67 | final TreeItem<String> rootItem = createTreeItem( name ); | |
| 68 | ||
| 69 | rootItem.setExpanded( true ); | |
| 70 | adapt( rootNode, rootItem ); | |
| 71 | return new TreeView<>( rootItem ); | |
| 72 | } | |
| 73 | ||
| 74 | /** | |
| 75 | * Iterate over a given root node (at any level of the tree) and adapt each | |
| 76 | * leaf node. | |
| 77 | * | |
| 78 | * @param rootNode A JSON node (YAML node) to adapt. | |
| 79 | * @param rootItem The tree item to use as the root when processing the node. | |
| 80 | */ | |
| 81 | private void adapt( | |
| 82 | final JsonNode rootNode, final TreeItem<String> rootItem ) { | |
| 83 | ||
| 84 | rootNode.fields().forEachRemaining( | |
| 85 | (Entry<String, JsonNode> leaf) -> adapt( leaf, rootItem ) | |
| 86 | ); | |
| 87 | } | |
| 88 | ||
| 89 | /** | |
| 90 | * Recursively adapt each rootNode to a corresponding rootItem. | |
| 91 | * | |
| 92 | * @param rootNode The node to adapt. | |
| 93 | * @param rootItem The item to adapt using the node's key. | |
| 94 | */ | |
| 95 | private void adapt( | |
| 96 | final Entry<String, JsonNode> rootNode, final TreeItem<String> rootItem ) { | |
| 97 | ||
| 98 | final JsonNode leafNode = rootNode.getValue(); | |
| 99 | final String key = rootNode.getKey(); | |
| 100 | final TreeItem<String> leaf = createTreeItem( key ); | |
| 101 | ||
| 102 | if( leafNode.isValueNode() ) { | |
| 103 | leaf.getChildren().add( createTreeItem( rootNode.getValue().asText() ) ); | |
| 104 | } | |
| 105 | ||
| 106 | rootItem.getChildren().add( leaf ); | |
| 107 | ||
| 108 | if( leafNode.isObject() ) { | |
| 109 | adapt( leafNode, leaf ); | |
| 110 | } | |
| 111 | } | |
| 112 | ||
| 113 | /** | |
| 114 | * Creates a new tree item that can be added to the tree view. | |
| 115 | * | |
| 116 | * @param value The node's value. | |
| 117 | * | |
| 118 | * @return A new tree item node, never null. | |
| 119 | */ | |
| 120 | private TreeItem<String> createTreeItem( final String value ) { | |
| 121 | return new VariableTreeItem<>( value ); | |
| 122 | } | |
| 123 | ||
| 124 | private YamlParser getYamlParser() { | |
| 125 | return this.yamlParser; | |
| 126 | } | |
| 127 | ||
| 128 | private void setYamlParser( final YamlParser yamlParser ) { | |
| 129 | this.yamlParser = yamlParser; | |
| 130 | } | |
| 131 | ||
| 132 | } | |
| 133 | 1 |
| 228 | 228 | |
| 229 | 229 | kbd { |
| 230 | -moz-border-bottom-colors: none; | |
| 231 | -moz-border-left-colors: none; | |
| 232 | -moz-border-right-colors: none; | |
| 233 | -moz-border-top-colors: none; | |
| 234 | background-color: #DDDDDD; | |
| 235 | background-image: linear-gradient(#F1F1F1, #DDDDDD); | |
| 236 | background-repeat: repeat-x; | |
| 237 | border-color: #DDDDDD #CCCCCC #CCCCCC #DDDDDD; | |
| 238 | border-image: none; | |
| 239 | border-radius: 2px 2px 2px 2px; | |
| 240 | border-style: solid; | |
| 241 | border-width: 1px; | |
| 242 | font-family: "Helvetica Neue",Helvetica,Arial,sans-serif; | |
| 243 | line-height: 10px; | |
| 244 | padding: 1px 4px; | |
| 230 | -moz-border-bottom-colors: none; | |
| 231 | -moz-border-left-colors: none; | |
| 232 | -moz-border-right-colors: none; | |
| 233 | -moz-border-top-colors: none; | |
| 234 | background-color: #DDDDDD; | |
| 235 | background-image: linear-gradient(#F1F1F1, #DDDDDD); | |
| 236 | background-repeat: repeat-x; | |
| 237 | border-color: #DDDDDD #CCCCCC #CCCCCC #DDDDDD; | |
| 238 | border-image: none; | |
| 239 | border-radius: 2px 2px 2px 2px; | |
| 240 | border-style: solid; | |
| 241 | border-width: 1px; | |
| 242 | font-family: "Helvetica Neue",Helvetica,Arial,sans-serif; | |
| 243 | line-height: 10px; | |
| 244 | padding: 1px 4px; | |
| 245 | 245 | } |
| 246 | 246 | |
| ... | ||
| 302 | 302 | img { |
| 303 | 303 | max-width: 100% |
| 304 | } | |
| 305 | ||
| 306 | /* CARET | |
| 307 | =============================================================================*/ | |
| 308 | ||
| 309 | #CARETPOSITION { | |
| 310 | border-right:1px solid #333; | |
| 311 | margin-right:-1px; | |
| 312 | animation: blink 1s linear infinite; | |
| 313 | } | |
| 314 | ||
| 315 | @keyframes blink { | |
| 316 | from { | |
| 317 | visibility:hidden; | |
| 318 | } | |
| 319 | 50% { | |
| 320 | visibility:hidden; | |
| 321 | } | |
| 322 | to { | |
| 323 | visibility:visible; | |
| 324 | } | |
| 304 | 325 | } |
| 305 | 326 | |
| 1 | 1 | # ######################################################################## |
| 2 | 2 | # |
| 3 | # Application | |
| 4 | # | |
| 5 | # ######################################################################## | |
| 6 | ||
| 7 | application.title=scrivenvar | |
| 8 | application.package=com/${application.title} | |
| 9 | application.messages= com.${application.title}.messages | |
| 10 | ||
| 11 | # ######################################################################## | |
| 12 | # | |
| 13 | # Preferences | |
| 14 | # | |
| 15 | # ######################################################################## | |
| 16 | ||
| 17 | preferences.root=com.${application.title} | |
| 18 | preferences.root.state=state | |
| 19 | preferences.root.options=options | |
| 20 | ||
| 21 | # ######################################################################## | |
| 22 | # | |
| 23 | # File References | |
| 24 | # | |
| 25 | # ######################################################################## | |
| 26 | ||
| 27 | file.stylesheet.scene=${application.package}/scene.css | |
| 28 | file.stylesheet.markdown=${application.package}/editor/Markdown.css | |
| 29 | file.stylesheet.preview=webview.css | |
| 30 | ||
| 31 | file.logo.16 =${application.package}/logo16.png | |
| 32 | file.logo.32 =${application.package}/logo32.png | |
| 33 | file.logo.128=${application.package}/logo128.png | |
| 34 | file.logo.256=${application.package}/logo256.png | |
| 35 | file.logo.512=${application.package}/logo512.png | |
| 36 | ||
| 37 | # ######################################################################## | |
| 38 | # | |
| 39 | # Caret token | |
| 40 | # | |
| 41 | # ######################################################################## | |
| 42 | caret.token.base=CARETPOSITION | |
| 43 | caret.token.markdown=%${constant.caret.token.base}% | |
| 44 | caret.token.xml=<![CDATA[${constant.caret.token.markdown}]]> | |
| 45 | caret.token.html=<span id="${caret.token.base}"></span> | |
| 46 | ||
| 47 | # ######################################################################## | |
| 48 | # | |
| 3 | 49 | # Filename Extensions |
| 4 | 50 | # |
| 5 | 51 | # ######################################################################## |
| 52 | ||
| 53 | # Comma-separated list of definition filename extensions. | |
| 54 | file.ext.definition.json=*.json | |
| 55 | file.ext.definition.toml=*.toml | |
| 56 | file.ext.definition.yaml=*.yml,*.yaml | |
| 57 | file.ext.definition.properties=*.properties,*.props | |
| 6 | 58 | |
| 7 | 59 | # Comma-separated list of filename extensions. |
| 8 | Dialog.file.choose.filter.ext.markdown=*.Rmd,*.md,*.markdown,*.mkdown,*.mdown,*.mkdn,*.mkd,*.mdwn,*.mdtxt,*.mdtext,*.text,*.txt | |
| 9 | Dialog.file.choose.filter.ext.definition=*.yml,*.yaml,*.properties,*.props | |
| 10 | Dialog.file.choose.filter.ext.xml=*.xml,*.Rxml | |
| 11 | Dialog.file.choose.filter.ext.all=*.* | |
| 60 | filter.file.ext.markdown=*.Rmd,*.md,*.markdown,*.mkdown,*.mdown,*.mkdn,*.mkd,*.mdwn,*.mdtxt,*.mdtext,*.text,*.txt | |
| 61 | filter.file.ext.definition=${file.ext.definition.yaml} | |
| 62 | filter.file.ext.xml=*.xml,*.Rxml | |
| 63 | filter.file.ext.all=*.* | |
| 12 | 64 | |
| 13 | 65 | # ######################################################################## |