| 1 | From https://github.com/greenrobot/EventBus#r8-proguard | |
| 2 | ||
| 3 | -keepattributes *Annotation* | |
| 4 | -keepclassmembers class * { | |
| 5 | @org.greenrobot.eventbus.Subscribe <methods>; | |
| 6 | } | |
| 7 | -keep enum org.greenrobot.eventbus.ThreadMode { *; } | |
| 8 | ||
| 1 | 9 |
| 49 | 49 | * Real-time rendering of math using TeX notation |
| 50 | 50 | * Diagrams: Mermaid, GraphViz, UML, sequence, timing, and [many more](https://kroki.io/)! |
| 51 | * Dark, custom, and responsive themes | |
| 52 | * Interactive document outline | |
| 53 | * Internationalized font support (e.g., Chinese, Japanese, Korean, etc.) | |
| 51 | 54 | * R integration |
| 52 | 55 | * XML transformation using XSLT3 or older |
| 53 | 56 | * Customizable user interface having detachable tabs |
| 54 | 57 | * Platform-independent (Windows, Linux, MacOS) |
| 55 | 58 | |
| 56 | 59 | ## Usage |
| 57 | 60 | |
| 58 | See the [detailed documentation](docs/README.md) for information about | |
| 59 | using the application. | |
| 61 | Read the [detailed documentation](docs/README.md) for using the application. | |
| 62 | ||
| 63 | ### Themes | |
| 64 | ||
| 65 | Read the [themes documentation](docs/themes.md) to learn about themes. | |
| 60 | 66 | |
| 61 | 67 | ## Screenshots |
| 62 | 68 | |
| 63 | 69 | Diagram that includes variables: |
| 64 | 70 | |
| 65 |  | |
| 71 |  | |
| 66 | 72 | |
| 67 | 73 | Poem with locale settings: |
| 68 | 74 | |
| 69 |  | |
| 75 |  | |
| 70 | 76 | |
| 71 | 77 | TeX equations with detached preview: |
| 72 | 78 | |
| 73 |  | |
| 79 |  | |
| 80 | ||
| 81 | Document outline opened and docked in bottom-left corner: | |
| 82 | ||
| 83 |  | |
| 74 | 84 | |
| 75 | 85 | ## License |
| 104 | 104 | implementation 'com.googlecode.juniversalchardet:juniversalchardet:1.0.3' |
| 105 | 105 | implementation 'javax.validation:validation-api:2.0.1.Final' |
| 106 | implementation 'org.greenrobot:eventbus:3.2.0' | |
| 106 | 107 | |
| 107 | 108 | // Configuration |
| 5 | 5 | * [definitions.md](definitions.md) -- Definitions and interpolation |
| 6 | 6 | * [r.md](r.md) -- Call R functions within R Markdown documents |
| 7 | * [texample.Rmd](texample.Rmd) -- Numerous examples of formulas | |
| 8 | 7 | * [svg.md](svg.md) -- Fix known issues with displaying SVG files |
| 8 | * [themes.md](themes.md) -- Describes how to add and customize themes | |
| 9 | * [texample.Rmd](texample.Rmd) -- Numerous examples of formulas | |
| 9 | 10 | * [credits.md](credits.md) -- Thanks to authors of contributing projects |
| 10 | 11 |
| 1 | # Themes | |
| 2 | ||
| 3 | The application provides bundled themes and the ability to add custom | |
| 4 | themes. This document describes the interplay between bundled themes | |
| 5 | and building your own theme. | |
| 6 | ||
| 7 | A theme is a set of styles, similar to cascading style sheet classes, | |
| 8 | that instruct the user interface on how to apply colours, fonts, spacing, | |
| 9 | highlights, drop-shadows, gradients, and so forth. | |
| 10 | ||
| 11 | For more information on CSS, see the [W3C CSS tutorial](https://www.w3.org/Style/Examples/011/firstcss). | |
| 12 | ||
| 13 | # Order | |
| 14 | ||
| 15 | The order that stylesheets are applied matters so that stylesheets can | |
| 16 | override styles defined previously. The application's user interface | |
| 17 | is made up of the following stylesheets, applied in the order listed: | |
| 18 | ||
| 19 | * **scene.css** --- Defines toolbar styling. | |
| 20 | * **markdown.css** --- Defines text editor styling. | |
| 21 | * **themes/theme_name.css** --- Bundled theme selected in preferences. | |
| 22 | * **custom.css** --- User-defined file set in preferences. | |
| 23 | ||
| 24 | # Customization | |
| 25 | ||
| 26 | Create a custom theme as follows: | |
| 27 | ||
| 28 | 1. Start the application. | |
| 29 | 1. Click **File → New** to create a new file. | |
| 30 | 1. Click **File → Save As** to rename the file. | |
| 31 | 1. Save the file as `custom.css`. | |
| 32 | 1. Change the content to the following: | |
| 33 | ``` css | |
| 34 | .root { | |
| 35 | -fx-base: rgb( 30, 30, 30 ); | |
| 36 | -fx-background: -fx-base; | |
| 37 | } | |
| 38 | ``` | |
| 39 | ||
| 40 | Next, apply the theme as follows: | |
| 41 | ||
| 42 | 1. Click **Edit → Preferences** to open the preferences dialog. | |
| 43 | 1. Click **Themes** to view the theme options. | |
| 44 | 1. Click **Browse** to select a custom theme file. | |
| 45 | 1. Browse to and select `custom.css`, created previously. | |
| 46 | 1. Click **Open**. | |
| 47 | 1. Click **Apply**. | |
| 48 | ||
| 49 | The user interface immediately changes to a dark mode. Continue: | |
| 50 | ||
| 51 | 1. Click **OK** to close the dialog. | |
| 52 | 1. Change the **rgb** numbers in **custom.css** from `30` to `60`. | |
| 53 | 1. Click **File → Save** to save the CSS file. | |
| 54 | ||
| 55 | The user interface immediately changes colour. | |
| 56 | ||
| 57 | # Classes | |
| 58 | ||
| 59 | When creating your own theme, there many classes that can be styled. The | |
| 60 | previous section showed how to set up a rudimentary theme. Instead, start | |
| 61 | with a template that already has a number of classes defined so that you | |
| 62 | can tweak them to your taste. Accomplish this as follows: | |
| 63 | ||
| 64 | 1. Visit the [themes](https://github.com/DaveJarvis/keenwrite/tree/master/src/main/resources/com/keenwrite/themes) repository directory | |
| 65 | 1. Click one of the themes (e.g., `haunted_grey.css`). | |
| 66 | 1. Click **Raw**. | |
| 67 | 1. Copy the entire text. | |
| 68 | 1. Return to `custom.css`. | |
| 69 | 1. Delete the contents. | |
| 70 | 1. Paste the copied text. | |
| 71 | 1. Save the file. | |
| 72 | ||
| 73 | To see how the CSS styles are applied to the text editor, open | |
| 74 | [markdown.css](https://github.com/DaveJarvis/keenwrite/blob/master/src/main/resources/com/keenwrite/editor/markdown.css), which is also in the repository. | |
| 75 | ||
| 76 | # Modena | |
| 77 | ||
| 78 | The basic theme used by the application is _Modena Light_. Typically we | |
| 79 | only need to override a few classes to completely change the application's | |
| 80 | look and feel. For a full listing of available styles see the OpenJDK's | |
| 81 | [Modena CSS file](https://github.com/openjdk/jfx/blob/master/modules/javafx.controls/src/main/resources/com/sun/javafx/scene/control/skin/modena/modena.css). | |
| 82 | ||
| 83 | # JavaFX CSS | |
| 84 | ||
| 85 | The [Java CSS Reference Guide](https://openjfx.io/javadoc/11/javafx.graphics/javafx/scene/doc-files/cssref.html) is exhaustive. In addition to showing many | |
| 86 | differences between JavaFX CSS and W3C CSS, the guide introduces numerous | |
| 87 | helpful functions for manipulating colours and gradients using existing | |
| 88 | colour definitions. | |
| 89 | ||
| 90 | # RichTextFX | |
| 91 | ||
| 92 | The application uses RichTextFX to render the text editor. Styling various | |
| 93 | text editor classes can require using the prefix `-rtfx` instead of the | |
| 94 | regular JavaFX `-fx`. | |
| 95 | ||
| 96 | # Submit | |
| 97 | ||
| 98 | Send in your themes! If you have a theme you'd like to contribute to the | |
| 99 | project, or improvements to an existing theme, do pass it along. Either open a new issue in the [issue tracker](https://github.com/DaveJarvis/keenwrite/issues) that contains the CSS file or submit a pull request. | |
| 100 | ||
| 1 | 101 |
| 198 | 198 | getParagraphCount(), |
| 199 | 199 | getTextOffset() + 1 ); |
| 200 | } catch( final NullPointerException ex ) { | |
| 200 | } catch( final Exception ex ) { | |
| 201 | 201 | return get( STATUS_BAR_LINE, 0, 0, 0 ); |
| 202 | 202 | } |
| 3 | 3 | |
| 4 | 4 | import com.keenwrite.service.Settings; |
| 5 | import javafx.scene.control.ToolBar; | |
| 6 | import javafx.scene.control.TreeItem; | |
| 5 | 7 | import javafx.scene.image.Image; |
| 6 | 8 | import javafx.scene.image.ImageView; |
| ... | ||
| 215 | 217 | */ |
| 216 | 218 | public static final String CARET_ID = "caret"; |
| 219 | ||
| 220 | /** | |
| 221 | * Default icon size for {@link ToolBar}, {@link TreeItem}s, etc. | |
| 222 | */ | |
| 223 | public static final String ICON_SIZE_DEFAULT = "1.2em"; | |
| 217 | 224 | |
| 218 | 225 | /** |
| 8 | 8 | |
| 9 | 9 | import static com.keenwrite.Constants.*; |
| 10 | import static com.keenwrite.StatusNotifier.clue; | |
| 10 | import static com.keenwrite.events.StatusEvent.clue; | |
| 11 | 11 | |
| 12 | 12 | /** |
| ... | ||
| 53 | 53 | } |
| 54 | 54 | } |
| 55 | } catch( final Exception ignored ) { | |
| 56 | clue( STATUS_DEFINITION_BLANK ); | |
| 55 | } catch( final Exception ex ) { | |
| 56 | clue( STATUS_DEFINITION_BLANK, ex ); | |
| 57 | 57 | } |
| 58 | 58 | } |
| 30 | 30 | |
| 31 | 31 | private Workspace mWorkspace; |
| 32 | private MainScene mMainScene; | |
| 32 | 33 | |
| 33 | 34 | /** |
| ... | ||
| 96 | 97 | // After the app loses focus, when the user switches back using Alt+Tab, |
| 97 | 98 | // the menu mnemonic is sometimes engaged, swallowing the first letter that |
| 98 | // the user types---if it is a menu mnemonic. This consumes the Alt key | |
| 99 | // event to work around the bug. | |
| 99 | // the user types---if it is a menu mnemonic. See MainScene::createScene(). | |
| 100 | 100 | // |
| 101 | // See: https://bugs.openjdk.java.net/browse/JDK-8090647 | |
| 101 | // JavaFX Bug: https://bugs.openjdk.java.net/browse/JDK-8090647 | |
| 102 | 102 | stage.focusedProperty().addListener( ( c, lost, show ) -> { |
| 103 | if( lost ) { | |
| 104 | for( final var mnemonics : stage.getScene().getMnemonics().values() ) { | |
| 105 | for( final var mnemonic : mnemonics ) { | |
| 106 | mnemonic.getNode().fireEvent( keyUp( ALT, false ) ); | |
| 107 | } | |
| 103 | for( final var menu : mMainScene.getMenuBar().getMenus() ) { | |
| 104 | menu.hide(); | |
| 105 | } | |
| 106 | ||
| 107 | for( final var mnemonics : stage.getScene().getMnemonics().values() ) { | |
| 108 | for( final var mnemonic : mnemonics ) { | |
| 109 | mnemonic.getNode().fireEvent( keyUp( ALT ) ); | |
| 108 | 110 | } |
| 109 | 111 | } |
| ... | ||
| 116 | 118 | |
| 117 | 119 | private void initScene( final Stage stage ) { |
| 118 | stage.setScene( (new MainScene( mWorkspace )).getScene() ); | |
| 120 | mMainScene = new MainScene( mWorkspace ); | |
| 121 | stage.setScene( mMainScene.getScene() ); | |
| 119 | 122 | } |
| 120 | 123 | |
| ... | ||
| 139 | 142 | public static Event keyUp( final KeyCode code, final boolean shift ) { |
| 140 | 143 | return keyEvent( KEY_RELEASED, code, shift ); |
| 144 | } | |
| 145 | ||
| 146 | /** | |
| 147 | * Returns a key released event without any modifier keys held. | |
| 148 | * | |
| 149 | * @param code The key code representing a key to simulate releasing. | |
| 150 | * @return An instance of {@link KeyEvent}. | |
| 151 | */ | |
| 152 | public static Event keyUp( final KeyCode code ) { | |
| 153 | return keyUp( code, false ); | |
| 141 | 154 | } |
| 142 | 155 | |
| 6 | 6 | import com.keenwrite.editors.TextResource; |
| 7 | 7 | import com.keenwrite.editors.definition.DefinitionEditor; |
| 8 | import com.keenwrite.editors.definition.DefinitionTabSceneFactory; | |
| 9 | import com.keenwrite.editors.definition.TreeTransformer; | |
| 10 | import com.keenwrite.editors.definition.yaml.YamlTreeTransformer; | |
| 11 | import com.keenwrite.editors.markdown.MarkdownEditor; | |
| 12 | import com.keenwrite.io.MediaType; | |
| 13 | import com.keenwrite.preferences.Key; | |
| 14 | import com.keenwrite.preferences.Workspace; | |
| 15 | import com.keenwrite.preview.HtmlPreview; | |
| 16 | import com.keenwrite.processors.IdentityProcessor; | |
| 17 | import com.keenwrite.processors.Processor; | |
| 18 | import com.keenwrite.processors.ProcessorContext; | |
| 19 | import com.keenwrite.processors.ProcessorFactory; | |
| 20 | import com.keenwrite.processors.markdown.extensions.caret.CaretExtension; | |
| 21 | import com.keenwrite.service.events.Notifier; | |
| 22 | import com.keenwrite.sigils.RSigilOperator; | |
| 23 | import com.keenwrite.sigils.SigilOperator; | |
| 24 | import com.keenwrite.sigils.Tokens; | |
| 25 | import com.keenwrite.sigils.YamlSigilOperator; | |
| 26 | import com.panemu.tiwulfx.control.dock.DetachableTab; | |
| 27 | import com.panemu.tiwulfx.control.dock.DetachableTabPane; | |
| 28 | import javafx.application.Platform; | |
| 29 | import javafx.beans.property.*; | |
| 30 | import javafx.collections.ListChangeListener; | |
| 31 | import javafx.event.ActionEvent; | |
| 32 | import javafx.event.Event; | |
| 33 | import javafx.event.EventHandler; | |
| 34 | import javafx.scene.Scene; | |
| 35 | import javafx.scene.control.SplitPane; | |
| 36 | import javafx.scene.control.Tab; | |
| 37 | import javafx.scene.control.Tooltip; | |
| 38 | import javafx.scene.control.TreeItem.TreeModificationEvent; | |
| 39 | import javafx.scene.input.KeyEvent; | |
| 40 | import javafx.stage.Stage; | |
| 41 | import javafx.stage.Window; | |
| 42 | ||
| 43 | import java.io.File; | |
| 44 | import java.nio.file.Path; | |
| 45 | import java.util.*; | |
| 46 | import java.util.concurrent.atomic.AtomicBoolean; | |
| 47 | import java.util.function.Function; | |
| 48 | import java.util.stream.Collectors; | |
| 49 | ||
| 50 | import static com.keenwrite.Constants.*; | |
| 51 | import static com.keenwrite.ExportFormat.NONE; | |
| 52 | import static com.keenwrite.Messages.get; | |
| 53 | import static com.keenwrite.StatusNotifier.clue; | |
| 54 | import static com.keenwrite.io.MediaType.*; | |
| 55 | import static com.keenwrite.preferences.WorkspaceKeys.*; | |
| 56 | import static com.keenwrite.processors.ProcessorFactory.createProcessors; | |
| 57 | import static com.keenwrite.service.events.Notifier.NO; | |
| 58 | import static com.keenwrite.service.events.Notifier.YES; | |
| 59 | import static java.util.stream.Collectors.groupingBy; | |
| 60 | import static javafx.application.Platform.runLater; | |
| 61 | import static javafx.scene.control.TabPane.TabClosingPolicy.ALL_TABS; | |
| 62 | import static javafx.scene.input.KeyCode.SPACE; | |
| 63 | import static javafx.scene.input.KeyCombination.CONTROL_DOWN; | |
| 64 | import static javafx.util.Duration.millis; | |
| 65 | import static javax.swing.SwingUtilities.invokeLater; | |
| 66 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 67 | ||
| 68 | /** | |
| 69 | * Responsible for wiring together the main application components for a | |
| 70 | * particular workspace (project). These include the definition views, | |
| 71 | * text editors, and preview pane along with any corresponding controllers. | |
| 72 | */ | |
| 73 | public final class MainPane extends SplitPane { | |
| 74 | private static final Notifier sNotifier = Services.load( Notifier.class ); | |
| 75 | ||
| 76 | /** | |
| 77 | * Used when opening files to determine how each file should be binned and | |
| 78 | * therefore what tab pane to be opened within. | |
| 79 | */ | |
| 80 | private static final Set<MediaType> PLAIN_TEXT_FORMAT = Set.of( | |
| 81 | TEXT_MARKDOWN, TEXT_R_MARKDOWN, TEXT_R_XML, UNDEFINED | |
| 82 | ); | |
| 83 | ||
| 84 | /** | |
| 85 | * Prevents re-instantiation of processing classes. | |
| 86 | */ | |
| 87 | private final Map<TextResource, Processor<String>> mProcessors = | |
| 88 | new HashMap<>(); | |
| 89 | ||
| 90 | private final Workspace mWorkspace; | |
| 91 | ||
| 92 | /** | |
| 93 | * Groups similar file type tabs together. | |
| 94 | */ | |
| 95 | private final Map<MediaType, DetachableTabPane> mTabPanes = new HashMap<>(); | |
| 96 | ||
| 97 | /** | |
| 98 | * Stores definition names and values. | |
| 99 | */ | |
| 100 | private final Map<String, String> mResolvedMap = | |
| 101 | new HashMap<>( MAP_SIZE_DEFAULT ); | |
| 102 | ||
| 103 | /** | |
| 104 | * Renders the actively selected plain text editor tab. | |
| 105 | */ | |
| 106 | private final HtmlPreview mHtmlPreview; | |
| 107 | ||
| 108 | /** | |
| 109 | * Changing the active editor fires the value changed event. This allows | |
| 110 | * refreshes to happen when external definitions are modified and need to | |
| 111 | * trigger the processing chain. | |
| 112 | */ | |
| 113 | private final ObjectProperty<TextEditor> mActiveTextEditor = | |
| 114 | createActiveTextEditor(); | |
| 115 | ||
| 116 | /** | |
| 117 | * Changing the active definition editor fires the value changed event. This | |
| 118 | * allows refreshes to happen when external definitions are modified and need | |
| 119 | * to trigger the processing chain. | |
| 120 | */ | |
| 121 | private final ObjectProperty<TextDefinition> mActiveDefinitionEditor = | |
| 122 | createActiveDefinitionEditor( mActiveTextEditor ); | |
| 123 | ||
| 124 | /** | |
| 125 | * Responsible for creating a new scene when a tab is detached into | |
| 126 | * its own window frame. | |
| 127 | */ | |
| 128 | private final DefinitionTabSceneFactory mDefinitionTabSceneFactory = | |
| 129 | createDefinitionTabSceneFactory( mActiveDefinitionEditor ); | |
| 130 | ||
| 131 | /** | |
| 132 | * Tracks the number of detached tab panels opened into their own windows, | |
| 133 | * which allows unique identification of subordinate windows by their title. | |
| 134 | * It is doubtful more than 128 windows, much less 256, will be created. | |
| 135 | */ | |
| 136 | private byte mWindowCount; | |
| 137 | ||
| 138 | /** | |
| 139 | * Called when the definition data is changed. | |
| 140 | */ | |
| 141 | private final EventHandler<TreeModificationEvent<Event>> mTreeHandler = | |
| 142 | event -> { | |
| 143 | final var editor = mActiveDefinitionEditor.get(); | |
| 144 | ||
| 145 | resolve( editor ); | |
| 146 | process( getActiveTextEditor() ); | |
| 147 | save( editor ); | |
| 148 | }; | |
| 149 | ||
| 150 | /** | |
| 151 | * Adds all content panels to the main user interface. This will load the | |
| 152 | * configuration settings from the workspace to reproduce the settings from | |
| 153 | * a previous session. | |
| 154 | */ | |
| 155 | public MainPane( final Workspace workspace ) { | |
| 156 | mWorkspace = workspace; | |
| 157 | mHtmlPreview = new HtmlPreview( workspace ); | |
| 158 | ||
| 159 | open( bin( getRecentFiles() ) ); | |
| 160 | viewPreview(); | |
| 161 | setDividerPositions( calculateDividerPositions() ); | |
| 162 | ||
| 163 | // Once the main scene's window regains focus, update the active definition | |
| 164 | // editor to the currently selected tab. | |
| 165 | runLater( | |
| 166 | () -> { | |
| 167 | getWindow().focusedProperty().addListener( ( c, o, n ) -> { | |
| 168 | if( n != null && n ) { | |
| 169 | final var pane = mTabPanes.get( TEXT_YAML ); | |
| 170 | final var model = pane.getSelectionModel(); | |
| 171 | final var tab = model.getSelectedItem(); | |
| 172 | ||
| 173 | if( tab != null ) { | |
| 174 | final var resource = tab.getContent(); | |
| 175 | ||
| 176 | if( resource instanceof TextDefinition ) { | |
| 177 | mActiveDefinitionEditor.set( (TextDefinition) tab.getContent() ); | |
| 178 | } | |
| 179 | } | |
| 180 | } | |
| 181 | } ); | |
| 182 | ||
| 183 | getWindow().setOnCloseRequest( ( event ) -> { | |
| 184 | // Order matters here. We want to close all the tabs to ensure each | |
| 185 | // is saved, but after they are closed, the workspace should still | |
| 186 | // retain the list of files that were open. If this line came after | |
| 187 | // closing, then restarting the application would list no files. | |
| 188 | mWorkspace.save(); | |
| 189 | ||
| 190 | if( closeAll() ) { | |
| 191 | Platform.exit(); | |
| 192 | System.exit( 0 ); | |
| 193 | } | |
| 194 | else { | |
| 195 | event.consume(); | |
| 196 | } | |
| 197 | } ); | |
| 198 | } | |
| 199 | ); | |
| 200 | } | |
| 201 | ||
| 202 | /** | |
| 203 | * TODO: Load divider positions from exported settings, see bin() comment. | |
| 204 | */ | |
| 205 | private double[] calculateDividerPositions() { | |
| 206 | final var ratio = 100f / getItems().size() / 100; | |
| 207 | final var positions = getDividerPositions(); | |
| 208 | ||
| 209 | for( int i = 0; i < positions.length; i++ ) { | |
| 210 | positions[ i ] = ratio * i; | |
| 211 | } | |
| 212 | ||
| 213 | return positions; | |
| 214 | } | |
| 215 | ||
| 216 | /** | |
| 217 | * Opens all the files into the application, provided the paths are unique. | |
| 218 | * This may only be called for any type of files that a user can edit | |
| 219 | * (i.e., update and persist), such as definitions and text files. | |
| 220 | * | |
| 221 | * @param files The list of files to open. | |
| 222 | */ | |
| 223 | public void open( final List<File> files ) { | |
| 224 | files.forEach( this::open ); | |
| 225 | } | |
| 226 | ||
| 227 | /** | |
| 228 | * This opens the given file. Since the preview pane is not a file that | |
| 229 | * can be opened, it is safe to add a listener to the detachable pane. | |
| 230 | * | |
| 231 | * @param file The file to open. | |
| 232 | */ | |
| 233 | private void open( final File file ) { | |
| 234 | final var tab = createTab( file ); | |
| 235 | final var node = tab.getContent(); | |
| 236 | final var mediaType = MediaType.valueFrom( file ); | |
| 237 | final var tabPane = obtainDetachableTabPane( mediaType ); | |
| 238 | final var newTabPane = !getItems().contains( tabPane ); | |
| 239 | ||
| 240 | tab.setTooltip( createTooltip( file ) ); | |
| 241 | tabPane.setFocusTraversable( false ); | |
| 242 | tabPane.setTabClosingPolicy( ALL_TABS ); | |
| 243 | tabPane.getTabs().add( tab ); | |
| 244 | ||
| 245 | if( newTabPane ) { | |
| 246 | var index = getItems().size(); | |
| 247 | ||
| 248 | if( node instanceof TextDefinition ) { | |
| 249 | tabPane.setSceneFactory( mDefinitionTabSceneFactory::create ); | |
| 250 | index = 0; | |
| 251 | } | |
| 252 | ||
| 253 | addTabPane( index, tabPane ); | |
| 254 | } | |
| 255 | ||
| 256 | getRecentFiles().add( file.getAbsolutePath() ); | |
| 257 | } | |
| 258 | ||
| 259 | /** | |
| 260 | * Opens a new text editor document using the default document file name. | |
| 261 | */ | |
| 262 | public void newTextEditor() { | |
| 263 | open( DOCUMENT_DEFAULT ); | |
| 264 | } | |
| 265 | ||
| 266 | /** | |
| 267 | * Opens a new definition editor document using the default definition | |
| 268 | * file name. | |
| 269 | */ | |
| 270 | public void newDefinitionEditor() { | |
| 271 | open( DEFINITION_DEFAULT ); | |
| 272 | } | |
| 273 | ||
| 274 | /** | |
| 275 | * Iterates over all tab panes to find all {@link TextEditor}s and request | |
| 276 | * that they save themselves. | |
| 277 | */ | |
| 278 | public void saveAll() { | |
| 279 | mTabPanes.forEach( | |
| 280 | ( mt, tp ) -> tp.getTabs().forEach( ( tab ) -> { | |
| 281 | final var node = tab.getContent(); | |
| 282 | if( node instanceof TextEditor ) { | |
| 283 | save( ((TextEditor) node) ); | |
| 284 | } | |
| 285 | } ) | |
| 286 | ); | |
| 287 | } | |
| 288 | ||
| 289 | /** | |
| 290 | * Requests that the active {@link TextEditor} saves itself. Don't bother | |
| 291 | * checking if modified first because if the user swaps external media from | |
| 292 | * an external source (e.g., USB thumb drive), save should not second-guess | |
| 293 | * the user: save always re-saves. Also, it's less code. | |
| 294 | */ | |
| 295 | public void save() { | |
| 296 | save( getActiveTextEditor() ); | |
| 297 | } | |
| 298 | ||
| 299 | /** | |
| 300 | * Saves the active {@link TextEditor} under a new name. | |
| 301 | * | |
| 302 | * @param file The new active editor {@link File} reference. | |
| 303 | */ | |
| 304 | public void saveAs( final File file ) { | |
| 305 | assert file != null; | |
| 306 | final var editor = getActiveTextEditor(); | |
| 307 | final var tab = getTab( editor ); | |
| 308 | ||
| 309 | editor.rename( file ); | |
| 310 | tab.ifPresent( t -> { | |
| 311 | t.setText( editor.getFilename() ); | |
| 312 | t.setTooltip( createTooltip( file ) ); | |
| 313 | } ); | |
| 314 | ||
| 315 | save(); | |
| 316 | } | |
| 317 | ||
| 318 | /** | |
| 319 | * Saves the given {@link TextResource} to a file. This is typically used | |
| 320 | * to save either an instance of {@link TextEditor} or {@link TextDefinition}. | |
| 321 | * | |
| 322 | * @param resource The resource to export. | |
| 323 | */ | |
| 324 | private void save( final TextResource resource ) { | |
| 325 | try { | |
| 326 | resource.save(); | |
| 327 | } catch( final Exception ex ) { | |
| 328 | clue( ex ); | |
| 329 | sNotifier.alert( | |
| 330 | getWindow(), resource.getPath(), "TextResource.saveFailed", ex | |
| 331 | ); | |
| 332 | } | |
| 333 | } | |
| 334 | ||
| 335 | /** | |
| 336 | * Closes all open {@link TextEditor}s; all {@link TextDefinition}s stay open. | |
| 337 | * | |
| 338 | * @return {@code true} when all editors, modified or otherwise, were | |
| 339 | * permitted to close; {@code false} when one or more editors were modified | |
| 340 | * and the user requested no closing. | |
| 341 | */ | |
| 342 | public boolean closeAll() { | |
| 343 | var closable = true; | |
| 344 | ||
| 345 | for( final var entry : mTabPanes.entrySet() ) { | |
| 346 | final var tabPane = entry.getValue(); | |
| 347 | final var tabIterator = tabPane.getTabs().iterator(); | |
| 348 | ||
| 349 | while( tabIterator.hasNext() ) { | |
| 350 | final var tab = tabIterator.next(); | |
| 351 | final var resource = tab.getContent(); | |
| 352 | ||
| 353 | if( !(resource instanceof TextResource) ) { | |
| 354 | continue; | |
| 355 | } | |
| 356 | ||
| 357 | if( canClose( (TextResource) resource ) ) { | |
| 358 | tabIterator.remove(); | |
| 359 | close( tab ); | |
| 360 | } | |
| 361 | else { | |
| 362 | closable = false; | |
| 363 | } | |
| 364 | } | |
| 365 | } | |
| 366 | ||
| 367 | return closable; | |
| 368 | } | |
| 369 | ||
| 370 | /** | |
| 371 | * Calls the tab's {@link Tab#getOnClosed()} handler to carry out a close | |
| 372 | * event. | |
| 373 | * | |
| 374 | * @param tab The {@link Tab} that was closed. | |
| 375 | */ | |
| 376 | private void close( final Tab tab ) { | |
| 377 | final var handler = tab.getOnClosed(); | |
| 378 | ||
| 379 | if( handler != null ) { | |
| 380 | handler.handle( new ActionEvent() ); | |
| 381 | } | |
| 382 | } | |
| 383 | ||
| 384 | /** | |
| 385 | * Closes the active tab; delegates to {@link #canClose(TextResource)}. | |
| 386 | */ | |
| 387 | public void close() { | |
| 388 | final var editor = getActiveTextEditor(); | |
| 389 | if( canClose( editor ) ) { | |
| 390 | close( editor ); | |
| 391 | } | |
| 392 | } | |
| 393 | ||
| 394 | /** | |
| 395 | * Closes the given {@link TextResource}. This must not be called from within | |
| 396 | * a loop that iterates over the tab panes using {@code forEach}, lest a | |
| 397 | * concurrent modification exception be thrown. | |
| 398 | * | |
| 399 | * @param resource The {@link TextResource} to close, without confirming with | |
| 400 | * the user. | |
| 401 | */ | |
| 402 | private void close( final TextResource resource ) { | |
| 403 | getTab( resource ).ifPresent( | |
| 404 | ( tab ) -> { | |
| 405 | tab.getTabPane().getTabs().remove( tab ); | |
| 406 | close( tab ); | |
| 407 | } | |
| 408 | ); | |
| 409 | } | |
| 410 | ||
| 411 | /** | |
| 412 | * Answers whether the given {@link TextResource} may be closed. | |
| 413 | * | |
| 414 | * @param editor The {@link TextResource} to try closing. | |
| 415 | * @return {@code true} when the editor may be closed; {@code false} when | |
| 416 | * the user has requested to keep the editor open. | |
| 417 | */ | |
| 418 | private boolean canClose( final TextResource editor ) { | |
| 419 | final var editorTab = getTab( editor ); | |
| 420 | final var canClose = new AtomicBoolean( true ); | |
| 421 | ||
| 422 | if( editor.isModified() ) { | |
| 423 | final var filename = new StringBuilder(); | |
| 424 | editorTab.ifPresent( ( tab ) -> filename.append( tab.getText() ) ); | |
| 425 | ||
| 426 | final var message = sNotifier.createNotification( | |
| 427 | Messages.get( "Alert.file.close.title" ), | |
| 428 | Messages.get( "Alert.file.close.text" ), | |
| 429 | filename.toString() | |
| 430 | ); | |
| 431 | ||
| 432 | final var dialog = sNotifier.createConfirmation( getWindow(), message ); | |
| 433 | ||
| 434 | dialog.showAndWait().ifPresent( | |
| 435 | save -> canClose.set( save == YES ? editor.save() : save == NO ) | |
| 436 | ); | |
| 437 | } | |
| 438 | ||
| 439 | return canClose.get(); | |
| 440 | } | |
| 441 | ||
| 442 | private ObjectProperty<TextEditor> createActiveTextEditor() { | |
| 443 | final var editor = new SimpleObjectProperty<TextEditor>(); | |
| 444 | ||
| 445 | editor.addListener( ( c, o, n ) -> { | |
| 446 | if( n != null ) { | |
| 447 | mHtmlPreview.setBaseUri( n.getPath() ); | |
| 448 | process( n ); | |
| 449 | } | |
| 450 | } ); | |
| 451 | ||
| 452 | return editor; | |
| 453 | } | |
| 454 | ||
| 455 | /** | |
| 456 | * Adds the HTML preview tab to its own tab pane. This will only add the | |
| 457 | * preview once. | |
| 458 | */ | |
| 459 | public void viewPreview() { | |
| 460 | final var tabPane = obtainDetachableTabPane( TEXT_HTML ); | |
| 461 | ||
| 462 | // Prevent multiple HTML previews because in the end, there can be only one. | |
| 463 | for( final var tab : tabPane.getTabs() ) { | |
| 464 | if( tab.getContent() == mHtmlPreview ) { | |
| 465 | return; | |
| 466 | } | |
| 467 | } | |
| 468 | ||
| 469 | tabPane.addTab( "HTML", mHtmlPreview ); | |
| 470 | addTabPane( tabPane ); | |
| 471 | } | |
| 472 | ||
| 473 | public void viewRefresh() { | |
| 474 | mHtmlPreview.refresh(); | |
| 475 | } | |
| 476 | ||
| 477 | /** | |
| 478 | * Returns the tab that contains the given {@link TextEditor}. | |
| 479 | * | |
| 480 | * @param editor The {@link TextEditor} instance to find amongst the tabs. | |
| 481 | * @return The first tab having content that matches the given tab. | |
| 482 | */ | |
| 483 | private Optional<Tab> getTab( final TextResource editor ) { | |
| 484 | return mTabPanes.values() | |
| 485 | .stream() | |
| 486 | .flatMap( pane -> pane.getTabs().stream() ) | |
| 487 | .filter( tab -> editor.equals( tab.getContent() ) ) | |
| 488 | .findFirst(); | |
| 489 | } | |
| 490 | ||
| 491 | /** | |
| 492 | * Creates a new {@link DefinitionEditor} wrapped in a listener that | |
| 493 | * is used to detect when the active {@link DefinitionEditor} has changed. | |
| 494 | * Upon changing, the {@link #mResolvedMap} is updated and the active | |
| 495 | * text editor is refreshed. | |
| 496 | * | |
| 497 | * @param editor Text editor to update with the revised resolved map. | |
| 498 | * @return A newly configured property that represents the active | |
| 499 | * {@link DefinitionEditor}, never null. | |
| 500 | */ | |
| 501 | private ObjectProperty<TextDefinition> createActiveDefinitionEditor( | |
| 502 | final ObjectProperty<TextEditor> editor ) { | |
| 503 | final var definitions = new SimpleObjectProperty<TextDefinition>(); | |
| 504 | definitions.addListener( ( c, o, n ) -> { | |
| 505 | resolve( n == null ? createDefinitionEditor() : n ); | |
| 506 | process( editor.get() ); | |
| 507 | } ); | |
| 508 | ||
| 509 | return definitions; | |
| 510 | } | |
| 511 | ||
| 512 | /** | |
| 513 | * Instantiates a factory that's responsible for creating new scenes when | |
| 514 | * a tab is dropped outside of any application window. The definition tabs | |
| 515 | * are fairly complex in that only one may be active at any time. When | |
| 516 | * activated, the {@link #mResolvedMap} must be updated to reflect the | |
| 517 | * hierarchy displayed in the {@link DefinitionEditor}. | |
| 518 | * | |
| 519 | * @param activeDefinitionEditor The current {@link DefinitionEditor}. | |
| 520 | * @return An object that listens to {@link DefinitionEditor} tab focus | |
| 521 | * changes. | |
| 522 | */ | |
| 523 | private DefinitionTabSceneFactory createDefinitionTabSceneFactory( | |
| 524 | final ObjectProperty<TextDefinition> activeDefinitionEditor ) { | |
| 525 | return new DefinitionTabSceneFactory( ( tab ) -> { | |
| 526 | assert tab != null; | |
| 527 | ||
| 528 | var node = tab.getContent(); | |
| 529 | if( node instanceof TextDefinition ) { | |
| 530 | activeDefinitionEditor.set( (DefinitionEditor) node ); | |
| 531 | } | |
| 532 | } ); | |
| 533 | } | |
| 534 | ||
| 535 | private DetachableTab createTab( final File file ) { | |
| 536 | final var r = createTextResource( file ); | |
| 537 | final var tab = new DetachableTab( r.getFilename(), r.getNode() ); | |
| 538 | ||
| 539 | r.modifiedProperty().addListener( | |
| 540 | ( c, o, n ) -> tab.setText( r.getFilename() + (n ? "*" : "") ) | |
| 541 | ); | |
| 542 | ||
| 543 | // This is called when either the tab is closed by the user clicking on | |
| 544 | // the tab's close icon or when closing (all) from the file menu. | |
| 545 | tab.setOnClosed( | |
| 546 | ( __ ) -> getRecentFiles().remove( file.getAbsolutePath() ) | |
| 547 | ); | |
| 548 | ||
| 549 | return tab; | |
| 550 | } | |
| 551 | ||
| 552 | /** | |
| 553 | * Creates bins for the different {@link MediaType}s, which eventually are | |
| 554 | * added to the UI as separate tab panes. If ever a general-purpose scene | |
| 555 | * exporter is developed to serialize a scene to an FXML file, this could | |
| 556 | * be replaced by such a class. | |
| 557 | * <p> | |
| 558 | * When binning the files, this makes sure that at least one file exists | |
| 559 | * for every type. If the user has opted to close a particular type (such | |
| 560 | * as the definition pane), the view will suppressed elsewhere. | |
| 561 | * </p> | |
| 562 | * <p> | |
| 563 | * The order that the binned files are returned will be reflected in the | |
| 564 | * order that the corresponding panes are rendered in the UI. | |
| 565 | * </p> | |
| 566 | * | |
| 567 | * @param paths The file paths to bin according to their type. | |
| 568 | * @return An in-order list of files, first by structured definition files, | |
| 569 | * then by plain text documents. | |
| 570 | */ | |
| 571 | private List<File> bin( final SetProperty<String> paths ) { | |
| 572 | // Treat all files destined for the text editor as plain text documents | |
| 573 | // so that they are added to the same pane. Grouping by TEXT_PLAIN is a | |
| 574 | // bit arbitrary, but means explicitly capturing TEXT_PLAIN isn't needed. | |
| 575 | final Function<MediaType, MediaType> bin = | |
| 576 | m -> PLAIN_TEXT_FORMAT.contains( m ) ? TEXT_PLAIN : m; | |
| 577 | ||
| 578 | // Create two groups: YAML files and plain text files. | |
| 579 | final var bins = paths | |
| 580 | .stream() | |
| 581 | .collect( | |
| 582 | groupingBy( path -> bin.apply( MediaType.valueFrom( path ) ) ) | |
| 583 | ); | |
| 584 | ||
| 585 | bins.putIfAbsent( TEXT_YAML, List.of( DEFINITION_DEFAULT.toString() ) ); | |
| 586 | bins.putIfAbsent( TEXT_PLAIN, List.of( DOCUMENT_DEFAULT.toString() ) ); | |
| 587 | ||
| 588 | final var result = new ArrayList<File>( paths.size() ); | |
| 589 | ||
| 590 | // Ensure that the same types are listed together (keep insertion order). | |
| 591 | bins.forEach( ( mediaType, files ) -> result.addAll( | |
| 592 | files.stream().map( File::new ).collect( Collectors.toList() ) ) | |
| 593 | ); | |
| 594 | ||
| 595 | return result; | |
| 596 | } | |
| 597 | ||
| 598 | /** | |
| 599 | * Uses the given {@link TextDefinition} instance to update the | |
| 600 | * {@link #mResolvedMap}. | |
| 601 | * | |
| 602 | * @param editor A non-null, possibly empty definition editor. | |
| 603 | */ | |
| 604 | private void resolve( final TextDefinition editor ) { | |
| 605 | assert editor != null; | |
| 606 | ||
| 607 | final var tokens = createDefinitionTokens(); | |
| 608 | final var operator = new YamlSigilOperator( tokens ); | |
| 609 | final var map = new HashMap<String, String>(); | |
| 610 | ||
| 611 | editor.toMap().forEach( ( k, v ) -> map.put( operator.entoken( k ), v ) ); | |
| 612 | ||
| 613 | mResolvedMap.clear(); | |
| 614 | mResolvedMap.putAll( editor.interpolate( map, tokens ) ); | |
| 615 | } | |
| 616 | ||
| 617 | /** | |
| 618 | * Force the active editor to update, which will cause the processor | |
| 619 | * to re-evaluate the interpolated definition map thereby updating the | |
| 620 | * preview pane. | |
| 621 | * | |
| 622 | * @param editor Contains the source document to update in the preview pane. | |
| 623 | */ | |
| 624 | private void process( final TextEditor editor ) { | |
| 625 | // Ensure that these are run from within the Swing event dispatch thread | |
| 626 | // so that the text editor thread is immediately freed for caret movement. | |
| 627 | // This means that the preview will have a slight delay when catching up | |
| 628 | // to the caret position. | |
| 629 | invokeLater( () -> { | |
| 630 | mProcessors.getOrDefault( editor, IdentityProcessor.IDENTITY ) | |
| 631 | .apply( editor == null ? "" : editor.getText() ); | |
| 632 | mHtmlPreview.scrollTo( CARET_ID ); | |
| 633 | } ); | |
| 634 | } | |
| 635 | ||
| 636 | /** | |
| 637 | * Lazily creates a {@link DetachableTabPane} configured to handle focus | |
| 638 | * requests by delegating to the selected tab's content. The tab pane is | |
| 639 | * associated with a given media type so that similar files can be grouped | |
| 640 | * together. | |
| 641 | * | |
| 642 | * @param mediaType The media type to associate with the tab pane. | |
| 643 | * @return An instance of {@link DetachableTabPane} that will handle | |
| 644 | * docking of tabs. | |
| 645 | */ | |
| 646 | private DetachableTabPane obtainDetachableTabPane( | |
| 647 | final MediaType mediaType ) { | |
| 648 | return mTabPanes.computeIfAbsent( | |
| 649 | mediaType, ( mt ) -> createDetachableTabPane() | |
| 650 | ); | |
| 651 | } | |
| 652 | ||
| 653 | /** | |
| 654 | * Creates an initialized {@link DetachableTabPane} instance. | |
| 655 | * | |
| 656 | * @return A new {@link DetachableTabPane} with all listeners configured. | |
| 657 | */ | |
| 658 | private DetachableTabPane createDetachableTabPane() { | |
| 659 | final var tabPane = new DetachableTabPane(); | |
| 660 | ||
| 661 | initStageOwnerFactory( tabPane ); | |
| 662 | initTabListener( tabPane ); | |
| 663 | initSelectionModelListener( tabPane ); | |
| 664 | ||
| 665 | return tabPane; | |
| 666 | } | |
| 667 | ||
| 668 | /** | |
| 669 | * When any {@link DetachableTabPane} is detached from the main window, | |
| 670 | * the stage owner factory must be given its parent window, which will | |
| 671 | * own the child window. The parent window is the {@link MainPane}'s | |
| 672 | * {@link Scene}'s {@link Window} instance. | |
| 673 | * | |
| 674 | * <p> | |
| 675 | * This will derives the new title from the main window title, incrementing | |
| 676 | * the window count to help uniquely identify the child windows. | |
| 677 | * </p> | |
| 678 | * | |
| 679 | * @param tabPane A new {@link DetachableTabPane} to configure. | |
| 680 | */ | |
| 681 | private void initStageOwnerFactory( final DetachableTabPane tabPane ) { | |
| 682 | tabPane.setStageOwnerFactory( ( stage ) -> { | |
| 683 | final var title = get( | |
| 684 | "Detach.tab.title", | |
| 685 | ((Stage) getWindow()).getTitle(), ++mWindowCount | |
| 686 | ); | |
| 687 | stage.setTitle( title ); | |
| 688 | return getScene().getWindow(); | |
| 689 | } ); | |
| 690 | } | |
| 691 | ||
| 692 | /** | |
| 693 | * Responsible for configuring the content of each {@link DetachableTab} when | |
| 694 | * it is added to the given {@link DetachableTabPane} instance. | |
| 695 | * <p> | |
| 696 | * For {@link TextEditor} contents, an instance of {@link ScrollEventHandler} | |
| 697 | * is initialized to perform synchronized scrolling between the editor and | |
| 698 | * its preview window. Additionally, the last tab in the tab pane's list of | |
| 699 | * tabs is given focus. | |
| 700 | * </p> | |
| 701 | * <p> | |
| 702 | * Note that multiple tabs can be added simultaneously. | |
| 703 | * </p> | |
| 704 | * | |
| 705 | * @param tabPane A new {@link DetachableTabPane} to configure. | |
| 706 | */ | |
| 707 | private void initTabListener( final DetachableTabPane tabPane ) { | |
| 708 | tabPane.getTabs().addListener( | |
| 709 | ( final ListChangeListener.Change<? extends Tab> listener ) -> { | |
| 710 | while( listener.next() ) { | |
| 711 | if( listener.wasAdded() ) { | |
| 712 | final var tabs = listener.getAddedSubList(); | |
| 713 | ||
| 714 | tabs.forEach( ( tab ) -> { | |
| 715 | final var node = tab.getContent(); | |
| 716 | ||
| 717 | if( node instanceof TextEditor ) { | |
| 718 | initScrollEventListener( tab ); | |
| 719 | } | |
| 720 | } ); | |
| 721 | ||
| 722 | // Select and give focus to the last tab opened. | |
| 723 | final var index = tabs.size() - 1; | |
| 724 | if( index >= 0 ) { | |
| 725 | final var tab = tabs.get( index ); | |
| 726 | tabPane.getSelectionModel().select( tab ); | |
| 727 | tab.getContent().requestFocus(); | |
| 728 | } | |
| 729 | } | |
| 730 | } | |
| 731 | } | |
| 732 | ); | |
| 733 | } | |
| 734 | ||
| 735 | /** | |
| 736 | * Responsible for handling tab change events. | |
| 737 | * | |
| 738 | * @param tabPane A new {@link DetachableTabPane} to configure. | |
| 739 | */ | |
| 740 | private void initSelectionModelListener( final DetachableTabPane tabPane ) { | |
| 741 | final var model = tabPane.getSelectionModel(); | |
| 742 | ||
| 743 | model.selectedItemProperty().addListener( ( c, o, n ) -> { | |
| 744 | if( o != null && n == null ) { | |
| 745 | final var node = o.getContent(); | |
| 746 | ||
| 747 | // If the last definition editor in the active pane was closed, | |
| 748 | // clear out the definitions then refresh the text editor. | |
| 749 | if( node instanceof TextDefinition ) { | |
| 750 | mActiveDefinitionEditor.set( createDefinitionEditor() ); | |
| 751 | } | |
| 752 | } | |
| 753 | else if( n != null ) { | |
| 754 | final var node = n.getContent(); | |
| 755 | ||
| 756 | if( node instanceof TextEditor ) { | |
| 757 | // Changing the active node will fire an event, which will | |
| 758 | // update the preview panel and grab focus. | |
| 759 | mActiveTextEditor.set( (TextEditor) node ); | |
| 760 | runLater( node::requestFocus ); | |
| 761 | } | |
| 762 | else if( node instanceof TextDefinition ) { | |
| 763 | mActiveDefinitionEditor.set( (DefinitionEditor) node ); | |
| 764 | } | |
| 765 | } | |
| 766 | } ); | |
| 767 | } | |
| 768 | ||
| 769 | /** | |
| 770 | * Synchronizes scrollbar positions between the given {@link Tab} that | |
| 771 | * contains an instance of {@link TextEditor} and {@link HtmlPreview} pane. | |
| 772 | * | |
| 773 | * @param tab The container for an instance of {@link TextEditor}. | |
| 774 | */ | |
| 775 | private void initScrollEventListener( final Tab tab ) { | |
| 776 | final var editor = (TextEditor) tab.getContent(); | |
| 777 | final var scrollPane = editor.getScrollPane(); | |
| 778 | final var scrollBar = mHtmlPreview.getVerticalScrollBar(); | |
| 779 | final var handler = new ScrollEventHandler( scrollPane, scrollBar ); | |
| 780 | handler.enabledProperty().bind( tab.selectedProperty() ); | |
| 781 | } | |
| 782 | ||
| 783 | private void addTabPane( final int index, final DetachableTabPane tabPane ) { | |
| 784 | final var items = getItems(); | |
| 785 | if( !items.contains( tabPane ) ) { | |
| 786 | items.add( index, tabPane ); | |
| 787 | } | |
| 788 | } | |
| 789 | ||
| 790 | private void addTabPane( final DetachableTabPane tabPane ) { | |
| 8 | import com.keenwrite.editors.definition.TreeTransformer; | |
| 9 | import com.keenwrite.editors.definition.yaml.YamlTreeTransformer; | |
| 10 | import com.keenwrite.editors.markdown.MarkdownEditor; | |
| 11 | import com.keenwrite.events.CaretNavigationEvent; | |
| 12 | import com.keenwrite.events.FileOpenEvent; | |
| 13 | import com.keenwrite.events.TextDefinitionFocusEvent; | |
| 14 | import com.keenwrite.events.TextEditorFocusEvent; | |
| 15 | import com.keenwrite.io.MediaType; | |
| 16 | import com.keenwrite.outline.DocumentOutline; | |
| 17 | import com.keenwrite.preferences.Key; | |
| 18 | import com.keenwrite.preferences.Workspace; | |
| 19 | import com.keenwrite.preview.HtmlPanel; | |
| 20 | import com.keenwrite.preview.HtmlPreview; | |
| 21 | import com.keenwrite.processors.Processor; | |
| 22 | import com.keenwrite.processors.ProcessorContext; | |
| 23 | import com.keenwrite.processors.ProcessorFactory; | |
| 24 | import com.keenwrite.processors.markdown.extensions.CaretExtension; | |
| 25 | import com.keenwrite.service.events.Notifier; | |
| 26 | import com.keenwrite.sigils.RSigilOperator; | |
| 27 | import com.keenwrite.sigils.SigilOperator; | |
| 28 | import com.keenwrite.sigils.Tokens; | |
| 29 | import com.keenwrite.sigils.YamlSigilOperator; | |
| 30 | import com.panemu.tiwulfx.control.dock.DetachableTab; | |
| 31 | import com.panemu.tiwulfx.control.dock.DetachableTabPane; | |
| 32 | import javafx.application.Platform; | |
| 33 | import javafx.beans.property.*; | |
| 34 | import javafx.collections.ListChangeListener; | |
| 35 | import javafx.event.ActionEvent; | |
| 36 | import javafx.event.Event; | |
| 37 | import javafx.event.EventHandler; | |
| 38 | import javafx.scene.Node; | |
| 39 | import javafx.scene.Scene; | |
| 40 | import javafx.scene.control.SplitPane; | |
| 41 | import javafx.scene.control.Tab; | |
| 42 | import javafx.scene.control.TabPane; | |
| 43 | import javafx.scene.control.Tooltip; | |
| 44 | import javafx.scene.control.TreeItem.TreeModificationEvent; | |
| 45 | import javafx.scene.input.KeyEvent; | |
| 46 | import javafx.stage.Stage; | |
| 47 | import javafx.stage.Window; | |
| 48 | import org.greenrobot.eventbus.Subscribe; | |
| 49 | ||
| 50 | import java.io.File; | |
| 51 | import java.io.FileNotFoundException; | |
| 52 | import java.nio.file.Path; | |
| 53 | import java.util.*; | |
| 54 | import java.util.concurrent.atomic.AtomicBoolean; | |
| 55 | import java.util.function.Function; | |
| 56 | import java.util.stream.Collectors; | |
| 57 | ||
| 58 | import static com.keenwrite.Constants.*; | |
| 59 | import static com.keenwrite.ExportFormat.NONE; | |
| 60 | import static com.keenwrite.Messages.get; | |
| 61 | import static com.keenwrite.events.Bus.register; | |
| 62 | import static com.keenwrite.events.StatusEvent.clue; | |
| 63 | import static com.keenwrite.io.MediaType.*; | |
| 64 | import static com.keenwrite.preferences.WorkspaceKeys.*; | |
| 65 | import static com.keenwrite.processors.IdentityProcessor.IDENTITY; | |
| 66 | import static com.keenwrite.processors.ProcessorFactory.createProcessors; | |
| 67 | import static java.util.stream.Collectors.groupingBy; | |
| 68 | import static javafx.application.Platform.runLater; | |
| 69 | import static javafx.scene.control.ButtonType.NO; | |
| 70 | import static javafx.scene.control.ButtonType.YES; | |
| 71 | import static javafx.scene.control.TabPane.TabClosingPolicy.ALL_TABS; | |
| 72 | import static javafx.scene.input.KeyCode.SPACE; | |
| 73 | import static javafx.scene.input.KeyCombination.CONTROL_DOWN; | |
| 74 | import static javafx.util.Duration.millis; | |
| 75 | import static javax.swing.SwingUtilities.invokeLater; | |
| 76 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 77 | ||
| 78 | /** | |
| 79 | * Responsible for wiring together the main application components for a | |
| 80 | * particular workspace (project). These include the definition views, | |
| 81 | * text editors, and preview pane along with any corresponding controllers. | |
| 82 | */ | |
| 83 | public final class MainPane extends SplitPane { | |
| 84 | private static final Notifier sNotifier = Services.load( Notifier.class ); | |
| 85 | ||
| 86 | /** | |
| 87 | * Used when opening files to determine how each file should be binned and | |
| 88 | * therefore what tab pane to be opened within. | |
| 89 | */ | |
| 90 | private static final Set<MediaType> PLAIN_TEXT_FORMAT = Set.of( | |
| 91 | TEXT_MARKDOWN, TEXT_R_MARKDOWN, TEXT_R_XML, UNDEFINED | |
| 92 | ); | |
| 93 | ||
| 94 | /** | |
| 95 | * Prevents re-instantiation of processing classes. | |
| 96 | */ | |
| 97 | private final Map<TextResource, Processor<String>> mProcessors = | |
| 98 | new HashMap<>(); | |
| 99 | ||
| 100 | private final Workspace mWorkspace; | |
| 101 | ||
| 102 | /** | |
| 103 | * Groups similar file type tabs together. | |
| 104 | */ | |
| 105 | private final Map<MediaType, TabPane> mTabPanes = new HashMap<>(); | |
| 106 | ||
| 107 | /** | |
| 108 | * Stores definition names and values. | |
| 109 | */ | |
| 110 | private final Map<String, String> mResolvedMap = | |
| 111 | new HashMap<>( MAP_SIZE_DEFAULT ); | |
| 112 | ||
| 113 | /** | |
| 114 | * Renders the actively selected plain text editor tab. | |
| 115 | */ | |
| 116 | private final HtmlPreview mHtmlPreview; | |
| 117 | ||
| 118 | /** | |
| 119 | * Provides an interactive document outline. | |
| 120 | */ | |
| 121 | private final DocumentOutline mDocumentOutline = new DocumentOutline(); | |
| 122 | ||
| 123 | /** | |
| 124 | * Changing the active editor fires the value changed event. This allows | |
| 125 | * refreshes to happen when external definitions are modified and need to | |
| 126 | * trigger the processing chain. | |
| 127 | */ | |
| 128 | private final ObjectProperty<TextEditor> mActiveTextEditor = | |
| 129 | createActiveTextEditor(); | |
| 130 | ||
| 131 | /** | |
| 132 | * Changing the active definition editor fires the value changed event. This | |
| 133 | * allows refreshes to happen when external definitions are modified and need | |
| 134 | * to trigger the processing chain. | |
| 135 | */ | |
| 136 | private final ObjectProperty<TextDefinition> mActiveDefinitionEditor = | |
| 137 | createActiveDefinitionEditor( mActiveTextEditor ); | |
| 138 | ||
| 139 | /** | |
| 140 | * Tracks the number of detached tab panels opened into their own windows, | |
| 141 | * which allows unique identification of subordinate windows by their title. | |
| 142 | * It is doubtful more than 128 windows, much less 256, will be created. | |
| 143 | */ | |
| 144 | private byte mWindowCount; | |
| 145 | ||
| 146 | /** | |
| 147 | * Called when the definition data is changed. | |
| 148 | */ | |
| 149 | private final EventHandler<TreeModificationEvent<Event>> mTreeHandler = | |
| 150 | event -> { | |
| 151 | final var editor = mActiveDefinitionEditor.get(); | |
| 152 | ||
| 153 | resolve( editor ); | |
| 154 | process( getActiveTextEditor() ); | |
| 155 | save( editor ); | |
| 156 | }; | |
| 157 | ||
| 158 | /** | |
| 159 | * Adds all content panels to the main user interface. This will load the | |
| 160 | * configuration settings from the workspace to reproduce the settings from | |
| 161 | * a previous session. | |
| 162 | */ | |
| 163 | public MainPane( final Workspace workspace ) { | |
| 164 | mWorkspace = workspace; | |
| 165 | mHtmlPreview = new HtmlPreview( workspace ); | |
| 166 | ||
| 167 | open( bin( getRecentFiles() ) ); | |
| 168 | viewPreview(); | |
| 169 | setDividerPositions( calculateDividerPositions() ); | |
| 170 | ||
| 171 | // Once the main scene's window regains focus, update the active definition | |
| 172 | // editor to the currently selected tab. | |
| 173 | runLater( | |
| 174 | () -> getWindow().setOnCloseRequest( ( event ) -> { | |
| 175 | // Order matters here. We want to close all the tabs to ensure each | |
| 176 | // is saved, but after they are closed, the workspace should still | |
| 177 | // retain the list of files that were open. If this line came after | |
| 178 | // closing, then restarting the application would list no files. | |
| 179 | mWorkspace.save(); | |
| 180 | ||
| 181 | if( closeAll() ) { | |
| 182 | Platform.exit(); | |
| 183 | System.exit( 0 ); | |
| 184 | } | |
| 185 | else { | |
| 186 | event.consume(); | |
| 187 | } | |
| 188 | } ) | |
| 189 | ); | |
| 190 | ||
| 191 | register( this ); | |
| 192 | } | |
| 193 | ||
| 194 | @Subscribe | |
| 195 | public void handle( final TextEditorFocusEvent event ) { | |
| 196 | mActiveTextEditor.set( event.get() ); | |
| 197 | } | |
| 198 | ||
| 199 | @Subscribe | |
| 200 | public void handle( final TextDefinitionFocusEvent event ) { | |
| 201 | mActiveDefinitionEditor.set( event.get() ); | |
| 202 | } | |
| 203 | ||
| 204 | /** | |
| 205 | * Typically called when a file name is clicked in the {@link HtmlPanel}. | |
| 206 | * | |
| 207 | * @param event The event to process, must contain a valid file reference. | |
| 208 | */ | |
| 209 | @Subscribe | |
| 210 | public void handle( final FileOpenEvent event ) { | |
| 211 | final File eventFile; | |
| 212 | final var eventUri = event.getUri(); | |
| 213 | ||
| 214 | if( eventUri.isAbsolute() ) { | |
| 215 | eventFile = new File( eventUri.getPath() ); | |
| 216 | } | |
| 217 | else { | |
| 218 | final var activeFile = getActiveTextEditor().getFile(); | |
| 219 | final var parent = activeFile.getParentFile(); | |
| 220 | ||
| 221 | if( parent == null ) { | |
| 222 | clue( new FileNotFoundException( eventUri.getPath() ) ); | |
| 223 | return; | |
| 224 | } | |
| 225 | else { | |
| 226 | final var parentPath = parent.getAbsolutePath(); | |
| 227 | eventFile = Path.of( parentPath, eventUri.getPath() ).toFile(); | |
| 228 | } | |
| 229 | } | |
| 230 | ||
| 231 | runLater( () -> open( eventFile ) ); | |
| 232 | } | |
| 233 | ||
| 234 | @Subscribe | |
| 235 | public void handle( final CaretNavigationEvent event ) { | |
| 236 | runLater( () -> { | |
| 237 | final var textArea = getActiveTextEditor().getTextArea(); | |
| 238 | textArea.moveTo( event.getOffset() ); | |
| 239 | textArea.requestFollowCaret(); | |
| 240 | textArea.requestFocus(); | |
| 241 | } ); | |
| 242 | } | |
| 243 | ||
| 244 | /** | |
| 245 | * TODO: Load divider positions from exported settings, see bin() comment. | |
| 246 | */ | |
| 247 | private double[] calculateDividerPositions() { | |
| 248 | final var ratio = 100f / getItems().size() / 100; | |
| 249 | final var positions = getDividerPositions(); | |
| 250 | ||
| 251 | for( int i = 0; i < positions.length; i++ ) { | |
| 252 | positions[ i ] = ratio * i; | |
| 253 | } | |
| 254 | ||
| 255 | return positions; | |
| 256 | } | |
| 257 | ||
| 258 | /** | |
| 259 | * Opens all the files into the application, provided the paths are unique. | |
| 260 | * This may only be called for any type of files that a user can edit | |
| 261 | * (i.e., update and persist), such as definitions and text files. | |
| 262 | * | |
| 263 | * @param files The list of files to open. | |
| 264 | */ | |
| 265 | public void open( final List<File> files ) { | |
| 266 | files.forEach( this::open ); | |
| 267 | } | |
| 268 | ||
| 269 | /** | |
| 270 | * This opens the given file. Since the preview pane is not a file that | |
| 271 | * can be opened, it is safe to add a listener to the detachable pane. | |
| 272 | * | |
| 273 | * @param file The file to open. | |
| 274 | */ | |
| 275 | private void open( final File file ) { | |
| 276 | final var tab = createTab( file ); | |
| 277 | final var node = tab.getContent(); | |
| 278 | final var mediaType = MediaType.valueFrom( file ); | |
| 279 | final var tabPane = obtainTabPane( mediaType ); | |
| 280 | ||
| 281 | tab.setTooltip( createTooltip( file ) ); | |
| 282 | tabPane.setFocusTraversable( false ); | |
| 283 | tabPane.setTabClosingPolicy( ALL_TABS ); | |
| 284 | tabPane.getTabs().add( tab ); | |
| 285 | ||
| 286 | // Attach the tab scene factory for new tab panes. | |
| 287 | if( !getItems().contains( tabPane ) ) { | |
| 288 | addTabPane( | |
| 289 | node instanceof TextDefinition ? 0 : getItems().size(), tabPane | |
| 290 | ); | |
| 291 | } | |
| 292 | ||
| 293 | getRecentFiles().add( file.getAbsolutePath() ); | |
| 294 | } | |
| 295 | ||
| 296 | /** | |
| 297 | * Opens a new text editor document using the default document file name. | |
| 298 | */ | |
| 299 | public void newTextEditor() { | |
| 300 | open( DOCUMENT_DEFAULT ); | |
| 301 | } | |
| 302 | ||
| 303 | /** | |
| 304 | * Opens a new definition editor document using the default definition | |
| 305 | * file name. | |
| 306 | */ | |
| 307 | public void newDefinitionEditor() { | |
| 308 | open( DEFINITION_DEFAULT ); | |
| 309 | } | |
| 310 | ||
| 311 | /** | |
| 312 | * Iterates over all tab panes to find all {@link TextEditor}s and request | |
| 313 | * that they save themselves. | |
| 314 | */ | |
| 315 | public void saveAll() { | |
| 316 | mTabPanes.forEach( | |
| 317 | ( mt, tp ) -> tp.getTabs().forEach( ( tab ) -> { | |
| 318 | final var node = tab.getContent(); | |
| 319 | if( node instanceof TextEditor ) { | |
| 320 | save( ((TextEditor) node) ); | |
| 321 | } | |
| 322 | } ) | |
| 323 | ); | |
| 324 | } | |
| 325 | ||
| 326 | /** | |
| 327 | * Requests that the active {@link TextEditor} saves itself. Don't bother | |
| 328 | * checking if modified first because if the user swaps external media from | |
| 329 | * an external source (e.g., USB thumb drive), save should not second-guess | |
| 330 | * the user: save always re-saves. Also, it's less code. | |
| 331 | */ | |
| 332 | public void save() { | |
| 333 | save( getActiveTextEditor() ); | |
| 334 | } | |
| 335 | ||
| 336 | /** | |
| 337 | * Saves the active {@link TextEditor} under a new name. | |
| 338 | * | |
| 339 | * @param file The new active editor {@link File} reference. | |
| 340 | */ | |
| 341 | public void saveAs( final File file ) { | |
| 342 | assert file != null; | |
| 343 | final var editor = getActiveTextEditor(); | |
| 344 | final var tab = getTab( editor ); | |
| 345 | ||
| 346 | editor.rename( file ); | |
| 347 | tab.ifPresent( t -> { | |
| 348 | t.setText( editor.getFilename() ); | |
| 349 | t.setTooltip( createTooltip( file ) ); | |
| 350 | } ); | |
| 351 | ||
| 352 | save(); | |
| 353 | } | |
| 354 | ||
| 355 | /** | |
| 356 | * Saves the given {@link TextResource} to a file. This is typically used | |
| 357 | * to save either an instance of {@link TextEditor} or {@link TextDefinition}. | |
| 358 | * | |
| 359 | * @param resource The resource to export. | |
| 360 | */ | |
| 361 | private void save( final TextResource resource ) { | |
| 362 | try { | |
| 363 | resource.save(); | |
| 364 | } catch( final Exception ex ) { | |
| 365 | clue( ex ); | |
| 366 | sNotifier.alert( | |
| 367 | getWindow(), resource.getPath(), "TextResource.saveFailed", ex | |
| 368 | ); | |
| 369 | } | |
| 370 | } | |
| 371 | ||
| 372 | /** | |
| 373 | * Closes all open {@link TextEditor}s; all {@link TextDefinition}s stay open. | |
| 374 | * | |
| 375 | * @return {@code true} when all editors, modified or otherwise, were | |
| 376 | * permitted to close; {@code false} when one or more editors were modified | |
| 377 | * and the user requested no closing. | |
| 378 | */ | |
| 379 | public boolean closeAll() { | |
| 380 | var closable = true; | |
| 381 | ||
| 382 | for( final var entry : mTabPanes.entrySet() ) { | |
| 383 | final var tabPane = entry.getValue(); | |
| 384 | final var tabIterator = tabPane.getTabs().iterator(); | |
| 385 | ||
| 386 | while( tabIterator.hasNext() ) { | |
| 387 | final var tab = tabIterator.next(); | |
| 388 | final var resource = tab.getContent(); | |
| 389 | ||
| 390 | if( !(resource instanceof TextResource) ) { | |
| 391 | continue; | |
| 392 | } | |
| 393 | ||
| 394 | if( canClose( (TextResource) resource ) ) { | |
| 395 | tabIterator.remove(); | |
| 396 | close( tab ); | |
| 397 | } | |
| 398 | else { | |
| 399 | closable = false; | |
| 400 | } | |
| 401 | } | |
| 402 | } | |
| 403 | ||
| 404 | return closable; | |
| 405 | } | |
| 406 | ||
| 407 | /** | |
| 408 | * Calls the tab's {@link Tab#getOnClosed()} handler to carry out a close | |
| 409 | * event. | |
| 410 | * | |
| 411 | * @param tab The {@link Tab} that was closed. | |
| 412 | */ | |
| 413 | private void close( final Tab tab ) { | |
| 414 | final var handler = tab.getOnClosed(); | |
| 415 | ||
| 416 | if( handler != null ) { | |
| 417 | handler.handle( new ActionEvent() ); | |
| 418 | } | |
| 419 | } | |
| 420 | ||
| 421 | /** | |
| 422 | * Closes the active tab; delegates to {@link #canClose(TextResource)}. | |
| 423 | */ | |
| 424 | public void close() { | |
| 425 | final var editor = getActiveTextEditor(); | |
| 426 | if( canClose( editor ) ) { | |
| 427 | close( editor ); | |
| 428 | } | |
| 429 | } | |
| 430 | ||
| 431 | /** | |
| 432 | * Closes the given {@link TextResource}. This must not be called from within | |
| 433 | * a loop that iterates over the tab panes using {@code forEach}, lest a | |
| 434 | * concurrent modification exception be thrown. | |
| 435 | * | |
| 436 | * @param resource The {@link TextResource} to close, without confirming with | |
| 437 | * the user. | |
| 438 | */ | |
| 439 | private void close( final TextResource resource ) { | |
| 440 | getTab( resource ).ifPresent( | |
| 441 | ( tab ) -> { | |
| 442 | tab.getTabPane().getTabs().remove( tab ); | |
| 443 | close( tab ); | |
| 444 | } | |
| 445 | ); | |
| 446 | } | |
| 447 | ||
| 448 | /** | |
| 449 | * Answers whether the given {@link TextResource} may be closed. | |
| 450 | * | |
| 451 | * @param editor The {@link TextResource} to try closing. | |
| 452 | * @return {@code true} when the editor may be closed; {@code false} when | |
| 453 | * the user has requested to keep the editor open. | |
| 454 | */ | |
| 455 | private boolean canClose( final TextResource editor ) { | |
| 456 | final var editorTab = getTab( editor ); | |
| 457 | final var canClose = new AtomicBoolean( true ); | |
| 458 | ||
| 459 | if( editor.isModified() ) { | |
| 460 | final var filename = new StringBuilder(); | |
| 461 | editorTab.ifPresent( ( tab ) -> filename.append( tab.getText() ) ); | |
| 462 | ||
| 463 | final var message = sNotifier.createNotification( | |
| 464 | Messages.get( "Alert.file.close.title" ), | |
| 465 | Messages.get( "Alert.file.close.text" ), | |
| 466 | filename.toString() | |
| 467 | ); | |
| 468 | ||
| 469 | final var dialog = sNotifier.createConfirmation( getWindow(), message ); | |
| 470 | ||
| 471 | dialog.showAndWait().ifPresent( | |
| 472 | save -> canClose.set( save == YES ? editor.save() : save == NO ) | |
| 473 | ); | |
| 474 | } | |
| 475 | ||
| 476 | return canClose.get(); | |
| 477 | } | |
| 478 | ||
| 479 | private ObjectProperty<TextEditor> createActiveTextEditor() { | |
| 480 | final var editor = new SimpleObjectProperty<TextEditor>(); | |
| 481 | ||
| 482 | editor.addListener( ( c, o, n ) -> { | |
| 483 | if( n != null ) { | |
| 484 | mHtmlPreview.setBaseUri( n.getPath() ); | |
| 485 | process( n ); | |
| 486 | } | |
| 487 | } ); | |
| 488 | ||
| 489 | return editor; | |
| 490 | } | |
| 491 | ||
| 492 | /** | |
| 493 | * Adds the HTML preview tab to its own, singular tab pane. | |
| 494 | */ | |
| 495 | public void viewPreview() { | |
| 496 | viewTab( mHtmlPreview, TEXT_HTML, "HTML" ); | |
| 497 | } | |
| 498 | ||
| 499 | /** | |
| 500 | * Adds the document outline tab to its own, singular tab pane. | |
| 501 | */ | |
| 502 | public void viewOutline() { | |
| 503 | viewTab( mDocumentOutline, APP_DOCUMENT_OUTLINE, "Outline" ); | |
| 504 | } | |
| 505 | ||
| 506 | private void viewTab( | |
| 507 | final Node node, final MediaType mediaType, final String name ) { | |
| 508 | final var tabPane = obtainTabPane( mediaType ); | |
| 509 | ||
| 510 | for( final var tab : tabPane.getTabs() ) { | |
| 511 | if( tab.getContent() == node ) { | |
| 512 | return; | |
| 513 | } | |
| 514 | } | |
| 515 | ||
| 516 | tabPane.getTabs().add( createTab( name, node ) ); | |
| 517 | addTabPane( tabPane ); | |
| 518 | } | |
| 519 | ||
| 520 | public void viewRefresh() { | |
| 521 | mHtmlPreview.refresh(); | |
| 522 | } | |
| 523 | ||
| 524 | /** | |
| 525 | * Returns the tab that contains the given {@link TextEditor}. | |
| 526 | * | |
| 527 | * @param editor The {@link TextEditor} instance to find amongst the tabs. | |
| 528 | * @return The first tab having content that matches the given tab. | |
| 529 | */ | |
| 530 | private Optional<Tab> getTab( final TextResource editor ) { | |
| 531 | return mTabPanes.values() | |
| 532 | .stream() | |
| 533 | .flatMap( pane -> pane.getTabs().stream() ) | |
| 534 | .filter( tab -> editor.equals( tab.getContent() ) ) | |
| 535 | .findFirst(); | |
| 536 | } | |
| 537 | ||
| 538 | /** | |
| 539 | * Creates a new {@link DefinitionEditor} wrapped in a listener that | |
| 540 | * is used to detect when the active {@link DefinitionEditor} has changed. | |
| 541 | * Upon changing, the {@link #mResolvedMap} is updated and the active | |
| 542 | * text editor is refreshed. | |
| 543 | * | |
| 544 | * @param editor Text editor to update with the revised resolved map. | |
| 545 | * @return A newly configured property that represents the active | |
| 546 | * {@link DefinitionEditor}, never null. | |
| 547 | */ | |
| 548 | private ObjectProperty<TextDefinition> createActiveDefinitionEditor( | |
| 549 | final ObjectProperty<TextEditor> editor ) { | |
| 550 | final var definitions = new SimpleObjectProperty<TextDefinition>(); | |
| 551 | definitions.addListener( ( c, o, n ) -> { | |
| 552 | resolve( n == null ? createDefinitionEditor() : n ); | |
| 553 | process( editor.get() ); | |
| 554 | } ); | |
| 555 | ||
| 556 | return definitions; | |
| 557 | } | |
| 558 | ||
| 559 | private Tab createTab( final String filename, final Node node ) { | |
| 560 | return new DetachableTab( filename, node ); | |
| 561 | } | |
| 562 | ||
| 563 | private Tab createTab( final File file ) { | |
| 564 | final var r = createTextResource( file ); | |
| 565 | final var tab = createTab( r.getFilename(), r.getNode() ); | |
| 566 | ||
| 567 | r.modifiedProperty().addListener( | |
| 568 | ( c, o, n ) -> tab.setText( r.getFilename() + (n ? "*" : "") ) | |
| 569 | ); | |
| 570 | ||
| 571 | // This is called when either the tab is closed by the user clicking on | |
| 572 | // the tab's close icon or when closing (all) from the file menu. | |
| 573 | tab.setOnClosed( | |
| 574 | ( __ ) -> getRecentFiles().remove( file.getAbsolutePath() ) | |
| 575 | ); | |
| 576 | ||
| 577 | tab.tabPaneProperty().addListener( ( cPane, oPane, nPane ) -> { | |
| 578 | if( nPane != null ) { | |
| 579 | nPane.focusedProperty().addListener( ( c, o, n ) -> { | |
| 580 | if( n != null && n ) { | |
| 581 | final var selected = nPane.getSelectionModel().getSelectedItem(); | |
| 582 | final var node = selected.getContent(); | |
| 583 | node.requestFocus(); | |
| 584 | } | |
| 585 | } ); | |
| 586 | } | |
| 587 | } ); | |
| 588 | ||
| 589 | return tab; | |
| 590 | } | |
| 591 | ||
| 592 | /** | |
| 593 | * Creates bins for the different {@link MediaType}s, which eventually are | |
| 594 | * added to the UI as separate tab panes. If ever a general-purpose scene | |
| 595 | * exporter is developed to serialize a scene to an FXML file, this could | |
| 596 | * be replaced by such a class. | |
| 597 | * <p> | |
| 598 | * When binning the files, this makes sure that at least one file exists | |
| 599 | * for every type. If the user has opted to close a particular type (such | |
| 600 | * as the definition pane), the view will suppressed elsewhere. | |
| 601 | * </p> | |
| 602 | * <p> | |
| 603 | * The order that the binned files are returned will be reflected in the | |
| 604 | * order that the corresponding panes are rendered in the UI. | |
| 605 | * </p> | |
| 606 | * | |
| 607 | * @param paths The file paths to bin according to their type. | |
| 608 | * @return An in-order list of files, first by structured definition files, | |
| 609 | * then by plain text documents. | |
| 610 | */ | |
| 611 | private List<File> bin( final SetProperty<String> paths ) { | |
| 612 | // Treat all files destined for the text editor as plain text documents | |
| 613 | // so that they are added to the same pane. Grouping by TEXT_PLAIN is a | |
| 614 | // bit arbitrary, but means explicitly capturing TEXT_PLAIN isn't needed. | |
| 615 | final Function<MediaType, MediaType> bin = | |
| 616 | m -> PLAIN_TEXT_FORMAT.contains( m ) ? TEXT_PLAIN : m; | |
| 617 | ||
| 618 | // Create two groups: YAML files and plain text files. | |
| 619 | final var bins = paths | |
| 620 | .stream() | |
| 621 | .collect( | |
| 622 | groupingBy( path -> bin.apply( MediaType.valueFrom( path ) ) ) | |
| 623 | ); | |
| 624 | ||
| 625 | bins.putIfAbsent( TEXT_YAML, List.of( DEFINITION_DEFAULT.toString() ) ); | |
| 626 | bins.putIfAbsent( TEXT_PLAIN, List.of( DOCUMENT_DEFAULT.toString() ) ); | |
| 627 | ||
| 628 | final var result = new ArrayList<File>( paths.size() ); | |
| 629 | ||
| 630 | // Ensure that the same types are listed together (keep insertion order). | |
| 631 | bins.forEach( ( mediaType, files ) -> result.addAll( | |
| 632 | files.stream().map( File::new ).collect( Collectors.toList() ) ) | |
| 633 | ); | |
| 634 | ||
| 635 | return result; | |
| 636 | } | |
| 637 | ||
| 638 | /** | |
| 639 | * Uses the given {@link TextDefinition} instance to update the | |
| 640 | * {@link #mResolvedMap}. | |
| 641 | * | |
| 642 | * @param editor A non-null, possibly empty definition editor. | |
| 643 | */ | |
| 644 | private void resolve( final TextDefinition editor ) { | |
| 645 | assert editor != null; | |
| 646 | ||
| 647 | final var tokens = createDefinitionTokens(); | |
| 648 | final var operator = new YamlSigilOperator( tokens ); | |
| 649 | final var map = new HashMap<String, String>(); | |
| 650 | ||
| 651 | editor.toMap().forEach( ( k, v ) -> map.put( operator.entoken( k ), v ) ); | |
| 652 | ||
| 653 | mResolvedMap.clear(); | |
| 654 | mResolvedMap.putAll( editor.interpolate( map, tokens ) ); | |
| 655 | } | |
| 656 | ||
| 657 | /** | |
| 658 | * Force the active editor to update, which will cause the processor | |
| 659 | * to re-evaluate the interpolated definition map thereby updating the | |
| 660 | * preview pane. | |
| 661 | * | |
| 662 | * @param editor Contains the source document to update in the preview pane. | |
| 663 | */ | |
| 664 | private void process( final TextEditor editor ) { | |
| 665 | // Ensure that these are run from within the Swing event dispatch thread | |
| 666 | // so that the text editor thread is immediately freed for caret movement. | |
| 667 | // This means that the preview will have a slight delay when catching up | |
| 668 | // to the caret position. | |
| 669 | invokeLater( () -> { | |
| 670 | final var processor = mProcessors.getOrDefault( editor, IDENTITY ); | |
| 671 | processor.apply( editor == null ? "" : editor.getText() ); | |
| 672 | mHtmlPreview.scrollTo( CARET_ID ); | |
| 673 | } ); | |
| 674 | } | |
| 675 | ||
| 676 | /** | |
| 677 | * Lazily creates a {@link TabPane} configured to listen for tab select | |
| 678 | * events. The tab pane is associated with a given media type so that | |
| 679 | * similar files can be grouped together. | |
| 680 | * | |
| 681 | * @param mediaType The media type to associate with the tab pane. | |
| 682 | * @return An instance of {@link TabPane} that will handle tab docking. | |
| 683 | */ | |
| 684 | private TabPane obtainTabPane( final MediaType mediaType ) { | |
| 685 | return mTabPanes.computeIfAbsent( | |
| 686 | mediaType, ( mt ) -> createTabPane() | |
| 687 | ); | |
| 688 | } | |
| 689 | ||
| 690 | /** | |
| 691 | * Creates an initialized {@link TabPane} instance. | |
| 692 | * | |
| 693 | * @return A new {@link TabPane} with all listeners configured. | |
| 694 | */ | |
| 695 | private TabPane createTabPane() { | |
| 696 | final var tabPane = new DetachableTabPane(); | |
| 697 | ||
| 698 | initStageOwnerFactory( tabPane ); | |
| 699 | initTabListener( tabPane ); | |
| 700 | ||
| 701 | return tabPane; | |
| 702 | } | |
| 703 | ||
| 704 | /** | |
| 705 | * When any {@link DetachableTabPane} is detached from the main window, | |
| 706 | * the stage owner factory must be given its parent window, which will | |
| 707 | * own the child window. The parent window is the {@link MainPane}'s | |
| 708 | * {@link Scene}'s {@link Window} instance. | |
| 709 | * | |
| 710 | * <p> | |
| 711 | * This will derives the new title from the main window title, incrementing | |
| 712 | * the window count to help uniquely identify the child windows. | |
| 713 | * </p> | |
| 714 | * | |
| 715 | * @param tabPane A new {@link DetachableTabPane} to configure. | |
| 716 | */ | |
| 717 | private void initStageOwnerFactory( final DetachableTabPane tabPane ) { | |
| 718 | tabPane.setStageOwnerFactory( ( stage ) -> { | |
| 719 | final var title = get( | |
| 720 | "Detach.tab.title", | |
| 721 | ((Stage) getWindow()).getTitle(), ++mWindowCount | |
| 722 | ); | |
| 723 | stage.setTitle( title ); | |
| 724 | ||
| 725 | return getScene().getWindow(); | |
| 726 | } ); | |
| 727 | } | |
| 728 | ||
| 729 | /** | |
| 730 | * Responsible for configuring the content of each {@link DetachableTab} when | |
| 731 | * it is added to the given {@link DetachableTabPane} instance. | |
| 732 | * <p> | |
| 733 | * For {@link TextEditor} contents, an instance of {@link ScrollEventHandler} | |
| 734 | * is initialized to perform synchronized scrolling between the editor and | |
| 735 | * its preview window. Additionally, the last tab in the tab pane's list of | |
| 736 | * tabs is given focus. | |
| 737 | * </p> | |
| 738 | * <p> | |
| 739 | * Note that multiple tabs can be added simultaneously. | |
| 740 | * </p> | |
| 741 | * | |
| 742 | * @param tabPane A new {@link TabPane} to configure. | |
| 743 | */ | |
| 744 | private void initTabListener( final TabPane tabPane ) { | |
| 745 | tabPane.getTabs().addListener( | |
| 746 | ( final ListChangeListener.Change<? extends Tab> listener ) -> { | |
| 747 | while( listener.next() ) { | |
| 748 | if( listener.wasAdded() ) { | |
| 749 | final var tabs = listener.getAddedSubList(); | |
| 750 | ||
| 751 | tabs.forEach( ( tab ) -> { | |
| 752 | final var node = tab.getContent(); | |
| 753 | ||
| 754 | if( node instanceof TextEditor ) { | |
| 755 | initScrollEventListener( tab ); | |
| 756 | } | |
| 757 | } ); | |
| 758 | ||
| 759 | // Select and give focus to the last tab opened. | |
| 760 | final var index = tabs.size() - 1; | |
| 761 | if( index >= 0 ) { | |
| 762 | final var tab = tabs.get( index ); | |
| 763 | tabPane.getSelectionModel().select( tab ); | |
| 764 | tab.getContent().requestFocus(); | |
| 765 | } | |
| 766 | } | |
| 767 | } | |
| 768 | } | |
| 769 | ); | |
| 770 | } | |
| 771 | ||
| 772 | /** | |
| 773 | * Synchronizes scrollbar positions between the given {@link Tab} that | |
| 774 | * contains an instance of {@link TextEditor} and {@link HtmlPreview} pane. | |
| 775 | * | |
| 776 | * @param tab The container for an instance of {@link TextEditor}. | |
| 777 | */ | |
| 778 | private void initScrollEventListener( final Tab tab ) { | |
| 779 | final var editor = (TextEditor) tab.getContent(); | |
| 780 | final var scrollPane = editor.getScrollPane(); | |
| 781 | final var scrollBar = mHtmlPreview.getVerticalScrollBar(); | |
| 782 | final var handler = new ScrollEventHandler( scrollPane, scrollBar ); | |
| 783 | handler.enabledProperty().bind( tab.selectedProperty() ); | |
| 784 | } | |
| 785 | ||
| 786 | private void addTabPane( final int index, final TabPane tabPane ) { | |
| 787 | final var items = getItems(); | |
| 788 | if( !items.contains( tabPane ) ) { | |
| 789 | items.add( index, tabPane ); | |
| 790 | } | |
| 791 | } | |
| 792 | ||
| 793 | private void addTabPane( final TabPane tabPane ) { | |
| 791 | 794 | addTabPane( getItems().size(), tabPane ); |
| 792 | 795 | } |
| 7 | 7 | import com.keenwrite.ui.actions.ApplicationActions; |
| 8 | 8 | import com.keenwrite.ui.listeners.CaretListener; |
| 9 | import javafx.scene.AccessibleRole; | |
| 10 | 9 | import javafx.scene.Node; |
| 11 | 10 | import javafx.scene.Parent; |
| 12 | 11 | import javafx.scene.Scene; |
| 12 | import javafx.scene.control.MenuBar; | |
| 13 | 13 | import javafx.scene.layout.BorderPane; |
| 14 | 14 | import javafx.scene.layout.VBox; |
| 15 | 15 | import org.controlsfx.control.StatusBar; |
| 16 | 16 | |
| 17 | 17 | import java.io.File; |
| 18 | 18 | |
| 19 | 19 | import static com.keenwrite.Constants.*; |
| 20 | 20 | import static com.keenwrite.Messages.get; |
| 21 | import static com.keenwrite.StatusNotifier.clue; | |
| 22 | import static com.keenwrite.StatusNotifier.getStatusBar; | |
| 21 | import static com.keenwrite.events.StatusEvent.clue; | |
| 23 | 22 | import static com.keenwrite.preferences.ThemeProperty.toFilename; |
| 24 | 23 | import static com.keenwrite.preferences.WorkspaceKeys.KEY_UI_THEME_CUSTOM; |
| 25 | 24 | import static com.keenwrite.preferences.WorkspaceKeys.KEY_UI_THEME_SELECTION; |
| 26 | import static com.keenwrite.ui.actions.ApplicationBars.createMenuBar; | |
| 27 | import static com.keenwrite.ui.actions.ApplicationBars.createToolBar; | |
| 25 | import static com.keenwrite.ui.actions.ApplicationBars.*; | |
| 28 | 26 | import static javafx.application.Platform.runLater; |
| 29 | 27 | import static javafx.scene.input.KeyCode.ALT; |
| ... | ||
| 36 | 34 | public final class MainScene { |
| 37 | 35 | private final Scene mScene; |
| 38 | private final Node mMenuBar; | |
| 36 | private final MenuBar mMenuBar; | |
| 39 | 37 | private final Node mToolBar; |
| 40 | 38 | private final StatusBar mStatusBar; |
| ... | ||
| 48 | 46 | mMenuBar = setManagedLayout( createMenuBar( actions ) ); |
| 49 | 47 | mToolBar = setManagedLayout( createToolBar() ); |
| 50 | mStatusBar = setManagedLayout( getStatusBar() ); | |
| 48 | mStatusBar = setManagedLayout( createStatusBar() ); | |
| 51 | 49 | |
| 52 | 50 | mStatusBar.getRightItems().add( caretListener ); |
| ... | ||
| 88 | 86 | final var node = mStatusBar; |
| 89 | 87 | node.setVisible( !node.isVisible() ); |
| 88 | } | |
| 89 | ||
| 90 | MenuBar getMenuBar() { | |
| 91 | return mMenuBar; | |
| 90 | 92 | } |
| 93 | ||
| 94 | public StatusBar getStatusBar() { return mStatusBar; } | |
| 91 | 95 | |
| 92 | 96 | private void initStylesheets( final Scene scene, final Workspace workspace ) { |
| ... | ||
| 180 | 184 | */ |
| 181 | 185 | private Scene createScene( final Parent parent ) { |
| 182 | return new Scene( parent ); | |
| 186 | final var scene = new Scene( parent ); | |
| 187 | ||
| 188 | // After the app loses focus, when the user switches back using Alt+Tab, | |
| 189 | // the menu is sometimes engaged. See MainApp::initStage(). | |
| 190 | // | |
| 191 | // JavaFX Bug: https://bugs.openjdk.java.net/browse/JDK-8090647 | |
| 192 | scene.addEventHandler( KEY_PRESSED, event -> { | |
| 193 | // Only consume lone ALT key press events. If the modifier is used in | |
| 194 | // combination with another key, don't consume the event. First check | |
| 195 | // if ALT is down before getting the key code as a micro-optimization. | |
| 196 | if( event.isAltDown() ) { | |
| 197 | if( event.getCode() == ALT || event.getCode() == ALT_GRAPH ) { | |
| 198 | event.consume(); | |
| 199 | } | |
| 200 | } | |
| 201 | } ); | |
| 202 | ||
| 203 | return scene; | |
| 183 | 204 | } |
| 184 | 205 | |
| 185 | 206 | /** |
| 186 | 207 | * Binds the visible property of the node to the managed property so that |
| 187 | 208 | * hiding the node also removes the screen real estate that it occupies. |
| 188 | 209 | * This allows the user to hide the menu bar, tool bar, etc. |
| 189 | 210 | * |
| 190 | 211 | * @param node The node to have its real estate bound to visibility. |
| 191 | * @return The given node. | |
| 212 | * @return The given node for fluent-like convenience. | |
| 192 | 213 | */ |
| 193 | 214 | private <T extends Node> T setManagedLayout( final T node ) { |
| 19 | 19 | |
| 20 | 20 | private static final ResourceBundle RESOURCE_BUNDLE = |
| 21 | getBundle( APP_BUNDLE_NAME ); | |
| 21 | getBundle( APP_BUNDLE_NAME ); | |
| 22 | 22 | |
| 23 | 23 | private Messages() { |
| ... | ||
| 32 | 32 | * @return The value of the key with all references recursively dereferenced. |
| 33 | 33 | */ |
| 34 | @SuppressWarnings("SameParameterValue") | |
| 34 | @SuppressWarnings( "SameParameterValue" ) | |
| 35 | 35 | private static String resolve( final ResourceBundle props, final String s ) { |
| 36 | 36 | final int len = s.length(); |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite; | |
| 3 | ||
| 4 | import com.keenwrite.service.events.Notifier; | |
| 5 | import com.keenwrite.ui.logging.LogView; | |
| 6 | import org.controlsfx.control.StatusBar; | |
| 7 | ||
| 8 | import static com.keenwrite.Constants.STATUS_BAR_OK; | |
| 9 | import static com.keenwrite.Messages.get; | |
| 10 | import static javafx.application.Platform.runLater; | |
| 11 | ||
| 12 | /** | |
| 13 | * Responsible for passing notifications about exceptions (or other error | |
| 14 | * messages) through the application. Once the Event Bus is implemented, this | |
| 15 | * class can go away. | |
| 16 | */ | |
| 17 | public final class StatusNotifier { | |
| 18 | private static final String OK = get( STATUS_BAR_OK, "OK" ); | |
| 19 | ||
| 20 | private static final Notifier sNotifier = Services.load( Notifier.class ); | |
| 21 | private static final StatusBar sStatusBar = new StatusBar(); | |
| 22 | private static final LogView sLogView = new LogView(); | |
| 23 | ||
| 24 | /** | |
| 25 | * Resets the status bar to a default message. | |
| 26 | */ | |
| 27 | public static void clue() { | |
| 28 | // Don't burden the repaint thread if there's no status bar change. | |
| 29 | if( !OK.equals( sStatusBar.getText() ) ) { | |
| 30 | update( OK ); | |
| 31 | } | |
| 32 | } | |
| 33 | ||
| 34 | /** | |
| 35 | * Updates the status bar with a custom message. | |
| 36 | * | |
| 37 | * @param key The property key having a value to populate with arguments. | |
| 38 | * @param args The placeholder values to substitute into the key's value. | |
| 39 | */ | |
| 40 | public static void clue( final String key, final Object... args ) { | |
| 41 | final var message = get( key, args ); | |
| 42 | update( message ); | |
| 43 | sLogView.log( message ); | |
| 44 | } | |
| 45 | ||
| 46 | /** | |
| 47 | * Update the status bar with a pre-parsed message and exception. | |
| 48 | * | |
| 49 | * @param message The custom message to log. | |
| 50 | * @param t The exception that triggered the status update. | |
| 51 | */ | |
| 52 | public static void clue( final String message, final Throwable t ) { | |
| 53 | update( message ); | |
| 54 | sLogView.log( message, t ); | |
| 55 | } | |
| 56 | ||
| 57 | /** | |
| 58 | * Called when an exception occurs that warrants the user's attention. | |
| 59 | * | |
| 60 | * @param t The exception with a message that the user should know about. | |
| 61 | */ | |
| 62 | public static void clue( final Throwable t ) { | |
| 63 | update( t.getMessage() ); | |
| 64 | sLogView.log( t ); | |
| 65 | } | |
| 66 | ||
| 67 | /** | |
| 68 | * Returns the global {@link Notifier} instance that can be used for opening | |
| 69 | * pop-up alert messages. | |
| 70 | * | |
| 71 | * @return The pop-up {@link Notifier} dispatcher. | |
| 72 | */ | |
| 73 | public static Notifier getNotifier() { | |
| 74 | return sNotifier; | |
| 75 | } | |
| 76 | ||
| 77 | public static StatusBar getStatusBar() { | |
| 78 | return sStatusBar; | |
| 79 | } | |
| 80 | ||
| 81 | /** | |
| 82 | * Updates the status bar to show the first line of the given message. | |
| 83 | * | |
| 84 | * @param message The message to show in the status bar. | |
| 85 | */ | |
| 86 | private static void update( final String message ) { | |
| 87 | runLater( | |
| 88 | () -> { | |
| 89 | final var s = message == null ? "" : message; | |
| 90 | final var i = s.indexOf( '\n' ); | |
| 91 | sStatusBar.setText( s.substring( 0, i > 0 ? i : s.length() ) ); | |
| 92 | } | |
| 93 | ); | |
| 94 | } | |
| 95 | ||
| 96 | public static void viewIssues() { | |
| 97 | sLogView.view(); | |
| 98 | } | |
| 99 | } | |
| 100 | 1 |
| 12 | 12 | |
| 13 | 13 | import static com.keenwrite.Constants.DEFAULT_CHARSET; |
| 14 | import static com.keenwrite.StatusNotifier.clue; | |
| 14 | import static com.keenwrite.events.StatusEvent.clue; | |
| 15 | 15 | import static java.nio.charset.Charset.forName; |
| 16 | 16 | import static java.nio.file.Files.readAllBytes; |
| 10 | 10 | import javafx.beans.property.ReadOnlyBooleanProperty; |
| 11 | 11 | import javafx.beans.property.SimpleBooleanProperty; |
| 12 | import javafx.collections.ObservableList; | |
| 13 | import javafx.event.ActionEvent; | |
| 14 | import javafx.event.Event; | |
| 15 | import javafx.event.EventHandler; | |
| 16 | import javafx.scene.Node; | |
| 17 | import javafx.scene.control.*; | |
| 18 | import javafx.scene.input.KeyEvent; | |
| 19 | import javafx.scene.layout.BorderPane; | |
| 20 | import javafx.scene.layout.HBox; | |
| 21 | ||
| 22 | import java.io.File; | |
| 23 | import java.nio.charset.Charset; | |
| 24 | import java.util.*; | |
| 25 | import java.util.regex.Pattern; | |
| 26 | ||
| 27 | import static com.keenwrite.Constants.DEFINITION_DEFAULT; | |
| 28 | import static com.keenwrite.Messages.get; | |
| 29 | import static com.keenwrite.StatusNotifier.clue; | |
| 30 | import static java.lang.String.format; | |
| 31 | import static java.util.regex.Pattern.compile; | |
| 32 | import static java.util.regex.Pattern.quote; | |
| 33 | import static javafx.geometry.Pos.CENTER; | |
| 34 | import static javafx.geometry.Pos.TOP_CENTER; | |
| 35 | import static javafx.scene.control.SelectionMode.MULTIPLE; | |
| 36 | import static javafx.scene.control.TreeItem.childrenModificationEvent; | |
| 37 | import static javafx.scene.control.TreeItem.valueChangedEvent; | |
| 38 | import static javafx.scene.input.KeyEvent.KEY_PRESSED; | |
| 39 | ||
| 40 | /** | |
| 41 | * Provides the user interface that holds a {@link TreeView}, which | |
| 42 | * allows users to interact with key/value pairs loaded from the | |
| 43 | * document parser and adapted using a {@link TreeTransformer}. | |
| 44 | */ | |
| 45 | public final class DefinitionEditor extends BorderPane | |
| 46 | implements TextDefinition { | |
| 47 | private static final int GROUP_DELIMITED = 1; | |
| 48 | ||
| 49 | /** | |
| 50 | * Contains the root that is added to the view. | |
| 51 | */ | |
| 52 | private final DefinitionTreeItem<String> mTreeRoot = createRootTreeItem(); | |
| 53 | ||
| 54 | /** | |
| 55 | * Contains a view of the definitions. | |
| 56 | */ | |
| 57 | private final TreeView<String> mTreeView = new TreeView<>( mTreeRoot ); | |
| 58 | ||
| 59 | /** | |
| 60 | * Used to adapt the structured document into a {@link TreeView}. | |
| 61 | */ | |
| 62 | private final TreeTransformer mTreeTransformer; | |
| 63 | ||
| 64 | /** | |
| 65 | * Handlers for key press events. | |
| 66 | */ | |
| 67 | private final Set<EventHandler<? super KeyEvent>> mKeyEventHandlers | |
| 68 | = new HashSet<>(); | |
| 69 | ||
| 70 | /** | |
| 71 | * File being edited by this editor instance. | |
| 72 | */ | |
| 73 | private File mFile; | |
| 74 | ||
| 75 | /** | |
| 76 | * Opened file's character encoding, or {@link Constants#DEFAULT_CHARSET} if | |
| 77 | * either no encoding could be determined or this is a new (empty) file. | |
| 78 | */ | |
| 79 | private final Charset mEncoding; | |
| 80 | ||
| 81 | /** | |
| 82 | * Tracks whether the in-memory definitions have changed with respect to the | |
| 83 | * persisted definitions. | |
| 84 | */ | |
| 85 | private final BooleanProperty mModified = new SimpleBooleanProperty(); | |
| 86 | ||
| 87 | /** | |
| 88 | * This is provided for unit tests that are not backed by files. | |
| 89 | * | |
| 90 | * @param treeTransformer Responsible for transforming the definitions into | |
| 91 | * {@link TreeItem} instances. | |
| 92 | */ | |
| 93 | public DefinitionEditor( | |
| 94 | final TreeTransformer treeTransformer ) { | |
| 95 | this( DEFINITION_DEFAULT, treeTransformer ); | |
| 96 | } | |
| 97 | ||
| 98 | /** | |
| 99 | * Constructs a definition pane with a given tree view root. | |
| 100 | * | |
| 101 | * @param file The file of definitions to maintain through the UI. | |
| 102 | */ | |
| 103 | public DefinitionEditor( | |
| 104 | final File file, | |
| 105 | final TreeTransformer treeTransformer ) { | |
| 106 | assert file != null; | |
| 107 | assert treeTransformer != null; | |
| 108 | ||
| 109 | mFile = file; | |
| 110 | mTreeTransformer = treeTransformer; | |
| 111 | ||
| 112 | mTreeView.setEditable( true ); | |
| 113 | mTreeView.setCellFactory( new TreeCellFactory() ); | |
| 114 | mTreeView.setContextMenu( createContextMenu() ); | |
| 115 | mTreeView.addEventFilter( KEY_PRESSED, this::keyEventFilter ); | |
| 116 | mTreeView.setShowRoot( false ); | |
| 117 | getSelectionModel().setSelectionMode( MULTIPLE ); | |
| 118 | ||
| 119 | final var buttonBar = new HBox(); | |
| 120 | buttonBar.getChildren().addAll( | |
| 121 | createButton( "create", e -> createDefinition() ), | |
| 122 | createButton( "rename", e -> renameDefinition() ), | |
| 123 | createButton( "delete", e -> deleteDefinitions() ) | |
| 124 | ); | |
| 125 | buttonBar.setAlignment( CENTER ); | |
| 126 | buttonBar.setSpacing( 10 ); | |
| 127 | ||
| 128 | setTop( buttonBar ); | |
| 129 | setCenter( mTreeView ); | |
| 130 | setAlignment( buttonBar, TOP_CENTER ); | |
| 131 | mEncoding = open( mFile ); | |
| 132 | ||
| 133 | // After the file is opened, watch for changes, not before. Otherwise, | |
| 134 | // upon saving, users will be prompted to save a file that hasn't had | |
| 135 | // any modifications (from their perspective). | |
| 136 | addTreeChangeHandler( event -> mModified.set( true ) ); | |
| 137 | } | |
| 138 | ||
| 139 | @Override | |
| 140 | public void setText( final String document ) { | |
| 141 | final var foster = mTreeTransformer.transform( document ); | |
| 142 | final var biological = getTreeRoot(); | |
| 143 | ||
| 144 | for( final var child : foster.getChildren() ) { | |
| 145 | biological.getChildren().add( child ); | |
| 146 | } | |
| 147 | ||
| 148 | getTreeView().refresh(); | |
| 149 | } | |
| 150 | ||
| 151 | @Override | |
| 152 | public String getText() { | |
| 153 | final var result = new StringBuilder( 32768 ); | |
| 154 | ||
| 155 | try { | |
| 156 | final var root = getTreeView().getRoot(); | |
| 157 | final var problem = isTreeWellFormed(); | |
| 158 | ||
| 159 | problem.ifPresentOrElse( | |
| 160 | ( node ) -> clue( "yaml.error.tree.form", node ), | |
| 161 | () -> result.append( mTreeTransformer.transform( root ) ) | |
| 162 | ); | |
| 163 | } catch( final Exception ex ) { | |
| 164 | // Catch errors while checking for a well-formed tree (e.g., stack smash). | |
| 165 | // Also catch any transformation exceptions (e.g., Json processing). | |
| 166 | clue( ex ); | |
| 167 | } | |
| 168 | ||
| 169 | return result.toString(); | |
| 170 | } | |
| 171 | ||
| 172 | @Override | |
| 173 | public File getFile() { | |
| 174 | return mFile; | |
| 175 | } | |
| 176 | ||
| 177 | @Override | |
| 178 | public void rename( final File file ) { | |
| 179 | mFile = file; | |
| 180 | } | |
| 181 | ||
| 182 | @Override | |
| 183 | public Charset getEncoding() { | |
| 184 | return mEncoding; | |
| 185 | } | |
| 186 | ||
| 187 | @Override | |
| 188 | public Node getNode() { | |
| 189 | return this; | |
| 190 | } | |
| 191 | ||
| 192 | @Override | |
| 193 | public ReadOnlyBooleanProperty modifiedProperty() { | |
| 194 | return mModified; | |
| 195 | } | |
| 196 | ||
| 197 | @Override | |
| 198 | public void clearModifiedProperty() { | |
| 199 | mModified.setValue( false ); | |
| 200 | } | |
| 201 | ||
| 202 | private Button createButton( | |
| 203 | final String msgKey, final EventHandler<ActionEvent> eventHandler ) { | |
| 204 | final var keyPrefix = "App.action.definition." + msgKey; | |
| 205 | final var button = new Button( get( keyPrefix + ".text" ) ); | |
| 206 | final var icon = get( keyPrefix + ".icon" ); | |
| 207 | final var glyph = FontAwesomeIcon.valueOf( icon.toUpperCase() ); | |
| 208 | ||
| 209 | button.setOnAction( eventHandler ); | |
| 210 | button.setGraphic( | |
| 211 | FontAwesomeIconFactory.get().createIcon( glyph ) | |
| 212 | ); | |
| 213 | button.setTooltip( new Tooltip( get( keyPrefix + ".tooltip" ) ) ); | |
| 214 | ||
| 215 | return button; | |
| 216 | } | |
| 217 | ||
| 218 | @Override | |
| 219 | public Map<String, String> toMap() { | |
| 220 | return new TreeItemMapper().toMap( getTreeView().getRoot() ); | |
| 221 | } | |
| 222 | ||
| 223 | @Override | |
| 224 | public Map<String, String> interpolate( | |
| 225 | final Map<String, String> map, final Tokens tokens ) { | |
| 226 | ||
| 227 | // Non-greedy match of key names delimited by definition tokens. | |
| 228 | final var pattern = compile( | |
| 229 | format( "(%s.*?%s)", | |
| 230 | quote( tokens.getBegan() ), | |
| 231 | quote( tokens.getEnded() ) | |
| 232 | ) | |
| 233 | ); | |
| 234 | ||
| 235 | map.replaceAll( ( k, v ) -> resolve( map, v, pattern ) ); | |
| 236 | return map; | |
| 237 | } | |
| 238 | ||
| 239 | /** | |
| 240 | * Given a value with zero or more key references, this will resolve all | |
| 241 | * the values, recursively. If a key cannot be de-referenced, the value will | |
| 242 | * contain the key name. | |
| 243 | * | |
| 244 | * @param map Map to search for keys when resolving key references. | |
| 245 | * @param value Value containing zero or more key references. | |
| 246 | * @param pattern The regular expression pattern to match variable key names. | |
| 247 | * @return The given value with all embedded key references interpolated. | |
| 248 | */ | |
| 249 | private String resolve( | |
| 250 | final Map<String, String> map, String value, final Pattern pattern ) { | |
| 251 | final var matcher = pattern.matcher( value ); | |
| 252 | ||
| 253 | while( matcher.find() ) { | |
| 254 | final var keyName = matcher.group( GROUP_DELIMITED ); | |
| 255 | final var mapValue = map.get( keyName ); | |
| 256 | final var keyValue = mapValue == null | |
| 257 | ? keyName | |
| 258 | : resolve( map, mapValue, pattern ); | |
| 259 | ||
| 260 | value = value.replace( keyName, keyValue ); | |
| 261 | } | |
| 262 | ||
| 263 | return value; | |
| 264 | } | |
| 265 | ||
| 266 | ||
| 267 | /** | |
| 268 | * Informs the caller of whenever any {@link TreeItem} in the {@link TreeView} | |
| 269 | * is modified. The modifications include: item value changes, item additions, | |
| 270 | * and item removals. | |
| 271 | * <p> | |
| 272 | * Safe to call multiple times; if a handler is already registered, the | |
| 273 | * old handler is used. | |
| 274 | * </p> | |
| 275 | * | |
| 276 | * @param handler The handler to call whenever any {@link TreeItem} changes. | |
| 277 | */ | |
| 278 | public void addTreeChangeHandler( | |
| 279 | final EventHandler<TreeItem.TreeModificationEvent<Event>> handler ) { | |
| 280 | final var root = getTreeView().getRoot(); | |
| 281 | root.addEventHandler( valueChangedEvent(), handler ); | |
| 282 | root.addEventHandler( childrenModificationEvent(), handler ); | |
| 283 | } | |
| 284 | ||
| 285 | public void addKeyEventHandler( | |
| 286 | final EventHandler<? super KeyEvent> handler ) { | |
| 287 | getKeyEventHandlers().add( handler ); | |
| 288 | } | |
| 289 | ||
| 290 | /** | |
| 291 | * Answers whether the {@link TreeItem}s in the {@link TreeView} are suitably | |
| 292 | * well-formed for export. A tree is considered well-formed if the following | |
| 293 | * conditions are met: | |
| 294 | * | |
| 295 | * <ul> | |
| 296 | * <li>The root node contains at least one child node having a leaf.</li> | |
| 297 | * <li>There are no leaf nodes with sibling leaf nodes.</li> | |
| 298 | * </ul> | |
| 299 | * | |
| 300 | * @return {@code null} if the document is well-formed, otherwise the | |
| 301 | * problematic child {@link TreeItem}. | |
| 302 | */ | |
| 303 | public Optional<TreeItem<String>> isTreeWellFormed() { | |
| 304 | final var root = getTreeView().getRoot(); | |
| 305 | ||
| 306 | for( final var child : root.getChildren() ) { | |
| 307 | final var problemChild = isWellFormed( child ); | |
| 308 | ||
| 309 | if( child.isLeaf() || problemChild != null ) { | |
| 310 | return Optional.ofNullable( problemChild ); | |
| 311 | } | |
| 312 | } | |
| 313 | ||
| 314 | return Optional.empty(); | |
| 315 | } | |
| 316 | ||
| 317 | /** | |
| 318 | * Determines whether the document is well-formed by ensuring that | |
| 319 | * child branches do not contain multiple leaves. | |
| 320 | * | |
| 321 | * @param item The sub-tree to check for well-formedness. | |
| 322 | * @return {@code null} when the tree is well-formed, otherwise the | |
| 323 | * problematic {@link TreeItem}. | |
| 324 | */ | |
| 325 | private TreeItem<String> isWellFormed( final TreeItem<String> item ) { | |
| 326 | int childLeafs = 0; | |
| 327 | int childBranches = 0; | |
| 328 | ||
| 329 | for( final var child : item.getChildren() ) { | |
| 330 | if( child.isLeaf() ) { | |
| 331 | childLeafs++; | |
| 332 | } | |
| 333 | else { | |
| 334 | childBranches++; | |
| 335 | } | |
| 336 | ||
| 337 | final var problemChild = isWellFormed( child ); | |
| 338 | ||
| 339 | if( problemChild != null ) { | |
| 340 | return problemChild; | |
| 341 | } | |
| 342 | } | |
| 343 | ||
| 344 | return ((childBranches > 0 && childLeafs == 0) || | |
| 345 | (childBranches == 0 && childLeafs <= 1)) ? null : item; | |
| 346 | } | |
| 347 | ||
| 348 | @Override | |
| 349 | public DefinitionTreeItem<String> findLeafExact( final String text ) { | |
| 350 | return getTreeRoot().findLeafExact( text ); | |
| 351 | } | |
| 352 | ||
| 353 | @Override | |
| 354 | public DefinitionTreeItem<String> findLeafContains( final String text ) { | |
| 355 | return getTreeRoot().findLeafContains( text ); | |
| 356 | } | |
| 357 | ||
| 358 | @Override | |
| 359 | public DefinitionTreeItem<String> findLeafContainsNoCase( | |
| 360 | final String text ) { | |
| 361 | return getTreeRoot().findLeafContainsNoCase( text ); | |
| 362 | } | |
| 363 | ||
| 364 | @Override | |
| 365 | public DefinitionTreeItem<String> findLeafStartsWith( final String text ) { | |
| 366 | return getTreeRoot().findLeafStartsWith( text ); | |
| 367 | } | |
| 368 | ||
| 369 | public void select( final TreeItem<String> item ) { | |
| 370 | getSelectionModel().clearSelection(); | |
| 371 | getSelectionModel().select( getTreeView().getRow( item ) ); | |
| 372 | } | |
| 373 | ||
| 374 | /** | |
| 375 | * Collapses the tree, recursively. | |
| 376 | */ | |
| 377 | public void collapse() { | |
| 378 | collapse( getTreeRoot().getChildren() ); | |
| 379 | } | |
| 380 | ||
| 381 | /** | |
| 382 | * Collapses the tree, recursively. | |
| 383 | * | |
| 384 | * @param <T> The type of tree item to expand (usually String). | |
| 385 | * @param nodes The nodes to collapse. | |
| 386 | */ | |
| 387 | private <T> void collapse( final ObservableList<TreeItem<T>> nodes ) { | |
| 388 | for( final var node : nodes ) { | |
| 389 | node.setExpanded( false ); | |
| 390 | collapse( node.getChildren() ); | |
| 391 | } | |
| 392 | } | |
| 393 | ||
| 394 | /** | |
| 395 | * @return {@code true} when the user is editing a {@link TreeItem}. | |
| 396 | */ | |
| 397 | private boolean isEditingTreeItem() { | |
| 398 | return getTreeView().editingItemProperty().getValue() != null; | |
| 399 | } | |
| 400 | ||
| 401 | /** | |
| 402 | * Changes to edit mode for the selected item. | |
| 403 | */ | |
| 404 | @Override | |
| 405 | public void renameDefinition() { | |
| 406 | getTreeView().edit( getSelectedItem() ); | |
| 407 | } | |
| 408 | ||
| 409 | /** | |
| 410 | * Removes all selected items from the {@link TreeView}. | |
| 411 | */ | |
| 412 | @Override | |
| 413 | public void deleteDefinitions() { | |
| 414 | for( final var item : getSelectedItems() ) { | |
| 415 | final var parent = item.getParent(); | |
| 416 | ||
| 417 | if( parent != null ) { | |
| 418 | parent.getChildren().remove( item ); | |
| 419 | } | |
| 420 | } | |
| 421 | } | |
| 422 | ||
| 423 | /** | |
| 424 | * Deletes the selected item. | |
| 425 | */ | |
| 426 | private void deleteSelectedItem() { | |
| 427 | final var c = getSelectedItem(); | |
| 428 | getSiblings( c ).remove( c ); | |
| 429 | } | |
| 430 | ||
| 431 | /** | |
| 432 | * Adds a new item under the selected item (or root if nothing is selected). | |
| 433 | * There are a few conditions to consider: when adding to the root, | |
| 434 | * when adding to a leaf, and when adding to a non-leaf. Items added to the | |
| 435 | * root must contain two items: a key and a value. | |
| 436 | */ | |
| 437 | @Override | |
| 438 | public void createDefinition() { | |
| 439 | final var value = createDefinitionTreeItem(); | |
| 440 | getSelectedItem().getChildren().add( value ); | |
| 441 | expand( value ); | |
| 442 | select( value ); | |
| 443 | } | |
| 444 | ||
| 445 | private ContextMenu createContextMenu() { | |
| 446 | final var menu = new ContextMenu(); | |
| 447 | final var items = menu.getItems(); | |
| 448 | ||
| 449 | addMenuItem( items, "App.action.definition.create.text" ) | |
| 450 | .setOnAction( e -> createDefinition() ); | |
| 451 | addMenuItem( items, "App.action.definition.rename.text" ) | |
| 452 | .setOnAction( e -> renameDefinition() ); | |
| 453 | addMenuItem( items, "App.action.definition.delete.text" ) | |
| 454 | .setOnAction( e -> deleteSelectedItem() ); | |
| 455 | ||
| 456 | return menu; | |
| 457 | } | |
| 458 | ||
| 459 | /** | |
| 460 | * Executes hot-keys for edits to the definition tree. | |
| 461 | * | |
| 462 | * @param event Contains the key code of the key that was pressed. | |
| 463 | */ | |
| 464 | private void keyEventFilter( final KeyEvent event ) { | |
| 465 | if( !isEditingTreeItem() ) { | |
| 466 | switch( event.getCode() ) { | |
| 467 | case ENTER -> { | |
| 468 | expand( getSelectedItem() ); | |
| 469 | event.consume(); | |
| 470 | } | |
| 471 | ||
| 472 | case DELETE -> deleteDefinitions(); | |
| 473 | case INSERT -> createDefinition(); | |
| 474 | ||
| 475 | case R -> { | |
| 476 | if( event.isControlDown() ) { | |
| 477 | renameDefinition(); | |
| 478 | } | |
| 479 | } | |
| 480 | } | |
| 481 | ||
| 482 | for( final var handler : getKeyEventHandlers() ) { | |
| 483 | handler.handle( event ); | |
| 484 | } | |
| 485 | } | |
| 486 | } | |
| 487 | ||
| 488 | /** | |
| 489 | * Adds a menu item to a list of menu items. | |
| 490 | * | |
| 491 | * @param items The list of menu items to append to. | |
| 492 | * @param labelKey The resource bundle key name for the menu item's label. | |
| 493 | * @return The menu item added to the list of menu items. | |
| 494 | */ | |
| 495 | private MenuItem addMenuItem( | |
| 496 | final List<MenuItem> items, final String labelKey ) { | |
| 497 | final MenuItem menuItem = createMenuItem( labelKey ); | |
| 498 | items.add( menuItem ); | |
| 499 | return menuItem; | |
| 500 | } | |
| 501 | ||
| 502 | private MenuItem createMenuItem( final String labelKey ) { | |
| 503 | return new MenuItem( get( labelKey ) ); | |
| 504 | } | |
| 505 | ||
| 506 | /** | |
| 507 | * Creates a new {@link TreeItem} that is intended to be the root-level item | |
| 508 | * added to the {@link TreeView}. This allows the root item to be | |
| 509 | * distinguished from the other items so that reference keys do not include | |
| 510 | * "Definition" as part of their name. | |
| 511 | * | |
| 512 | * @return A new {@link TreeItem}, never {@code null}. | |
| 513 | */ | |
| 514 | private RootTreeItem<String> createRootTreeItem() { | |
| 515 | return new RootTreeItem<>( get( "Pane.definition.node.root.title" ) ); | |
| 516 | } | |
| 517 | ||
| 518 | private DefinitionTreeItem<String> createDefinitionTreeItem() { | |
| 519 | return new DefinitionTreeItem<>( get( "Definition.menu.add.default" ) ); | |
| 520 | } | |
| 521 | ||
| 522 | @Override | |
| 523 | public void requestFocus() { | |
| 524 | super.requestFocus(); | |
| 12 | import javafx.beans.value.ObservableValue; | |
| 13 | import javafx.collections.ObservableList; | |
| 14 | import javafx.event.ActionEvent; | |
| 15 | import javafx.event.Event; | |
| 16 | import javafx.event.EventHandler; | |
| 17 | import javafx.scene.Node; | |
| 18 | import javafx.scene.control.*; | |
| 19 | import javafx.scene.input.KeyEvent; | |
| 20 | import javafx.scene.layout.BorderPane; | |
| 21 | import javafx.scene.layout.HBox; | |
| 22 | ||
| 23 | import java.io.File; | |
| 24 | import java.nio.charset.Charset; | |
| 25 | import java.util.*; | |
| 26 | import java.util.regex.Pattern; | |
| 27 | ||
| 28 | import static com.keenwrite.Constants.DEFINITION_DEFAULT; | |
| 29 | import static com.keenwrite.Messages.get; | |
| 30 | import static com.keenwrite.events.StatusEvent.clue; | |
| 31 | import static com.keenwrite.events.TextDefinitionFocusEvent.fireTextDefinitionFocus; | |
| 32 | import static java.lang.String.format; | |
| 33 | import static java.util.regex.Pattern.compile; | |
| 34 | import static java.util.regex.Pattern.quote; | |
| 35 | import static javafx.geometry.Pos.CENTER; | |
| 36 | import static javafx.geometry.Pos.TOP_CENTER; | |
| 37 | import static javafx.scene.control.SelectionMode.MULTIPLE; | |
| 38 | import static javafx.scene.control.TreeItem.childrenModificationEvent; | |
| 39 | import static javafx.scene.control.TreeItem.valueChangedEvent; | |
| 40 | import static javafx.scene.input.KeyEvent.KEY_PRESSED; | |
| 41 | ||
| 42 | /** | |
| 43 | * Provides the user interface that holds a {@link TreeView}, which | |
| 44 | * allows users to interact with key/value pairs loaded from the | |
| 45 | * document parser and adapted using a {@link TreeTransformer}. | |
| 46 | */ | |
| 47 | public final class DefinitionEditor extends BorderPane | |
| 48 | implements TextDefinition { | |
| 49 | private static final int GROUP_DELIMITED = 1; | |
| 50 | ||
| 51 | /** | |
| 52 | * Contains the root that is added to the view. | |
| 53 | */ | |
| 54 | private final DefinitionTreeItem<String> mTreeRoot = createRootTreeItem(); | |
| 55 | ||
| 56 | /** | |
| 57 | * Contains a view of the definitions. | |
| 58 | */ | |
| 59 | private final TreeView<String> mTreeView = new TreeView<>( mTreeRoot ); | |
| 60 | ||
| 61 | /** | |
| 62 | * Used to adapt the structured document into a {@link TreeView}. | |
| 63 | */ | |
| 64 | private final TreeTransformer mTreeTransformer; | |
| 65 | ||
| 66 | /** | |
| 67 | * Handlers for key press events. | |
| 68 | */ | |
| 69 | private final Set<EventHandler<? super KeyEvent>> mKeyEventHandlers | |
| 70 | = new HashSet<>(); | |
| 71 | ||
| 72 | /** | |
| 73 | * File being edited by this editor instance. | |
| 74 | */ | |
| 75 | private File mFile; | |
| 76 | ||
| 77 | /** | |
| 78 | * Opened file's character encoding, or {@link Constants#DEFAULT_CHARSET} if | |
| 79 | * either no encoding could be determined or this is a new (empty) file. | |
| 80 | */ | |
| 81 | private final Charset mEncoding; | |
| 82 | ||
| 83 | /** | |
| 84 | * Tracks whether the in-memory definitions have changed with respect to the | |
| 85 | * persisted definitions. | |
| 86 | */ | |
| 87 | private final BooleanProperty mModified = new SimpleBooleanProperty(); | |
| 88 | ||
| 89 | /** | |
| 90 | * This is provided for unit tests that are not backed by files. | |
| 91 | * | |
| 92 | * @param treeTransformer Responsible for transforming the definitions into | |
| 93 | * {@link TreeItem} instances. | |
| 94 | */ | |
| 95 | public DefinitionEditor( | |
| 96 | final TreeTransformer treeTransformer ) { | |
| 97 | this( DEFINITION_DEFAULT, treeTransformer ); | |
| 98 | } | |
| 99 | ||
| 100 | /** | |
| 101 | * Constructs a definition pane with a given tree view root. | |
| 102 | * | |
| 103 | * @param file The file of definitions to maintain through the UI. | |
| 104 | */ | |
| 105 | public DefinitionEditor( | |
| 106 | final File file, | |
| 107 | final TreeTransformer treeTransformer ) { | |
| 108 | assert file != null; | |
| 109 | assert treeTransformer != null; | |
| 110 | ||
| 111 | mFile = file; | |
| 112 | mTreeTransformer = treeTransformer; | |
| 113 | ||
| 114 | mTreeView.setEditable( true ); | |
| 115 | mTreeView.setCellFactory( new TreeCellFactory() ); | |
| 116 | mTreeView.setContextMenu( createContextMenu() ); | |
| 117 | mTreeView.addEventFilter( KEY_PRESSED, this::keyEventFilter ); | |
| 118 | mTreeView.setShowRoot( false ); | |
| 119 | mTreeView.focusedProperty().addListener( this::focused ); | |
| 120 | getSelectionModel().setSelectionMode( MULTIPLE ); | |
| 121 | ||
| 122 | final var buttonBar = new HBox(); | |
| 123 | buttonBar.getChildren().addAll( | |
| 124 | createButton( "create", e -> createDefinition() ), | |
| 125 | createButton( "rename", e -> renameDefinition() ), | |
| 126 | createButton( "delete", e -> deleteDefinitions() ) | |
| 127 | ); | |
| 128 | buttonBar.setAlignment( CENTER ); | |
| 129 | buttonBar.setSpacing( 10 ); | |
| 130 | ||
| 131 | setTop( buttonBar ); | |
| 132 | setCenter( mTreeView ); | |
| 133 | setAlignment( buttonBar, TOP_CENTER ); | |
| 134 | mEncoding = open( mFile ); | |
| 135 | ||
| 136 | // After the file is opened, watch for changes, not before. Otherwise, | |
| 137 | // upon saving, users will be prompted to save a file that hasn't had | |
| 138 | // any modifications (from their perspective). | |
| 139 | addTreeChangeHandler( event -> mModified.set( true ) ); | |
| 140 | } | |
| 141 | ||
| 142 | @Override | |
| 143 | public void setText( final String document ) { | |
| 144 | final var foster = mTreeTransformer.transform( document ); | |
| 145 | final var biological = getTreeRoot(); | |
| 146 | ||
| 147 | for( final var child : foster.getChildren() ) { | |
| 148 | biological.getChildren().add( child ); | |
| 149 | } | |
| 150 | ||
| 151 | getTreeView().refresh(); | |
| 152 | } | |
| 153 | ||
| 154 | @Override | |
| 155 | public String getText() { | |
| 156 | final var result = new StringBuilder( 32768 ); | |
| 157 | ||
| 158 | try { | |
| 159 | final var root = getTreeView().getRoot(); | |
| 160 | final var problem = isTreeWellFormed(); | |
| 161 | ||
| 162 | problem.ifPresentOrElse( | |
| 163 | ( node ) -> clue( "yaml.error.tree.form", node ), | |
| 164 | () -> result.append( mTreeTransformer.transform( root ) ) | |
| 165 | ); | |
| 166 | } catch( final Exception ex ) { | |
| 167 | // Catch errors while checking for a well-formed tree (e.g., stack smash). | |
| 168 | // Also catch any transformation exceptions (e.g., Json processing). | |
| 169 | clue( ex ); | |
| 170 | } | |
| 171 | ||
| 172 | return result.toString(); | |
| 173 | } | |
| 174 | ||
| 175 | @Override | |
| 176 | public File getFile() { | |
| 177 | return mFile; | |
| 178 | } | |
| 179 | ||
| 180 | @Override | |
| 181 | public void rename( final File file ) { | |
| 182 | mFile = file; | |
| 183 | } | |
| 184 | ||
| 185 | @Override | |
| 186 | public Charset getEncoding() { | |
| 187 | return mEncoding; | |
| 188 | } | |
| 189 | ||
| 190 | @Override | |
| 191 | public Node getNode() { | |
| 192 | return this; | |
| 193 | } | |
| 194 | ||
| 195 | @Override | |
| 196 | public ReadOnlyBooleanProperty modifiedProperty() { | |
| 197 | return mModified; | |
| 198 | } | |
| 199 | ||
| 200 | @Override | |
| 201 | public void clearModifiedProperty() { | |
| 202 | mModified.setValue( false ); | |
| 203 | } | |
| 204 | ||
| 205 | private Button createButton( | |
| 206 | final String msgKey, final EventHandler<ActionEvent> eventHandler ) { | |
| 207 | final var keyPrefix = "App.action.definition." + msgKey; | |
| 208 | final var button = new Button( get( keyPrefix + ".text" ) ); | |
| 209 | final var icon = get( keyPrefix + ".icon" ); | |
| 210 | final var glyph = FontAwesomeIcon.valueOf( icon.toUpperCase() ); | |
| 211 | ||
| 212 | button.setOnAction( eventHandler ); | |
| 213 | button.setGraphic( | |
| 214 | FontAwesomeIconFactory.get().createIcon( glyph ) | |
| 215 | ); | |
| 216 | button.setTooltip( new Tooltip( get( keyPrefix + ".tooltip" ) ) ); | |
| 217 | ||
| 218 | return button; | |
| 219 | } | |
| 220 | ||
| 221 | @Override | |
| 222 | public Map<String, String> toMap() { | |
| 223 | return new TreeItemMapper().toMap( getTreeView().getRoot() ); | |
| 224 | } | |
| 225 | ||
| 226 | @Override | |
| 227 | public Map<String, String> interpolate( | |
| 228 | final Map<String, String> map, final Tokens tokens ) { | |
| 229 | ||
| 230 | // Non-greedy match of key names delimited by definition tokens. | |
| 231 | final var pattern = compile( | |
| 232 | format( "(%s.*?%s)", | |
| 233 | quote( tokens.getBegan() ), | |
| 234 | quote( tokens.getEnded() ) | |
| 235 | ) | |
| 236 | ); | |
| 237 | ||
| 238 | map.replaceAll( ( k, v ) -> resolve( map, v, pattern ) ); | |
| 239 | return map; | |
| 240 | } | |
| 241 | ||
| 242 | /** | |
| 243 | * Given a value with zero or more key references, this will resolve all | |
| 244 | * the values, recursively. If a key cannot be de-referenced, the value will | |
| 245 | * contain the key name. | |
| 246 | * | |
| 247 | * @param map Map to search for keys when resolving key references. | |
| 248 | * @param value Value containing zero or more key references. | |
| 249 | * @param pattern The regular expression pattern to match variable key names. | |
| 250 | * @return The given value with all embedded key references interpolated. | |
| 251 | */ | |
| 252 | private String resolve( | |
| 253 | final Map<String, String> map, String value, final Pattern pattern ) { | |
| 254 | final var matcher = pattern.matcher( value ); | |
| 255 | ||
| 256 | while( matcher.find() ) { | |
| 257 | final var keyName = matcher.group( GROUP_DELIMITED ); | |
| 258 | final var mapValue = map.get( keyName ); | |
| 259 | final var keyValue = mapValue == null | |
| 260 | ? keyName | |
| 261 | : resolve( map, mapValue, pattern ); | |
| 262 | ||
| 263 | value = value.replace( keyName, keyValue ); | |
| 264 | } | |
| 265 | ||
| 266 | return value; | |
| 267 | } | |
| 268 | ||
| 269 | ||
| 270 | /** | |
| 271 | * Informs the caller of whenever any {@link TreeItem} in the {@link TreeView} | |
| 272 | * is modified. The modifications include: item value changes, item additions, | |
| 273 | * and item removals. | |
| 274 | * <p> | |
| 275 | * Safe to call multiple times; if a handler is already registered, the | |
| 276 | * old handler is used. | |
| 277 | * </p> | |
| 278 | * | |
| 279 | * @param handler The handler to call whenever any {@link TreeItem} changes. | |
| 280 | */ | |
| 281 | public void addTreeChangeHandler( | |
| 282 | final EventHandler<TreeItem.TreeModificationEvent<Event>> handler ) { | |
| 283 | final var root = getTreeView().getRoot(); | |
| 284 | root.addEventHandler( valueChangedEvent(), handler ); | |
| 285 | root.addEventHandler( childrenModificationEvent(), handler ); | |
| 286 | } | |
| 287 | ||
| 288 | /** | |
| 289 | * Answers whether the {@link TreeItem}s in the {@link TreeView} are suitably | |
| 290 | * well-formed for export. A tree is considered well-formed if the following | |
| 291 | * conditions are met: | |
| 292 | * | |
| 293 | * <ul> | |
| 294 | * <li>The root node contains at least one child node having a leaf.</li> | |
| 295 | * <li>There are no leaf nodes with sibling leaf nodes.</li> | |
| 296 | * </ul> | |
| 297 | * | |
| 298 | * @return {@code null} if the document is well-formed, otherwise the | |
| 299 | * problematic child {@link TreeItem}. | |
| 300 | */ | |
| 301 | public Optional<TreeItem<String>> isTreeWellFormed() { | |
| 302 | final var root = getTreeView().getRoot(); | |
| 303 | ||
| 304 | for( final var child : root.getChildren() ) { | |
| 305 | final var problemChild = isWellFormed( child ); | |
| 306 | ||
| 307 | if( child.isLeaf() || problemChild != null ) { | |
| 308 | return Optional.ofNullable( problemChild ); | |
| 309 | } | |
| 310 | } | |
| 311 | ||
| 312 | return Optional.empty(); | |
| 313 | } | |
| 314 | ||
| 315 | /** | |
| 316 | * Determines whether the document is well-formed by ensuring that | |
| 317 | * child branches do not contain multiple leaves. | |
| 318 | * | |
| 319 | * @param item The sub-tree to check for well-formedness. | |
| 320 | * @return {@code null} when the tree is well-formed, otherwise the | |
| 321 | * problematic {@link TreeItem}. | |
| 322 | */ | |
| 323 | private TreeItem<String> isWellFormed( final TreeItem<String> item ) { | |
| 324 | int childLeafs = 0; | |
| 325 | int childBranches = 0; | |
| 326 | ||
| 327 | for( final var child : item.getChildren() ) { | |
| 328 | if( child.isLeaf() ) { | |
| 329 | childLeafs++; | |
| 330 | } | |
| 331 | else { | |
| 332 | childBranches++; | |
| 333 | } | |
| 334 | ||
| 335 | final var problemChild = isWellFormed( child ); | |
| 336 | ||
| 337 | if( problemChild != null ) { | |
| 338 | return problemChild; | |
| 339 | } | |
| 340 | } | |
| 341 | ||
| 342 | return ((childBranches > 0 && childLeafs == 0) || | |
| 343 | (childBranches == 0 && childLeafs <= 1)) ? null : item; | |
| 344 | } | |
| 345 | ||
| 346 | @Override | |
| 347 | public DefinitionTreeItem<String> findLeafExact( final String text ) { | |
| 348 | return getTreeRoot().findLeafExact( text ); | |
| 349 | } | |
| 350 | ||
| 351 | @Override | |
| 352 | public DefinitionTreeItem<String> findLeafContains( final String text ) { | |
| 353 | return getTreeRoot().findLeafContains( text ); | |
| 354 | } | |
| 355 | ||
| 356 | @Override | |
| 357 | public DefinitionTreeItem<String> findLeafContainsNoCase( | |
| 358 | final String text ) { | |
| 359 | return getTreeRoot().findLeafContainsNoCase( text ); | |
| 360 | } | |
| 361 | ||
| 362 | @Override | |
| 363 | public DefinitionTreeItem<String> findLeafStartsWith( final String text ) { | |
| 364 | return getTreeRoot().findLeafStartsWith( text ); | |
| 365 | } | |
| 366 | ||
| 367 | public void select( final TreeItem<String> item ) { | |
| 368 | getSelectionModel().clearSelection(); | |
| 369 | getSelectionModel().select( getTreeView().getRow( item ) ); | |
| 370 | } | |
| 371 | ||
| 372 | /** | |
| 373 | * Collapses the tree, recursively. | |
| 374 | */ | |
| 375 | public void collapse() { | |
| 376 | collapse( getTreeRoot().getChildren() ); | |
| 377 | } | |
| 378 | ||
| 379 | /** | |
| 380 | * Collapses the tree, recursively. | |
| 381 | * | |
| 382 | * @param <T> The type of tree item to expand (usually String). | |
| 383 | * @param nodes The nodes to collapse. | |
| 384 | */ | |
| 385 | private <T> void collapse( final ObservableList<TreeItem<T>> nodes ) { | |
| 386 | for( final var node : nodes ) { | |
| 387 | node.setExpanded( false ); | |
| 388 | collapse( node.getChildren() ); | |
| 389 | } | |
| 390 | } | |
| 391 | ||
| 392 | /** | |
| 393 | * @return {@code true} when the user is editing a {@link TreeItem}. | |
| 394 | */ | |
| 395 | private boolean isEditingTreeItem() { | |
| 396 | return getTreeView().editingItemProperty().getValue() != null; | |
| 397 | } | |
| 398 | ||
| 399 | /** | |
| 400 | * Changes to edit mode for the selected item. | |
| 401 | */ | |
| 402 | @Override | |
| 403 | public void renameDefinition() { | |
| 404 | getTreeView().edit( getSelectedItem() ); | |
| 405 | } | |
| 406 | ||
| 407 | /** | |
| 408 | * Removes all selected items from the {@link TreeView}. | |
| 409 | */ | |
| 410 | @Override | |
| 411 | public void deleteDefinitions() { | |
| 412 | for( final var item : getSelectedItems() ) { | |
| 413 | final var parent = item.getParent(); | |
| 414 | ||
| 415 | if( parent != null ) { | |
| 416 | parent.getChildren().remove( item ); | |
| 417 | } | |
| 418 | } | |
| 419 | } | |
| 420 | ||
| 421 | /** | |
| 422 | * Deletes the selected item. | |
| 423 | */ | |
| 424 | private void deleteSelectedItem() { | |
| 425 | final var c = getSelectedItem(); | |
| 426 | getSiblings( c ).remove( c ); | |
| 427 | } | |
| 428 | ||
| 429 | /** | |
| 430 | * Adds a new item under the selected item (or root if nothing is selected). | |
| 431 | * There are a few conditions to consider: when adding to the root, | |
| 432 | * when adding to a leaf, and when adding to a non-leaf. Items added to the | |
| 433 | * root must contain two items: a key and a value. | |
| 434 | */ | |
| 435 | @Override | |
| 436 | public void createDefinition() { | |
| 437 | final var value = createDefinitionTreeItem(); | |
| 438 | getSelectedItem().getChildren().add( value ); | |
| 439 | expand( value ); | |
| 440 | select( value ); | |
| 441 | } | |
| 442 | ||
| 443 | private ContextMenu createContextMenu() { | |
| 444 | final var menu = new ContextMenu(); | |
| 445 | final var items = menu.getItems(); | |
| 446 | ||
| 447 | addMenuItem( items, "App.action.definition.create.text" ) | |
| 448 | .setOnAction( e -> createDefinition() ); | |
| 449 | addMenuItem( items, "App.action.definition.rename.text" ) | |
| 450 | .setOnAction( e -> renameDefinition() ); | |
| 451 | addMenuItem( items, "App.action.definition.delete.text" ) | |
| 452 | .setOnAction( e -> deleteSelectedItem() ); | |
| 453 | ||
| 454 | return menu; | |
| 455 | } | |
| 456 | ||
| 457 | /** | |
| 458 | * Executes hot-keys for edits to the definition tree. | |
| 459 | * | |
| 460 | * @param event Contains the key code of the key that was pressed. | |
| 461 | */ | |
| 462 | private void keyEventFilter( final KeyEvent event ) { | |
| 463 | if( !isEditingTreeItem() ) { | |
| 464 | switch( event.getCode() ) { | |
| 465 | case ENTER -> { | |
| 466 | expand( getSelectedItem() ); | |
| 467 | event.consume(); | |
| 468 | } | |
| 469 | ||
| 470 | case DELETE -> deleteDefinitions(); | |
| 471 | case INSERT -> createDefinition(); | |
| 472 | ||
| 473 | case R -> { | |
| 474 | if( event.isControlDown() ) { | |
| 475 | renameDefinition(); | |
| 476 | } | |
| 477 | } | |
| 478 | } | |
| 479 | ||
| 480 | for( final var handler : getKeyEventHandlers() ) { | |
| 481 | handler.handle( event ); | |
| 482 | } | |
| 483 | } | |
| 484 | } | |
| 485 | ||
| 486 | /** | |
| 487 | * Called when the editor's input focus changes. This will fire an event | |
| 488 | * for subscribers. | |
| 489 | * | |
| 490 | * @param ignored Not used. | |
| 491 | * @param o The old input focus property value. | |
| 492 | * @param n The new input focus property value. | |
| 493 | */ | |
| 494 | private void focused( | |
| 495 | final ObservableValue<? extends Boolean> ignored, | |
| 496 | final Boolean o, | |
| 497 | final Boolean n ) { | |
| 498 | if( n != null && n ) { | |
| 499 | fireTextDefinitionFocus( this ); | |
| 500 | } | |
| 501 | } | |
| 502 | ||
| 503 | /** | |
| 504 | * Adds a menu item to a list of menu items. | |
| 505 | * | |
| 506 | * @param items The list of menu items to append to. | |
| 507 | * @param labelKey The resource bundle key name for the menu item's label. | |
| 508 | * @return The menu item added to the list of menu items. | |
| 509 | */ | |
| 510 | private MenuItem addMenuItem( | |
| 511 | final List<MenuItem> items, final String labelKey ) { | |
| 512 | final MenuItem menuItem = createMenuItem( labelKey ); | |
| 513 | items.add( menuItem ); | |
| 514 | return menuItem; | |
| 515 | } | |
| 516 | ||
| 517 | private MenuItem createMenuItem( final String labelKey ) { | |
| 518 | return new MenuItem( get( labelKey ) ); | |
| 519 | } | |
| 520 | ||
| 521 | /** | |
| 522 | * Creates a new {@link TreeItem} that is intended to be the root-level item | |
| 523 | * added to the {@link TreeView}. This allows the root item to be | |
| 524 | * distinguished from the other items so that reference keys do not include | |
| 525 | * "Definition" as part of their name. | |
| 526 | * | |
| 527 | * @return A new {@link TreeItem}, never {@code null}. | |
| 528 | */ | |
| 529 | private RootTreeItem<String> createRootTreeItem() { | |
| 530 | return new RootTreeItem<>( get( "Pane.definition.node.root.title" ) ); | |
| 531 | } | |
| 532 | ||
| 533 | private DefinitionTreeItem<String> createDefinitionTreeItem() { | |
| 534 | return new DefinitionTreeItem<>( get( "Definition.menu.add.default" ) ); | |
| 535 | } | |
| 536 | ||
| 537 | @Override | |
| 538 | public void requestFocus() { | |
| 539 | //super.requestFocus(); | |
| 525 | 540 | getTreeView().requestFocus(); |
| 526 | 541 | } |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.editors.definition; | |
| 3 | ||
| 4 | import com.panemu.tiwulfx.control.dock.DetachableTabPane; | |
| 5 | import javafx.beans.property.ReadOnlyObjectProperty; | |
| 6 | import javafx.scene.Scene; | |
| 7 | import javafx.scene.control.SingleSelectionModel; | |
| 8 | import javafx.scene.control.Tab; | |
| 9 | import javafx.scene.layout.VBox; | |
| 10 | ||
| 11 | import java.util.function.Consumer; | |
| 12 | ||
| 13 | import static javafx.scene.layout.Priority.ALWAYS; | |
| 14 | ||
| 15 | /** | |
| 16 | * Responsible for delegating tab selection events to a consumer. This is | |
| 17 | * required so that when a tab is detached from the main view into its own | |
| 18 | * window (scene), any tab changes in that scene can have an effect on the | |
| 19 | * main view. | |
| 20 | * | |
| 21 | * @author Amrullah Syadzili | |
| 22 | * @author White Magic Software, Ltd. | |
| 23 | */ | |
| 24 | public final class DefinitionTabSceneFactory { | |
| 25 | ||
| 26 | private final Consumer<Tab> mTabSelectionConsumer; | |
| 27 | ||
| 28 | public DefinitionTabSceneFactory( final Consumer<Tab> tabSelectionConsumer ) { | |
| 29 | mTabSelectionConsumer = tabSelectionConsumer; | |
| 30 | } | |
| 31 | ||
| 32 | public Scene create( final DetachableTabPane tabPane ) { | |
| 33 | final var container = new TabContainer( tabPane ); | |
| 34 | final var scene = new Scene( container, 300, 900 ); | |
| 35 | ||
| 36 | scene.windowProperty().addListener( ( c, o, n ) -> { | |
| 37 | if( n != null ) { | |
| 38 | n.focusedProperty().addListener( ( __ ) -> { | |
| 39 | final var tab = container.getSelectedTab(); | |
| 40 | ||
| 41 | if( tab != null ) { | |
| 42 | mTabSelectionConsumer.accept( tab ); | |
| 43 | } | |
| 44 | } ); | |
| 45 | } | |
| 46 | } ); | |
| 47 | ||
| 48 | return scene; | |
| 49 | } | |
| 50 | ||
| 51 | private final class TabContainer extends VBox { | |
| 52 | private final DetachableTabPane mTabPane; | |
| 53 | ||
| 54 | public TabContainer( final DetachableTabPane tabPane ) { | |
| 55 | mTabPane = tabPane; | |
| 56 | setVgrow( tabPane, ALWAYS ); | |
| 57 | getChildren().add( tabPane ); | |
| 58 | ||
| 59 | selectedItemProperty().addListener( | |
| 60 | ( c, o, n ) -> { | |
| 61 | if( n != null ) { | |
| 62 | mTabSelectionConsumer.accept( n ); | |
| 63 | } | |
| 64 | } | |
| 65 | ); | |
| 66 | } | |
| 67 | ||
| 68 | private SingleSelectionModel<Tab> getSelectionModel() { | |
| 69 | return mTabPane.getSelectionModel(); | |
| 70 | } | |
| 71 | ||
| 72 | private ReadOnlyObjectProperty<Tab> selectedItemProperty() { | |
| 73 | return getSelectionModel().selectedItemProperty(); | |
| 74 | } | |
| 75 | ||
| 76 | private Tab getSelectedTab() { | |
| 77 | return getSelectionModel().getSelectedItem(); | |
| 78 | } | |
| 79 | } | |
| 80 | } | |
| 81 | 1 |
| 16 | 16 | import static com.fasterxml.jackson.dataformat.yaml.YAMLGenerator.Feature.MINIMIZE_QUOTES; |
| 17 | 17 | import static com.fasterxml.jackson.dataformat.yaml.YAMLGenerator.Feature.SPLIT_LINES; |
| 18 | import static com.keenwrite.events.StatusEvent.clue; | |
| 18 | 19 | |
| 19 | 20 | /** |
| ... | ||
| 53 | 54 | return sMapper.writeValueAsString( root ); |
| 54 | 55 | } catch( final Exception ex ) { |
| 56 | clue( ex ); | |
| 55 | 57 | throw new RuntimeException( ex ); |
| 56 | 58 | } |
| 4 | 4 | import com.keenwrite.Caret; |
| 5 | 5 | import com.keenwrite.Constants; |
| 6 | import com.keenwrite.MainApp; | |
| 7 | 6 | import com.keenwrite.editors.TextEditor; |
| 8 | 7 | import com.keenwrite.preferences.LocaleProperty; |
| ... | ||
| 35 | 34 | import static com.keenwrite.MainApp.keyDown; |
| 36 | 35 | import static com.keenwrite.Messages.get; |
| 37 | import static com.keenwrite.StatusNotifier.clue; | |
| 36 | import static com.keenwrite.events.StatusEvent.clue; | |
| 37 | import static com.keenwrite.events.TextEditorFocusEvent.fireTextEditorFocus; | |
| 38 | 38 | import static com.keenwrite.preferences.WorkspaceKeys.*; |
| 39 | 39 | import static java.lang.Character.isWhitespace; |
| ... | ||
| 132 | 132 | mDirty.set( true ); |
| 133 | 133 | } ); |
| 134 | ||
| 134 | 135 | textArea.caretPositionProperty().addListener( ( c, o, n ) -> { |
| 135 | 136 | // Fire when the caret position has changed and the text has not. |
| 136 | 137 | mDirty.set( true ); |
| 137 | 138 | mDirty.set( false ); |
| 139 | } ); | |
| 140 | ||
| 141 | textArea.focusedProperty().addListener( ( c, o, n ) -> { | |
| 142 | if( n != null && n ) { | |
| 143 | fireTextEditorFocus( this ); | |
| 144 | } | |
| 138 | 145 | } ); |
| 139 | 146 | } |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.events; | |
| 3 | ||
| 4 | import static com.keenwrite.events.Bus.post; | |
| 5 | ||
| 6 | /** | |
| 7 | * Marker interface for all application events. | |
| 8 | */ | |
| 9 | public interface AppEvent { | |
| 10 | ||
| 11 | /** | |
| 12 | * Submits this event to the {@link Bus}. | |
| 13 | */ | |
| 14 | default void fire() { | |
| 15 | post( this ); | |
| 16 | } | |
| 17 | } | |
| 1 | 18 |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.events; | |
| 3 | ||
| 4 | import org.greenrobot.eventbus.EventBus; | |
| 5 | ||
| 6 | /** | |
| 7 | * Responsible for delegating interactions to the event bus library. This | |
| 8 | * class decouples the rest of the application from a particular event bus | |
| 9 | * implementation. | |
| 10 | */ | |
| 11 | public class Bus { | |
| 12 | private static final EventBus sEventBus = EventBus | |
| 13 | .builder().logNoSubscriberMessages( false ).installDefaultEventBus(); | |
| 14 | ||
| 15 | public static <Subscriber> void register( final Subscriber subscriber ) { | |
| 16 | sEventBus.register( subscriber ); | |
| 17 | } | |
| 18 | ||
| 19 | public static <Subscriber> void unregister( final Subscriber subscriber ) { | |
| 20 | sEventBus.unregister( subscriber ); | |
| 21 | } | |
| 22 | ||
| 23 | public static <Event> void post( final Event event ) { | |
| 24 | sEventBus.post( event ); | |
| 25 | } | |
| 26 | } | |
| 1 | 27 |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.events; | |
| 3 | ||
| 4 | import com.keenwrite.outline.DocumentOutline; | |
| 5 | ||
| 6 | /** | |
| 7 | * Collates information about a caret event, which is typically triggered when | |
| 8 | * the user double-clicks in the {@link DocumentOutline}. | |
| 9 | */ | |
| 10 | public class CaretNavigationEvent implements AppEvent { | |
| 11 | /** | |
| 12 | * Absolute document offset. | |
| 13 | */ | |
| 14 | private final int mOffset; | |
| 15 | ||
| 16 | private CaretNavigationEvent( final int offset ) { | |
| 17 | mOffset = offset; | |
| 18 | } | |
| 19 | ||
| 20 | /** | |
| 21 | * Publishes an event that requests moving the caret to the given offset. | |
| 22 | * | |
| 23 | * @param offset Move the caret to this document offset. | |
| 24 | */ | |
| 25 | public static void fireCaretNavigationEvent( final int offset ) { | |
| 26 | new CaretNavigationEvent( offset ).fire(); | |
| 27 | } | |
| 28 | ||
| 29 | public int getOffset() { | |
| 30 | return mOffset; | |
| 31 | } | |
| 32 | } | |
| 1 | 33 |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.events; | |
| 3 | ||
| 4 | import com.keenwrite.preview.HtmlPanel; | |
| 5 | ||
| 6 | import java.net.URI; | |
| 7 | ||
| 8 | /** | |
| 9 | * Collates information about a file requested to be opened. This can be called | |
| 10 | * when the user clicks a hyperlink in the {@link HtmlPanel}. | |
| 11 | */ | |
| 12 | public class FileOpenEvent implements AppEvent { | |
| 13 | private final URI mUri; | |
| 14 | ||
| 15 | private FileOpenEvent( final URI uri ) { | |
| 16 | assert uri != null; | |
| 17 | mUri = uri; | |
| 18 | } | |
| 19 | ||
| 20 | /** | |
| 21 | * Fires a new file open event using the given {@link URI} instance. | |
| 22 | * | |
| 23 | * @param uri The instance of {@link URI} to open as a file in a text editor. | |
| 24 | */ | |
| 25 | public static void fireFileOpenEvent( final URI uri ) { | |
| 26 | new FileOpenEvent( uri ).fire(); | |
| 27 | } | |
| 28 | ||
| 29 | /** | |
| 30 | * Returns the requested file name to be opened. | |
| 31 | * | |
| 32 | * @return A file reference that can be opened in a text editor. | |
| 33 | */ | |
| 34 | public URI getUri() { | |
| 35 | return mUri; | |
| 36 | } | |
| 37 | } | |
| 1 | 38 |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.events; | |
| 3 | ||
| 4 | /** | |
| 5 | * Collates information about an object that has gained focus. This is typically | |
| 6 | * used by text resource editors (such as text editors and definition editors). | |
| 7 | */ | |
| 8 | public class FocusEvent<T> implements AppEvent { | |
| 9 | private final T mNode; | |
| 10 | ||
| 11 | protected FocusEvent( final T node ) { | |
| 12 | mNode = node; | |
| 13 | } | |
| 14 | ||
| 15 | /** | |
| 16 | * This method is used to help update the UI whenever a component has gained | |
| 17 | * input focus. | |
| 18 | * | |
| 19 | * @return The object that has gained focus. | |
| 20 | */ | |
| 21 | public T get() { | |
| 22 | return mNode; | |
| 23 | } | |
| 24 | } | |
| 1 | 25 |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.events; | |
| 3 | ||
| 4 | import com.keenwrite.processors.Processor; | |
| 5 | ||
| 6 | /** | |
| 7 | * Collates information about a document heading that has been parsed, after | |
| 8 | * all pertinent {@link Processor}s applied. | |
| 9 | */ | |
| 10 | public class ParseHeadingEvent implements AppEvent { | |
| 11 | private static final int NEW_OUTLINE_LEVEL = 0; | |
| 12 | ||
| 13 | /** | |
| 14 | * The heading text, which may be {@code null} upon creating a new outline. | |
| 15 | */ | |
| 16 | private final String mText; | |
| 17 | ||
| 18 | /** | |
| 19 | * The heading level, which will be set to {@link #NEW_OUTLINE_LEVEL} if this | |
| 20 | * event indicates that the existing outline should be cleared anew. | |
| 21 | */ | |
| 22 | private final int mLevel; | |
| 23 | ||
| 24 | /** | |
| 25 | * Offset into the text where the heading is found. | |
| 26 | */ | |
| 27 | private final int mOffset; | |
| 28 | ||
| 29 | private ParseHeadingEvent( | |
| 30 | final int level, final String text, final int offset ) { | |
| 31 | mText = text; | |
| 32 | mLevel = level; | |
| 33 | mOffset = offset; | |
| 34 | } | |
| 35 | ||
| 36 | /** | |
| 37 | * Call to indicate a new outline is to be created. | |
| 38 | */ | |
| 39 | public static void fireNewOutlineEvent() { | |
| 40 | new ParseHeadingEvent( NEW_OUTLINE_LEVEL, "Document", 0 ).fire(); | |
| 41 | } | |
| 42 | ||
| 43 | /** | |
| 44 | * Call to indicate that a new heading must be added to the document outline. | |
| 45 | * | |
| 46 | * @param text The heading text (parsed and processed). | |
| 47 | * @param level A value between 1 and 6. | |
| 48 | * @param offset Absolute offset into document where heading is found. | |
| 49 | */ | |
| 50 | public static void fireNewHeadingEvent( | |
| 51 | final int level, final String text, final int offset ) { | |
| 52 | assert text != null; | |
| 53 | assert 1 <= level && level <= 6; | |
| 54 | assert offset >= 0; | |
| 55 | new ParseHeadingEvent( level, text, offset ).fire(); | |
| 56 | } | |
| 57 | ||
| 58 | public boolean isNewOutline() { | |
| 59 | return getLevel() == NEW_OUTLINE_LEVEL; | |
| 60 | } | |
| 61 | ||
| 62 | public int getLevel() { | |
| 63 | return mLevel; | |
| 64 | } | |
| 65 | ||
| 66 | /** | |
| 67 | * Returns the text description for the heading. | |
| 68 | * | |
| 69 | * @return The post-parsed and processed heading text from the document. | |
| 70 | */ | |
| 71 | public String getText() { | |
| 72 | return mText; | |
| 73 | } | |
| 74 | ||
| 75 | public int getOffset() { | |
| 76 | return mOffset; | |
| 77 | } | |
| 78 | ||
| 79 | @Override | |
| 80 | public String toString() { | |
| 81 | return getText(); | |
| 82 | } | |
| 83 | } | |
| 1 | 84 |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.events; | |
| 3 | ||
| 4 | import com.keenwrite.MainApp; | |
| 5 | ||
| 6 | import java.util.stream.Collectors; | |
| 7 | ||
| 8 | import static com.keenwrite.Constants.NEWLINE; | |
| 9 | import static com.keenwrite.Constants.STATUS_BAR_OK; | |
| 10 | import static com.keenwrite.Messages.get; | |
| 11 | import static java.util.Arrays.stream; | |
| 12 | ||
| 13 | /** | |
| 14 | * Collates information about an application issue. The issues can be | |
| 15 | * exceptions, state problems, parsing errors, and so forth. | |
| 16 | */ | |
| 17 | public class StatusEvent implements AppEvent { | |
| 18 | /** | |
| 19 | * Indicates that there are no issues to bring to the user's attention. | |
| 20 | */ | |
| 21 | private static final StatusEvent OK = | |
| 22 | new StatusEvent( get( STATUS_BAR_OK, "OK" ) ); | |
| 23 | ||
| 24 | /** | |
| 25 | * Detailed information about a problem. | |
| 26 | */ | |
| 27 | private final String mMessage; | |
| 28 | ||
| 29 | /** | |
| 30 | * Provides stack trace information that isolates the cause. | |
| 31 | */ | |
| 32 | private final Throwable mProblem; | |
| 33 | ||
| 34 | /** | |
| 35 | * Constructs a new event that contains a problem description to help the | |
| 36 | * user resolve an issue encountered while using the application. | |
| 37 | * | |
| 38 | * @param message The human-readable message, typically displayed on-screen. | |
| 39 | */ | |
| 40 | public StatusEvent( final String message ) { | |
| 41 | this( message, null ); | |
| 42 | } | |
| 43 | ||
| 44 | /** | |
| 45 | * Constructs a new event that contains a problem description to help the | |
| 46 | * user resolve an issue encountered while using the application. | |
| 47 | * | |
| 48 | * @param message The human-readable message, typically displayed on-screen. | |
| 49 | * @param problem Stack trace to pin-point the problem, may be {@code null}. | |
| 50 | */ | |
| 51 | public StatusEvent( final String message, final Throwable problem ) { | |
| 52 | assert message != null; | |
| 53 | mMessage = message; | |
| 54 | mProblem = problem; | |
| 55 | } | |
| 56 | ||
| 57 | /** | |
| 58 | * Returns the stack trace information for the issue encountered. This is | |
| 59 | * optional because usually a status message isn't an application error. | |
| 60 | * | |
| 61 | * @return Optional stack trace to pin-point the problem area in the code. | |
| 62 | */ | |
| 63 | public String getProblem() { | |
| 64 | // 256 is arbitrary; stack traces shouldn't be much larger. | |
| 65 | final var sb = new StringBuilder( 256 ); | |
| 66 | final var trace = mProblem; | |
| 67 | ||
| 68 | if( trace != null ) { | |
| 69 | sb.append( trace.getMessage().trim() ).append( NEWLINE ); | |
| 70 | stream( trace.getStackTrace() ) | |
| 71 | .takeWhile( StatusEvent::filter ) | |
| 72 | .limit( 10 ) | |
| 73 | .collect( Collectors.toList() ) | |
| 74 | .forEach( e -> sb.append( e.toString() ).append( NEWLINE ) ); | |
| 75 | } | |
| 76 | ||
| 77 | return sb.toString(); | |
| 78 | } | |
| 79 | ||
| 80 | private static boolean filter( final StackTraceElement e ) { | |
| 81 | final var clazz = e.getClassName(); | |
| 82 | return clazz.contains( MainApp.class.getPackageName() ) || | |
| 83 | clazz.startsWith( "org.renjin" ); | |
| 84 | } | |
| 85 | ||
| 86 | /** | |
| 87 | * Returns the message used to construct the event. | |
| 88 | * | |
| 89 | * @return The message for this event. | |
| 90 | */ | |
| 91 | public String toString() { | |
| 92 | return mMessage; | |
| 93 | } | |
| 94 | ||
| 95 | /** | |
| 96 | * Resets the status bar to a default message. | |
| 97 | */ | |
| 98 | public static void clue() { | |
| 99 | OK.fire(); | |
| 100 | } | |
| 101 | ||
| 102 | /** | |
| 103 | * Updates the status bar with a custom message. | |
| 104 | * | |
| 105 | * @param key The property key having a value to populate with arguments. | |
| 106 | * @param args The placeholder values to substitute into the key's value. | |
| 107 | */ | |
| 108 | public static void clue( final String key, final Object... args ) { | |
| 109 | fireStatusEvent( get( key, args ) ); | |
| 110 | } | |
| 111 | ||
| 112 | /** | |
| 113 | * Update the status bar with a pre-parsed message and exception. | |
| 114 | * | |
| 115 | * @param message The custom message to log. | |
| 116 | * @param problem The exception that triggered the status update. | |
| 117 | */ | |
| 118 | public static void clue( final String message, final Throwable problem ) { | |
| 119 | fireStatusEvent( message, problem ); | |
| 120 | } | |
| 121 | ||
| 122 | /** | |
| 123 | * Called when an exception occurs that warrants the user's attention. | |
| 124 | * | |
| 125 | * @param problem The exception with a message to display to the user. | |
| 126 | */ | |
| 127 | public static void clue( final Throwable problem ) { | |
| 128 | fireStatusEvent( problem.getMessage() ); | |
| 129 | } | |
| 130 | ||
| 131 | private static void fireStatusEvent( final String message ) { | |
| 132 | new StatusEvent( message ).fire(); | |
| 133 | } | |
| 134 | ||
| 135 | private static void fireStatusEvent( final String message, final Throwable problem ) { | |
| 136 | new StatusEvent( message, problem ).fire(); | |
| 137 | } | |
| 138 | } | |
| 1 | 139 |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.events; | |
| 3 | ||
| 4 | import com.keenwrite.editors.TextDefinition; | |
| 5 | ||
| 6 | public class TextDefinitionFocusEvent extends FocusEvent<TextDefinition> { | |
| 7 | protected TextDefinitionFocusEvent( final TextDefinition editor ) { | |
| 8 | super( editor ); | |
| 9 | } | |
| 10 | ||
| 11 | /** | |
| 12 | * When the {@link TextDefinition} editor has focus, fire an event so that | |
| 13 | * subscribers may perform an action. | |
| 14 | * | |
| 15 | * @param editor The instance of editor that has gained input focus. | |
| 16 | */ | |
| 17 | public static void fireTextDefinitionFocus( final TextDefinition editor ) { | |
| 18 | new TextDefinitionFocusEvent( editor ).fire(); | |
| 19 | } | |
| 20 | } | |
| 1 | 21 |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.events; | |
| 3 | ||
| 4 | import com.keenwrite.editors.TextEditor; | |
| 5 | ||
| 6 | public class TextEditorFocusEvent extends FocusEvent<TextEditor> { | |
| 7 | protected TextEditorFocusEvent( final TextEditor editor ) { | |
| 8 | super( editor ); | |
| 9 | } | |
| 10 | ||
| 11 | /** | |
| 12 | * When the {@link TextEditor} has focus, fire an event so that subscribers | |
| 13 | * may perform an action---such as parsing and rendering the contents. | |
| 14 | * | |
| 15 | * @param editor The instance of editor that has gained input focus. | |
| 16 | */ | |
| 17 | public static void fireTextEditorFocus( final TextEditor editor ) { | |
| 18 | new TextEditorFocusEvent( editor ).fire(); | |
| 19 | } | |
| 20 | } | |
| 1 | 21 |
| 48 | 48 | register( file ); |
| 49 | 49 | } |
| 50 | } catch( final IOException ex ) { | |
| 50 | } catch( final Exception ignored ) { | |
| 51 | 51 | // Create a fallback that allows the class to be instantiated and used |
| 52 | 52 | // without without preventing the application from launching. |
| 8 | 8 | import java.net.http.HttpRequest; |
| 9 | 9 | |
| 10 | import static com.keenwrite.StatusNotifier.clue; | |
| 10 | import static com.keenwrite.events.StatusEvent.clue; | |
| 11 | 11 | import static com.keenwrite.io.MediaType.UNDEFINED; |
| 12 | 12 | import static java.net.http.HttpClient.Redirect.NORMAL; |
| 20 | 20 | APPLICATION, "x-java-serialized-object" |
| 21 | 21 | ), |
| 22 | APP_DOCUMENT_OUTLINE( | |
| 23 | APPLICATION, "x-document-outline" | |
| 24 | ), | |
| 22 | 25 | |
| 23 | 26 | FONT_OTF( "otf" ), |
| 1 | package com.keenwrite.outline; | |
| 2 | ||
| 3 | import com.keenwrite.events.Bus; | |
| 4 | import com.keenwrite.events.ParseHeadingEvent; | |
| 5 | import javafx.event.Event; | |
| 6 | import javafx.event.EventDispatchChain; | |
| 7 | import javafx.event.EventDispatcher; | |
| 8 | import javafx.scene.control.TreeCell; | |
| 9 | import javafx.scene.control.TreeItem; | |
| 10 | import javafx.scene.control.TreeView; | |
| 11 | import javafx.scene.input.MouseEvent; | |
| 12 | import javafx.scene.text.Text; | |
| 13 | import javafx.util.Callback; | |
| 14 | import org.greenrobot.eventbus.Subscribe; | |
| 15 | ||
| 16 | import static com.keenwrite.Constants.ICON_SIZE_DEFAULT; | |
| 17 | import static com.keenwrite.events.Bus.register; | |
| 18 | import static com.keenwrite.events.CaretNavigationEvent.fireCaretNavigationEvent; | |
| 19 | import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.valueOf; | |
| 20 | import static de.jensd.fx.glyphs.fontawesome.utils.FontAwesomeIconFactory.get; | |
| 21 | import static javafx.application.Platform.runLater; | |
| 22 | import static javafx.scene.input.MouseButton.PRIMARY; | |
| 23 | import static javafx.scene.input.MouseEvent.MOUSE_PRESSED; | |
| 24 | ||
| 25 | public class DocumentOutline extends TreeView<ParseHeadingEvent> { | |
| 26 | private TreeItem<ParseHeadingEvent> mCurrent; | |
| 27 | ||
| 28 | /** | |
| 29 | * Registers with the {@link Bus}. | |
| 30 | */ | |
| 31 | public DocumentOutline() { | |
| 32 | register( this ); | |
| 33 | ||
| 34 | // Override double-click to issue a caret navigation event. | |
| 35 | setCellFactory( new Callback<>() { | |
| 36 | @Override | |
| 37 | public TreeCell<ParseHeadingEvent> call( | |
| 38 | TreeView<ParseHeadingEvent> treeView ) { | |
| 39 | TreeCell<ParseHeadingEvent> cell = new TreeCell<>() { | |
| 40 | @Override | |
| 41 | protected void updateItem( ParseHeadingEvent item, boolean empty ) { | |
| 42 | super.updateItem( item, empty ); | |
| 43 | if( empty || item == null ) { | |
| 44 | setText( null ); | |
| 45 | setGraphic( null ); | |
| 46 | } | |
| 47 | else { | |
| 48 | setText( item.toString() ); | |
| 49 | setGraphic( createIcon() ); | |
| 50 | } | |
| 51 | } | |
| 52 | }; | |
| 53 | ||
| 54 | cell.addEventFilter( MOUSE_PRESSED, event -> { | |
| 55 | if( event.getButton() == PRIMARY && event.getClickCount() % 2 == 0 ) { | |
| 56 | fireCaretNavigationEvent( cell.getItem().getOffset() ); | |
| 57 | event.consume(); | |
| 58 | } | |
| 59 | } ); | |
| 60 | ||
| 61 | return cell; | |
| 62 | } | |
| 63 | } ); | |
| 64 | } | |
| 65 | ||
| 66 | /** | |
| 67 | * Updates the {@link TreeView} with the given event data. This method will | |
| 68 | * track the most recently added {@link TreeItem} so that the nesting | |
| 69 | * hierarchy reflects the document hierarchy. | |
| 70 | * | |
| 71 | * @param event Represents a document heading to add to the tree. | |
| 72 | */ | |
| 73 | @Subscribe | |
| 74 | public void handle( final ParseHeadingEvent event ) { | |
| 75 | runLater( | |
| 76 | () -> mCurrent = event.isNewOutline() ? clear( event ) : addItem( event ) | |
| 77 | ); | |
| 78 | } | |
| 79 | ||
| 80 | private TreeItem<ParseHeadingEvent> clear( final ParseHeadingEvent event ) { | |
| 81 | final var root = createTreeItem( event ); | |
| 82 | setRoot( root ); | |
| 83 | setShowRoot( false ); | |
| 84 | return root; | |
| 85 | } | |
| 86 | ||
| 87 | /** | |
| 88 | * This method is called once for every heading in the document. The event | |
| 89 | * data directly corresponds to the sequence of headings in the document. | |
| 90 | * The given event data contains a level that is relative to the last | |
| 91 | * item in the tree. | |
| 92 | * | |
| 93 | * @param next Contains a level value to indicate heading depth. | |
| 94 | */ | |
| 95 | private TreeItem<ParseHeadingEvent> addItem( final ParseHeadingEvent next ) { | |
| 96 | var parent = mCurrent; | |
| 97 | final var item = createTreeItem( next ); | |
| 98 | final var curr = parent.getValue(); | |
| 99 | final var currLevel = curr.getLevel(); | |
| 100 | final var nextLevel = next.getLevel(); | |
| 101 | var deltaLevel = currLevel - nextLevel + 1; | |
| 102 | ||
| 103 | while( deltaLevel > 0 && parent != null ) { | |
| 104 | parent = parent.getParent(); | |
| 105 | deltaLevel--; | |
| 106 | } | |
| 107 | ||
| 108 | if( parent == null ) { | |
| 109 | parent = getRoot(); | |
| 110 | } | |
| 111 | ||
| 112 | parent.getChildren().add( item ); | |
| 113 | ||
| 114 | return item; | |
| 115 | } | |
| 116 | ||
| 117 | private TreeItem<ParseHeadingEvent> createTreeItem( | |
| 118 | final ParseHeadingEvent event ) { | |
| 119 | final var item = new TreeItem<>( event, createIcon() ); | |
| 120 | item.setExpanded( true ); | |
| 121 | return item; | |
| 122 | } | |
| 123 | ||
| 124 | private Text createIcon() { | |
| 125 | return get().createIcon( valueOf( "BOOKMARK" ), ICON_SIZE_DEFAULT ); | |
| 126 | } | |
| 127 | ||
| 128 | private class TreeMouseEventDispatcher implements EventDispatcher { | |
| 129 | private final EventDispatcher mDispatcher; | |
| 130 | ||
| 131 | public TreeMouseEventDispatcher( final EventDispatcher dispatcher ) { | |
| 132 | mDispatcher = dispatcher; | |
| 133 | } | |
| 134 | ||
| 135 | @Override | |
| 136 | public Event dispatchEvent( final Event e, final EventDispatchChain tail ) { | |
| 137 | if( e instanceof MouseEvent ) { | |
| 138 | final var event = (MouseEvent) e; | |
| 139 | if( event.getButton() == PRIMARY && event.getClickCount() >= 2 ) { | |
| 140 | e.consume(); | |
| 141 | } | |
| 142 | } | |
| 143 | ||
| 144 | return mDispatcher.dispatchEvent( e, tail ); | |
| 145 | } | |
| 146 | } | |
| 147 | } | |
| 1 | 148 |
| 18 | 18 | |
| 19 | 19 | import static com.keenwrite.Constants.ICON_DIALOG; |
| 20 | import static com.keenwrite.StatusNotifier.clue; | |
| 20 | import static com.keenwrite.events.StatusEvent.clue; | |
| 21 | 21 | import static java.lang.System.currentTimeMillis; |
| 22 | 22 | import static javafx.geometry.Pos.CENTER_LEFT; |
| 21 | 21 | import static com.keenwrite.Constants.*; |
| 22 | 22 | import static com.keenwrite.Launcher.getVersion; |
| 23 | import static com.keenwrite.StatusNotifier.clue; | |
| 23 | import static com.keenwrite.events.StatusEvent.clue; | |
| 24 | 24 | import static com.keenwrite.preferences.WorkspaceKeys.*; |
| 25 | 25 | import static java.util.Map.entry; |
| 14 | 14 | import java.util.Map; |
| 15 | 15 | |
| 16 | import static com.keenwrite.StatusNotifier.clue; | |
| 16 | import static com.keenwrite.events.StatusEvent.clue; | |
| 17 | 17 | import static com.keenwrite.processors.text.TextReplacementFactory.replace; |
| 18 | 18 |
| 18 | 18 | import java.net.URI; |
| 19 | 19 | |
| 20 | import static com.keenwrite.StatusNotifier.clue; | |
| 20 | import static com.keenwrite.events.FileOpenEvent.fireFileOpenEvent; | |
| 21 | import static com.keenwrite.events.StatusEvent.clue; | |
| 21 | 22 | import static com.keenwrite.util.ProtocolScheme.getProtocol; |
| 22 | 23 | import static java.awt.Desktop.Action.BROWSE; |
| ... | ||
| 67 | 68 | private static final class HyperlinkListener extends LinkListener { |
| 68 | 69 | @Override |
| 69 | public void linkClicked( final BasicPanel panel, final String link ) { | |
| 70 | switch( getProtocol( link ) ) { | |
| 71 | case HTTP -> { | |
| 72 | final var desktop = getDesktop(); | |
| 70 | public void linkClicked( final BasicPanel panel, final String uri ) { | |
| 71 | try { | |
| 72 | switch( getProtocol( uri ) ) { | |
| 73 | case HTTP -> { | |
| 74 | final var desktop = getDesktop(); | |
| 73 | 75 | |
| 74 | if( desktop.isSupported( BROWSE ) ) { | |
| 75 | try { | |
| 76 | desktop.browse( new URI( link ) ); | |
| 77 | } catch( final Exception ex ) { | |
| 78 | clue( ex ); | |
| 76 | if( desktop.isSupported( BROWSE ) ) { | |
| 77 | desktop.browse( new URI( uri ) ); | |
| 79 | 78 | } |
| 80 | 79 | } |
| 81 | } | |
| 82 | case FILE -> { | |
| 83 | // TODO: #88 -- publish a message to the event bus. | |
| 80 | case FILE -> fireFileOpenEvent( new URI( uri ) ); | |
| 84 | 81 | } |
| 82 | } catch( final Exception ex ) { | |
| 83 | clue( ex ); | |
| 85 | 84 | } |
| 86 | 85 | } |
| 19 | 19 | import static com.keenwrite.Constants.*; |
| 20 | 20 | import static com.keenwrite.Messages.get; |
| 21 | import static com.keenwrite.StatusNotifier.clue; | |
| 21 | import static com.keenwrite.events.StatusEvent.clue; | |
| 22 | 22 | import static com.keenwrite.preferences.WorkspaceKeys.*; |
| 23 | 23 | import static java.lang.Math.max; |
| ... | ||
| 191 | 191 | try { |
| 192 | 192 | sleep( 10 ); |
| 193 | } catch( final InterruptedException ex ) { | |
| 193 | } catch( final Exception ex ) { | |
| 194 | 194 | clue( ex ); |
| 195 | 195 | } |
| 8 | 8 | import java.util.function.Supplier; |
| 9 | 9 | |
| 10 | import static com.keenwrite.StatusNotifier.clue; | |
| 10 | import static com.keenwrite.events.StatusEvent.clue; | |
| 11 | 11 | |
| 12 | 12 | /** |
| 11 | 11 | |
| 12 | 12 | import javax.xml.transform.Transformer; |
| 13 | import javax.xml.transform.TransformerConfigurationException; | |
| 14 | 13 | import javax.xml.transform.TransformerFactory; |
| 15 | 14 | import javax.xml.transform.dom.DOMSource; |
| ... | ||
| 24 | 23 | import java.text.NumberFormat; |
| 25 | 24 | |
| 26 | import static com.keenwrite.StatusNotifier.clue; | |
| 25 | import static com.keenwrite.events.StatusEvent.clue; | |
| 27 | 26 | import static com.keenwrite.preview.RenderingSettings.RENDERING_HINTS; |
| 28 | 27 | import static java.awt.image.BufferedImage.TYPE_INT_RGB; |
| ... | ||
| 54 | 53 | t.setOutputProperty( INDENT, "no" ); |
| 55 | 54 | t.setOutputProperty( ENCODING, UTF_8.name() ); |
| 56 | } catch( final TransformerConfigurationException e ) { | |
| 55 | } catch( final Exception ignored ) { | |
| 57 | 56 | t = null; |
| 58 | 57 | } |
| 16 | 16 | import java.nio.file.Path; |
| 17 | 17 | |
| 18 | import static com.keenwrite.StatusNotifier.clue; | |
| 18 | import static com.keenwrite.events.StatusEvent.clue; | |
| 19 | 19 | import static com.keenwrite.io.MediaType.*; |
| 20 | 20 | import static com.keenwrite.preview.MathRenderer.MATH_RENDERER; |
| 5 | 5 | import java.util.concurrent.atomic.AtomicReference; |
| 6 | 6 | |
| 7 | import static com.keenwrite.events.StatusEvent.clue; | |
| 8 | ||
| 7 | 9 | /** |
| 8 | 10 | * Responsible for transforming data through a variety of chained handlers. |
| ... | ||
| 44 | 46 | while( handler.isPresent() ) { |
| 45 | 47 | handler = handler.flatMap( p -> { |
| 46 | result.set( p.apply( result.get() ) ); | |
| 48 | try { | |
| 49 | result.set( p.apply( result.get() ) ); | |
| 50 | } catch( final Exception ex ) { | |
| 51 | clue( ex ); | |
| 52 | } | |
| 53 | ||
| 47 | 54 | return p.next(); |
| 48 | 55 | } ); |
| 31 | 31 | * |
| 32 | 32 | * @param html The document content to render in the preview pane. The HTML |
| 33 | * should not contain a doctype, head, or body tag, only | |
| 34 | * content to render within the body. | |
| 33 | * should not contain a doctype, head, or body tag. | |
| 35 | 34 | * @return The given {@code html} string. |
| 36 | 35 | */ |
| 5 | 5 | import com.keenwrite.processors.Processor; |
| 6 | 6 | import com.keenwrite.processors.ProcessorContext; |
| 7 | import com.keenwrite.processors.markdown.extensions.DocumentOutlineExtension; | |
| 7 | 8 | import com.keenwrite.processors.markdown.extensions.FencedBlockExtension; |
| 8 | 9 | import com.keenwrite.processors.markdown.extensions.ImageLinkExtension; |
| 9 | import com.keenwrite.processors.markdown.extensions.caret.CaretExtension; | |
| 10 | import com.keenwrite.processors.markdown.extensions.CaretExtension; | |
| 10 | 11 | import com.keenwrite.processors.markdown.extensions.r.RExtension; |
| 11 | 12 | import com.keenwrite.processors.markdown.extensions.tex.TeXExtension; |
| ... | ||
| 74 | 75 | extensions.add( FencedBlockExtension.create( context ) ); |
| 75 | 76 | extensions.add( CaretExtension.create( context ) ); |
| 77 | extensions.add( DocumentOutlineExtension.create( processor ) ); | |
| 76 | 78 | } |
| 77 | 79 | } |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.processors.markdown.extensions; | |
| 3 | ||
| 4 | import com.keenwrite.Caret; | |
| 5 | import com.keenwrite.Constants; | |
| 6 | import com.keenwrite.processors.ProcessorContext; | |
| 7 | import com.vladsch.flexmark.ext.tables.TableBlock; | |
| 8 | import com.vladsch.flexmark.html.AttributeProvider; | |
| 9 | import com.vladsch.flexmark.html.AttributeProviderFactory; | |
| 10 | import com.vladsch.flexmark.html.IndependentAttributeProviderFactory; | |
| 11 | import com.vladsch.flexmark.html.renderer.AttributablePart; | |
| 12 | import com.vladsch.flexmark.html.renderer.LinkResolverContext; | |
| 13 | import com.vladsch.flexmark.util.ast.Node; | |
| 14 | import com.vladsch.flexmark.util.html.AttributeImpl; | |
| 15 | import com.vladsch.flexmark.util.html.MutableAttributes; | |
| 16 | import org.jetbrains.annotations.NotNull; | |
| 17 | ||
| 18 | import static com.keenwrite.Constants.CARET_ID; | |
| 19 | import static com.keenwrite.processors.markdown.extensions.EmptyNode.EMPTY_NODE; | |
| 20 | import static com.vladsch.flexmark.html.HtmlRenderer.Builder; | |
| 21 | ||
| 22 | /** | |
| 23 | * Responsible for giving most block-level elements a unique identifier | |
| 24 | * attribute. The identifier is used to coordinate scrolling. | |
| 25 | */ | |
| 26 | public class CaretExtension extends HtmlRendererAdapter { | |
| 27 | ||
| 28 | private final Caret mCaret; | |
| 29 | ||
| 30 | private CaretExtension( final ProcessorContext context ) { | |
| 31 | mCaret = context.getCaret(); | |
| 32 | } | |
| 33 | ||
| 34 | public static CaretExtension create( final ProcessorContext context ) { | |
| 35 | return new CaretExtension( context ); | |
| 36 | } | |
| 37 | ||
| 38 | @Override | |
| 39 | public void extend( | |
| 40 | final Builder builder, @NotNull final String rendererType ) { | |
| 41 | builder.attributeProviderFactory( | |
| 42 | IdAttributeProvider.createFactory( mCaret ) ); | |
| 43 | } | |
| 44 | ||
| 45 | /** | |
| 46 | * Responsible for creating the id attribute. This class is instantiated | |
| 47 | * once: for the HTML element containing the {@link Constants#CARET_ID}. | |
| 48 | */ | |
| 49 | public static class IdAttributeProvider implements AttributeProvider { | |
| 50 | private final Caret mCaret; | |
| 51 | private boolean mAdded; | |
| 52 | ||
| 53 | public IdAttributeProvider( final Caret caret ) { | |
| 54 | mCaret = caret; | |
| 55 | } | |
| 56 | ||
| 57 | private static AttributeProviderFactory createFactory( final Caret caret ) { | |
| 58 | return new IndependentAttributeProviderFactory() { | |
| 59 | @Override | |
| 60 | public @NotNull AttributeProvider apply( | |
| 61 | @NotNull final LinkResolverContext context ) { | |
| 62 | return new IdAttributeProvider( caret ); | |
| 63 | } | |
| 64 | }; | |
| 65 | } | |
| 66 | ||
| 67 | @Override | |
| 68 | public void setAttributes( @NotNull Node curr, | |
| 69 | @NotNull AttributablePart part, | |
| 70 | @NotNull MutableAttributes attributes ) { | |
| 71 | // Optimization: if a caret is inserted, don't try to find another. | |
| 72 | if( mAdded ) { | |
| 73 | return; | |
| 74 | } | |
| 75 | ||
| 76 | // If a table block has been earmarked with an empty node, it means | |
| 77 | // another extension has generated code from an external source. The | |
| 78 | // Markdown processor won't be able to determine the caret position | |
| 79 | // with any semblance of accuracy, so skip the element. This usually | |
| 80 | // happens with tables, but in theory any Markdown generated from an | |
| 81 | // external source (e.g., an R script) could produce text that has no | |
| 82 | // caret position that can be calculated. | |
| 83 | var table = curr; | |
| 84 | ||
| 85 | if( !(curr instanceof TableBlock) ) { | |
| 86 | table = curr.getAncestorOfType( TableBlock.class ); | |
| 87 | } | |
| 88 | ||
| 89 | // The table was generated outside the document | |
| 90 | if( table != null && table.getLastChild() == EMPTY_NODE ) { | |
| 91 | return; | |
| 92 | } | |
| 93 | ||
| 94 | final var outside = mCaret.isAfterText() ? 1 : 0; | |
| 95 | final var began = curr.getStartOffset(); | |
| 96 | final var ended = curr.getEndOffset() + outside; | |
| 97 | final var prev = curr.getPrevious(); | |
| 98 | ||
| 99 | // If the caret is within the bounds of the current node or the | |
| 100 | // caret is within the bounds of the end of the previous node and | |
| 101 | // the start of the current node, then mark the current node with | |
| 102 | // a caret indicator. | |
| 103 | if( mCaret.isBetweenText( began, ended ) || | |
| 104 | prev != null && mCaret.isBetweenText( prev.getEndOffset(), began ) ) { | |
| 105 | // This line empowers synchronizing the text editor with the preview. | |
| 106 | attributes.addValue( AttributeImpl.of( "id", CARET_ID ) ); | |
| 107 | ||
| 108 | // We're done until the user moves the caret (micro-optimization) | |
| 109 | mAdded = true; | |
| 110 | } | |
| 111 | } | |
| 112 | } | |
| 113 | } | |
| 1 | 114 |
| 1 | package com.keenwrite.processors.markdown.extensions; | |
| 2 | ||
| 3 | import com.keenwrite.processors.Processor; | |
| 4 | import com.vladsch.flexmark.ast.Heading; | |
| 5 | import com.vladsch.flexmark.parser.Parser.Builder; | |
| 6 | import com.vladsch.flexmark.parser.Parser.ParserExtension; | |
| 7 | import com.vladsch.flexmark.parser.block.NodePostProcessor; | |
| 8 | import com.vladsch.flexmark.parser.block.NodePostProcessorFactory; | |
| 9 | import com.vladsch.flexmark.util.ast.Document; | |
| 10 | import com.vladsch.flexmark.util.ast.Node; | |
| 11 | import com.vladsch.flexmark.util.ast.NodeTracker; | |
| 12 | import com.vladsch.flexmark.util.data.MutableDataHolder; | |
| 13 | import org.jetbrains.annotations.NotNull; | |
| 14 | ||
| 15 | import java.util.regex.Pattern; | |
| 16 | ||
| 17 | import static com.keenwrite.events.ParseHeadingEvent.fireNewHeadingEvent; | |
| 18 | import static com.keenwrite.events.ParseHeadingEvent.fireNewOutlineEvent; | |
| 19 | ||
| 20 | public final class DocumentOutlineExtension implements ParserExtension { | |
| 21 | private static final Pattern sRegex = Pattern.compile( "^(#+)" ); | |
| 22 | ||
| 23 | private final Processor<String> mProcessor; | |
| 24 | ||
| 25 | private DocumentOutlineExtension( final Processor<String> processor ) { | |
| 26 | mProcessor = processor; | |
| 27 | } | |
| 28 | ||
| 29 | @Override | |
| 30 | public void parserOptions( final MutableDataHolder options ) {} | |
| 31 | ||
| 32 | @Override | |
| 33 | public void extend( final Builder builder ) { | |
| 34 | builder.postProcessorFactory( new Factory() ); | |
| 35 | } | |
| 36 | ||
| 37 | public static DocumentOutlineExtension create( | |
| 38 | final Processor<String> processor ) { | |
| 39 | return new DocumentOutlineExtension( processor ); | |
| 40 | } | |
| 41 | ||
| 42 | private class HeadingNodePostProcessor extends NodePostProcessor { | |
| 43 | ||
| 44 | @Override | |
| 45 | public void process( | |
| 46 | @NotNull final NodeTracker state, @NotNull final Node node ) { | |
| 47 | final var heading = mProcessor.apply( node.getChars().toString() ); | |
| 48 | final var matcher = sRegex.matcher( heading ); | |
| 49 | ||
| 50 | if( matcher.find() ) { | |
| 51 | final var level = matcher.group().length(); | |
| 52 | final var text = heading.substring( level ); | |
| 53 | final var offset = node.getStartOffset(); | |
| 54 | fireNewHeadingEvent( level, text, offset ); | |
| 55 | } | |
| 56 | } | |
| 57 | } | |
| 58 | ||
| 59 | public class Factory extends NodePostProcessorFactory { | |
| 60 | public Factory() { | |
| 61 | super( false ); | |
| 62 | addNodes( Heading.class ); | |
| 63 | } | |
| 64 | ||
| 65 | @NotNull | |
| 66 | @Override | |
| 67 | public NodePostProcessor apply( @NotNull final Document document ) { | |
| 68 | fireNewOutlineEvent(); | |
| 69 | return new HeadingNodePostProcessor(); | |
| 70 | } | |
| 71 | } | |
| 72 | } | |
| 1 | 73 |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.processors.markdown.extensions; | |
| 3 | ||
| 4 | import com.vladsch.flexmark.util.ast.Node; | |
| 5 | import com.vladsch.flexmark.util.sequence.BasedSequence; | |
| 6 | import org.jetbrains.annotations.NotNull; | |
| 7 | ||
| 8 | /** | |
| 9 | * The singleton is injected into the abstract syntax tree to mark an instance | |
| 10 | * of {@link Node} such that it must not be processed normally. Using a wrapper | |
| 11 | * for a given {@link Node} cannot work because the class type is used by | |
| 12 | * the parsing library for processing. | |
| 13 | */ | |
| 14 | public final class EmptyNode extends Node { | |
| 15 | public static final Node EMPTY_NODE = new EmptyNode(); | |
| 16 | ||
| 17 | private static final BasedSequence[] BASE_SEQ = new BasedSequence[ 0 ]; | |
| 18 | ||
| 19 | private EmptyNode() { | |
| 20 | } | |
| 21 | ||
| 22 | @Override | |
| 23 | public @NotNull BasedSequence[] getSegments() { | |
| 24 | return BASE_SEQ; | |
| 25 | } | |
| 26 | } | |
| 1 | 27 |
| 18 | 18 | import java.util.zip.Deflater; |
| 19 | 19 | |
| 20 | import static com.keenwrite.StatusNotifier.clue; | |
| 20 | import static com.keenwrite.events.StatusEvent.clue; | |
| 21 | 21 | import static com.keenwrite.processors.IdentityProcessor.IDENTITY; |
| 22 | 22 | import static com.vladsch.flexmark.html.HtmlRenderer.Builder; |
| 18 | 18 | |
| 19 | 19 | import static com.keenwrite.ExportFormat.NONE; |
| 20 | import static com.keenwrite.StatusNotifier.clue; | |
| 20 | import static com.keenwrite.events.StatusEvent.clue; | |
| 21 | 21 | import static com.keenwrite.preferences.WorkspaceKeys.KEY_IMAGES_DIR; |
| 22 | 22 | import static com.keenwrite.preferences.WorkspaceKeys.KEY_IMAGES_ORDER; |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.processors.markdown.extensions.caret; | |
| 3 | ||
| 4 | import com.keenwrite.Caret; | |
| 5 | import com.keenwrite.Constants; | |
| 6 | import com.keenwrite.processors.ProcessorContext; | |
| 7 | import com.keenwrite.processors.markdown.extensions.HtmlRendererAdapter; | |
| 8 | import com.vladsch.flexmark.ext.tables.TableBlock; | |
| 9 | import com.vladsch.flexmark.html.AttributeProvider; | |
| 10 | import com.vladsch.flexmark.html.AttributeProviderFactory; | |
| 11 | import com.vladsch.flexmark.html.IndependentAttributeProviderFactory; | |
| 12 | import com.vladsch.flexmark.html.renderer.AttributablePart; | |
| 13 | import com.vladsch.flexmark.html.renderer.LinkResolverContext; | |
| 14 | import com.vladsch.flexmark.util.ast.Node; | |
| 15 | import com.vladsch.flexmark.util.html.AttributeImpl; | |
| 16 | import com.vladsch.flexmark.util.html.MutableAttributes; | |
| 17 | import org.jetbrains.annotations.NotNull; | |
| 18 | ||
| 19 | import static com.keenwrite.Constants.CARET_ID; | |
| 20 | import static com.keenwrite.processors.markdown.extensions.r.EmptyNode.EMPTY_NODE; | |
| 21 | import static com.vladsch.flexmark.html.HtmlRenderer.Builder; | |
| 22 | ||
| 23 | /** | |
| 24 | * Responsible for giving most block-level elements a unique identifier | |
| 25 | * attribute. The identifier is used to coordinate scrolling. | |
| 26 | */ | |
| 27 | public class CaretExtension extends HtmlRendererAdapter { | |
| 28 | ||
| 29 | private final Caret mCaret; | |
| 30 | ||
| 31 | private CaretExtension( final ProcessorContext context ) { | |
| 32 | mCaret = context.getCaret(); | |
| 33 | } | |
| 34 | ||
| 35 | public static CaretExtension create( final ProcessorContext context ) { | |
| 36 | return new CaretExtension( context ); | |
| 37 | } | |
| 38 | ||
| 39 | @Override | |
| 40 | public void extend( | |
| 41 | final Builder builder, @NotNull final String rendererType ) { | |
| 42 | builder.attributeProviderFactory( | |
| 43 | IdAttributeProvider.createFactory( mCaret ) ); | |
| 44 | } | |
| 45 | ||
| 46 | /** | |
| 47 | * Responsible for creating the id attribute. This class is instantiated | |
| 48 | * once: for the HTML element containing the {@link Constants#CARET_ID}. | |
| 49 | */ | |
| 50 | public static class IdAttributeProvider implements AttributeProvider { | |
| 51 | private final Caret mCaret; | |
| 52 | private boolean mAdded; | |
| 53 | ||
| 54 | public IdAttributeProvider( final Caret caret ) { | |
| 55 | mCaret = caret; | |
| 56 | } | |
| 57 | ||
| 58 | private static AttributeProviderFactory createFactory( final Caret caret ) { | |
| 59 | return new IndependentAttributeProviderFactory() { | |
| 60 | @Override | |
| 61 | public @NotNull AttributeProvider apply( | |
| 62 | @NotNull final LinkResolverContext context ) { | |
| 63 | return new IdAttributeProvider( caret ); | |
| 64 | } | |
| 65 | }; | |
| 66 | } | |
| 67 | ||
| 68 | @Override | |
| 69 | public void setAttributes( @NotNull Node curr, | |
| 70 | @NotNull AttributablePart part, | |
| 71 | @NotNull MutableAttributes attributes ) { | |
| 72 | // Optimization: if a caret is inserted, don't try to find another. | |
| 73 | if( mAdded ) { | |
| 74 | return; | |
| 75 | } | |
| 76 | ||
| 77 | // If a table block has been earmarked with an empty node, it means | |
| 78 | // another extension has generated code from an external source. The | |
| 79 | // Markdown processor won't be able to determine the caret position | |
| 80 | // with any semblance of accuracy, so skip the element. This usually | |
| 81 | // happens with tables, but in theory any Markdown generated from an | |
| 82 | // external source (e.g., an R script) could produce text that has no | |
| 83 | // caret position that can be calculated. | |
| 84 | var table = curr; | |
| 85 | ||
| 86 | if( !(curr instanceof TableBlock) ) { | |
| 87 | table = curr.getAncestorOfType( TableBlock.class ); | |
| 88 | } | |
| 89 | ||
| 90 | // The table was generated outside the document | |
| 91 | if( table != null && table.getLastChild() == EMPTY_NODE ) { | |
| 92 | return; | |
| 93 | } | |
| 94 | ||
| 95 | final var outside = mCaret.isAfterText() ? 1 : 0; | |
| 96 | final var began = curr.getStartOffset(); | |
| 97 | final var ended = curr.getEndOffset() + outside; | |
| 98 | final var prev = curr.getPrevious(); | |
| 99 | ||
| 100 | // If the caret is within the bounds of the current node or the | |
| 101 | // caret is within the bounds of the end of the previous node and | |
| 102 | // the start of the current node, then mark the current node with | |
| 103 | // a caret indicator. | |
| 104 | if( mCaret.isBetweenText( began, ended ) || | |
| 105 | prev != null && mCaret.isBetweenText( prev.getEndOffset(), began ) ) { | |
| 106 | // This line empowers synchronizing the text editor with the preview. | |
| 107 | attributes.addValue( AttributeImpl.of( "id", CARET_ID ) ); | |
| 108 | ||
| 109 | // We're done until the user moves the caret (micro-optimization) | |
| 110 | mAdded = true; | |
| 111 | } | |
| 112 | } | |
| 113 | } | |
| 114 | } | |
| 115 | 1 |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.processors.markdown.extensions.r; | |
| 3 | ||
| 4 | import com.vladsch.flexmark.util.ast.Node; | |
| 5 | import com.vladsch.flexmark.util.sequence.BasedSequence; | |
| 6 | import org.jetbrains.annotations.NotNull; | |
| 7 | ||
| 8 | /** | |
| 9 | * The singleton is injected into the abstract syntax tree to mark an instance | |
| 10 | * of {@link Node} such that it must not be processed normally. Using a wrapper | |
| 11 | * for a given {@link Node} cannot work because the class type is used by | |
| 12 | * the parsing library for processing. | |
| 13 | */ | |
| 14 | public final class EmptyNode extends Node { | |
| 15 | public static final Node EMPTY_NODE = new EmptyNode(); | |
| 16 | ||
| 17 | private static final BasedSequence[] BASE_SEQ = new BasedSequence[ 0 ]; | |
| 18 | ||
| 19 | private EmptyNode() { | |
| 20 | } | |
| 21 | ||
| 22 | @Override | |
| 23 | public @NotNull BasedSequence[] getSegments() { | |
| 24 | return BASE_SEQ; | |
| 25 | } | |
| 26 | } | |
| 27 | 1 |
| 23 | 23 | |
| 24 | 24 | import static com.keenwrite.processors.IdentityProcessor.IDENTITY; |
| 25 | import static com.keenwrite.processors.markdown.extensions.r.EmptyNode.EMPTY_NODE; | |
| 25 | import static com.keenwrite.processors.markdown.extensions.EmptyNode.EMPTY_NODE; | |
| 26 | 26 | import static com.vladsch.flexmark.parser.Parser.Builder; |
| 27 | 27 | import static com.vladsch.flexmark.parser.Parser.ParserExtension; |
| 19 | 19 | import static com.keenwrite.Constants.STATUS_PARSE_ERROR; |
| 20 | 20 | import static com.keenwrite.Messages.get; |
| 21 | import static com.keenwrite.StatusNotifier.clue; | |
| 21 | import static com.keenwrite.events.StatusEvent.clue; | |
| 22 | 22 | import static com.keenwrite.preferences.WorkspaceKeys.*; |
| 23 | 23 | import static com.keenwrite.processors.text.TextReplacementFactory.replace; |
| 3 | 3 | |
| 4 | 4 | import javafx.scene.control.Alert; |
| 5 | import javafx.scene.control.ButtonType; | |
| 6 | 5 | import javafx.stage.Window; |
| 7 | 6 | |
| 8 | 7 | import java.nio.file.Path; |
| 9 | 8 | |
| 10 | 9 | /** |
| 11 | 10 | * Provides the application with a uniform way to notify the user of events. |
| 12 | 11 | */ |
| 13 | 12 | public interface Notifier { |
| 14 | ||
| 15 | ButtonType YES = ButtonType.YES; | |
| 16 | ButtonType NO = ButtonType.NO; | |
| 17 | ButtonType CANCEL = ButtonType.CANCEL; | |
| 18 | 13 | |
| 19 | 14 | /** |
| ... | ||
| 27 | 22 | */ |
| 28 | 23 | void alert( |
| 29 | Window parent, | |
| 30 | Path path, | |
| 31 | String titleKey, | |
| 32 | String messageKey, | |
| 33 | Exception ex ); | |
| 24 | Window parent, | |
| 25 | Path path, | |
| 26 | String titleKey, | |
| 27 | String messageKey, | |
| 28 | Exception ex ); | |
| 34 | 29 | |
| 35 | 30 | /** |
| ... | ||
| 42 | 37 | */ |
| 43 | 38 | default void alert( |
| 44 | Window parent, | |
| 45 | Path path, | |
| 46 | String key, | |
| 47 | Exception ex ) { | |
| 39 | Window parent, | |
| 40 | Path path, | |
| 41 | String key, | |
| 42 | Exception ex ) { | |
| 48 | 43 | alert( parent, path, key + ".title", key + ".message", ex ); |
| 49 | 44 | } |
| ... | ||
| 58 | 53 | */ |
| 59 | 54 | Notification createNotification( |
| 60 | String title, | |
| 61 | String message, | |
| 62 | Object... args ); | |
| 55 | String title, | |
| 56 | String message, | |
| 57 | Object... args ); | |
| 63 | 58 | |
| 64 | 59 | /** |
| 14 | 14 | import static javafx.scene.control.Alert.AlertType.CONFIRMATION; |
| 15 | 15 | import static javafx.scene.control.Alert.AlertType.ERROR; |
| 16 | import static javafx.scene.control.ButtonType.*; | |
| 16 | 17 | |
| 17 | 18 | /** |
| 18 | 18 | |
| 19 | 19 | import static com.keenwrite.Constants.LEXICONS_DIRECTORY; |
| 20 | import static com.keenwrite.StatusNotifier.clue; | |
| 20 | import static com.keenwrite.events.StatusEvent.clue; | |
| 21 | 21 | import static io.gitlab.rxp90.jsymspell.SymSpell.Verbosity; |
| 22 | 22 | import static io.gitlab.rxp90.jsymspell.SymSpell.Verbosity.ALL; |
| ... | ||
| 160 | 160 | */ |
| 161 | 161 | private List<SuggestItem> lookup( final String lexeme, final Verbosity v ) { |
| 162 | return getSpeller().lookup( lexeme, v ); | |
| 163 | } | |
| 164 | ||
| 165 | private SymSpell getSpeller() { | |
| 166 | return mSymSpell; | |
| 162 | return mSymSpell.lookup( lexeme, v ); | |
| 167 | 163 | } |
| 168 | 164 | } |
| 2 | 2 | package com.keenwrite.ui.actions; |
| 3 | 3 | |
| 4 | import com.keenwrite.Constants; | |
| 4 | 5 | import com.keenwrite.Messages; |
| 5 | 6 | import com.keenwrite.util.GenericBuilder; |
| ... | ||
| 109 | 110 | private Button createIconButton() { |
| 110 | 111 | final var button = new Button(); |
| 111 | button.setGraphic( get().createIcon( mIcon, "1.2em" ) ); | |
| 112 | button.setGraphic( get().createIcon( mIcon, Constants.ICON_SIZE_DEFAULT ) ); | |
| 112 | 113 | return button; |
| 113 | 114 | } |
| 5 | 5 | import com.keenwrite.MainPane; |
| 6 | 6 | import com.keenwrite.MainScene; |
| 7 | import com.keenwrite.StatusNotifier; | |
| 8 | import com.keenwrite.editors.TextDefinition; | |
| 9 | import com.keenwrite.editors.TextEditor; | |
| 10 | import com.keenwrite.editors.markdown.HyperlinkModel; | |
| 11 | import com.keenwrite.editors.markdown.LinkVisitor; | |
| 12 | import com.keenwrite.preferences.PreferencesController; | |
| 13 | import com.keenwrite.preferences.Workspace; | |
| 14 | import com.keenwrite.processors.markdown.MarkdownProcessor; | |
| 15 | import com.keenwrite.search.SearchModel; | |
| 16 | import com.keenwrite.ui.controls.SearchBar; | |
| 17 | import com.keenwrite.ui.dialogs.ImageDialog; | |
| 18 | import com.keenwrite.ui.dialogs.LinkDialog; | |
| 19 | import com.vladsch.flexmark.ast.Link; | |
| 20 | import javafx.scene.control.Alert; | |
| 21 | import javafx.scene.control.Dialog; | |
| 22 | import javafx.stage.Window; | |
| 23 | import javafx.stage.WindowEvent; | |
| 24 | ||
| 25 | import static com.keenwrite.Bootstrap.*; | |
| 26 | import static com.keenwrite.Constants.ICON_DIALOG_NODE; | |
| 27 | import static com.keenwrite.ExportFormat.*; | |
| 28 | import static com.keenwrite.Messages.get; | |
| 29 | import static com.keenwrite.StatusNotifier.clue; | |
| 30 | import static com.keenwrite.StatusNotifier.getStatusBar; | |
| 31 | import static com.keenwrite.preferences.WorkspaceKeys.KEY_UI_RECENT_DIR; | |
| 32 | import static com.keenwrite.processors.ProcessorFactory.createProcessors; | |
| 33 | import static java.nio.file.Files.writeString; | |
| 34 | import static javafx.event.Event.fireEvent; | |
| 35 | import static javafx.scene.control.Alert.AlertType.INFORMATION; | |
| 36 | import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST; | |
| 37 | ||
| 38 | /** | |
| 39 | * Responsible for abstracting how functionality is mapped to the application. | |
| 40 | * This allows users to customize accelerator keys and will provide pluggable | |
| 41 | * functionality so that different text markup languages can change documents | |
| 42 | * using their respective syntax. | |
| 43 | */ | |
| 44 | @SuppressWarnings( "NonAsciiCharacters" ) | |
| 45 | public final class ApplicationActions { | |
| 46 | private static final String STYLE_SEARCH = "search"; | |
| 47 | ||
| 48 | /** | |
| 49 | * When an action is executed, this is one of the recipients. | |
| 50 | */ | |
| 51 | private final MainPane mMainPane; | |
| 52 | ||
| 53 | private final MainScene mMainScene; | |
| 54 | ||
| 55 | /** | |
| 56 | * Tracks finding text in the active document. | |
| 57 | */ | |
| 58 | private final SearchModel mSearchModel; | |
| 59 | ||
| 60 | public ApplicationActions( final MainScene scene, final MainPane pane ) { | |
| 61 | mMainScene = scene; | |
| 62 | mMainPane = pane; | |
| 63 | mSearchModel = new SearchModel(); | |
| 64 | mSearchModel.matchOffsetProperty().addListener( ( c, o, n ) -> { | |
| 65 | final var editor = getActiveTextEditor(); | |
| 66 | ||
| 67 | // Clear highlighted areas before adding highlighting to a new region. | |
| 68 | if( o != null ) { | |
| 69 | editor.unstylize( STYLE_SEARCH ); | |
| 70 | } | |
| 71 | ||
| 72 | if( n != null ) { | |
| 73 | editor.moveTo( n.getStart() ); | |
| 74 | editor.stylize( n, STYLE_SEARCH ); | |
| 75 | } | |
| 76 | } ); | |
| 77 | ||
| 78 | // When the active text editor changes, update the haystack. | |
| 79 | mMainPane.activeTextEditorProperty().addListener( | |
| 80 | ( c, o, n ) -> mSearchModel.search( getActiveTextEditor().getText() ) | |
| 81 | ); | |
| 82 | } | |
| 83 | ||
| 84 | public void file‿new() { | |
| 85 | getMainPane().newTextEditor(); | |
| 86 | } | |
| 87 | ||
| 88 | public void file‿open() { | |
| 89 | getMainPane().open( createFileChooser().openFiles() ); | |
| 90 | } | |
| 91 | ||
| 92 | public void file‿close() { | |
| 93 | getMainPane().close(); | |
| 94 | } | |
| 95 | ||
| 96 | public void file‿close_all() { | |
| 97 | getMainPane().closeAll(); | |
| 98 | } | |
| 99 | ||
| 100 | public void file‿save() { | |
| 101 | getMainPane().save(); | |
| 102 | } | |
| 103 | ||
| 104 | public void file‿save_as() { | |
| 105 | final var file = createFileChooser().saveAs(); | |
| 106 | file.ifPresent( ( f ) -> getMainPane().saveAs( f ) ); | |
| 107 | } | |
| 108 | ||
| 109 | public void file‿save_all() { | |
| 110 | getMainPane().saveAll(); | |
| 111 | } | |
| 112 | ||
| 113 | public void file‿export‿html_svg() { | |
| 114 | file‿export( HTML_TEX_SVG ); | |
| 115 | } | |
| 116 | ||
| 117 | public void file‿export‿html_tex() { | |
| 118 | file‿export( HTML_TEX_DELIMITED ); | |
| 119 | } | |
| 120 | ||
| 121 | public void file‿export‿markdown() { | |
| 122 | file‿export( MARKDOWN_PLAIN ); | |
| 123 | } | |
| 124 | ||
| 125 | private void file‿export( final ExportFormat format ) { | |
| 126 | final var main = getMainPane(); | |
| 127 | final var context = main.createProcessorContext( format ); | |
| 128 | final var chain = createProcessors( context ); | |
| 129 | final var editor = main.getActiveTextEditor(); | |
| 130 | final var doc = editor.getText(); | |
| 131 | final var export = chain.apply( doc ); | |
| 132 | final var filename = format.toExportFilename( editor.getPath() ); | |
| 133 | final var chooser = createFileChooser(); | |
| 134 | final var file = chooser.exportAs( filename ); | |
| 135 | ||
| 136 | file.ifPresent( ( f ) -> { | |
| 137 | try { | |
| 138 | writeString( f.toPath(), export ); | |
| 139 | clue( get( "Main.status.export.success", f.toString() ) ); | |
| 140 | } catch( final Exception ex ) { | |
| 141 | clue( ex ); | |
| 142 | } | |
| 143 | } ); | |
| 144 | } | |
| 145 | ||
| 146 | public void file‿exit() { | |
| 147 | final var window = getWindow(); | |
| 148 | fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) ); | |
| 149 | } | |
| 150 | ||
| 151 | public void edit‿undo() { | |
| 152 | getActiveTextEditor().undo(); | |
| 153 | } | |
| 154 | ||
| 155 | public void edit‿redo() { | |
| 156 | getActiveTextEditor().redo(); | |
| 157 | } | |
| 158 | ||
| 159 | public void edit‿cut() { | |
| 160 | getActiveTextEditor().cut(); | |
| 161 | } | |
| 162 | ||
| 163 | public void edit‿copy() { | |
| 164 | getActiveTextEditor().copy(); | |
| 165 | } | |
| 166 | ||
| 167 | public void edit‿paste() { | |
| 168 | getActiveTextEditor().paste(); | |
| 169 | } | |
| 170 | ||
| 171 | public void edit‿select_all() { | |
| 172 | getActiveTextEditor().selectAll(); | |
| 173 | } | |
| 174 | ||
| 175 | public void edit‿find() { | |
| 176 | final var nodes = getStatusBar().getLeftItems(); | |
| 177 | ||
| 178 | if( nodes.isEmpty() ) { | |
| 179 | final var searchBar = new SearchBar(); | |
| 180 | ||
| 181 | searchBar.matchIndexProperty().bind( mSearchModel.matchIndexProperty() ); | |
| 182 | searchBar.matchCountProperty().bind( mSearchModel.matchCountProperty() ); | |
| 183 | ||
| 184 | searchBar.setOnCancelAction( ( event ) -> { | |
| 185 | final var editor = getActiveTextEditor(); | |
| 186 | nodes.remove( searchBar ); | |
| 187 | editor.unstylize( STYLE_SEARCH ); | |
| 188 | editor.getNode().requestFocus(); | |
| 189 | } ); | |
| 190 | ||
| 191 | searchBar.addInputListener( ( c, o, n ) -> { | |
| 192 | if( n != null && !n.isEmpty() ) { | |
| 193 | mSearchModel.search( n, getActiveTextEditor().getText() ); | |
| 194 | } | |
| 195 | } ); | |
| 196 | ||
| 197 | searchBar.setOnNextAction( ( event ) -> edit‿find_next() ); | |
| 198 | searchBar.setOnPrevAction( ( event ) -> edit‿find_prev() ); | |
| 199 | ||
| 200 | nodes.add( searchBar ); | |
| 201 | searchBar.requestFocus(); | |
| 202 | } | |
| 203 | else { | |
| 204 | nodes.clear(); | |
| 205 | } | |
| 206 | } | |
| 207 | ||
| 208 | public void edit‿find_next() { | |
| 209 | mSearchModel.advance(); | |
| 210 | } | |
| 211 | ||
| 212 | public void edit‿find_prev() { | |
| 213 | mSearchModel.retreat(); | |
| 214 | } | |
| 215 | ||
| 216 | public void edit‿preferences() { | |
| 217 | new PreferencesController( getWorkspace() ).show(); | |
| 218 | } | |
| 219 | ||
| 220 | public void format‿bold() { | |
| 221 | getActiveTextEditor().bold(); | |
| 222 | } | |
| 223 | ||
| 224 | public void format‿italic() { | |
| 225 | getActiveTextEditor().italic(); | |
| 226 | } | |
| 227 | ||
| 228 | public void format‿superscript() { | |
| 229 | getActiveTextEditor().superscript(); | |
| 230 | } | |
| 231 | ||
| 232 | public void format‿subscript() { | |
| 233 | getActiveTextEditor().subscript(); | |
| 234 | } | |
| 235 | ||
| 236 | public void format‿strikethrough() { | |
| 237 | getActiveTextEditor().strikethrough(); | |
| 238 | } | |
| 239 | ||
| 240 | public void insert‿blockquote() { | |
| 241 | getActiveTextEditor().blockquote(); | |
| 242 | } | |
| 243 | ||
| 244 | public void insert‿code() { | |
| 245 | getActiveTextEditor().code(); | |
| 246 | } | |
| 247 | ||
| 248 | public void insert‿fenced_code_block() { | |
| 249 | getActiveTextEditor().fencedCodeBlock(); | |
| 250 | } | |
| 251 | ||
| 252 | public void insert‿link() { | |
| 253 | insertObject( createLinkDialog() ); | |
| 254 | } | |
| 255 | ||
| 256 | public void insert‿image() { | |
| 257 | insertObject( createImageDialog() ); | |
| 258 | } | |
| 259 | ||
| 260 | private void insertObject( final Dialog<String> dialog ) { | |
| 261 | final var textArea = getActiveTextEditor().getTextArea(); | |
| 262 | dialog.showAndWait().ifPresent( textArea::replaceSelection ); | |
| 263 | } | |
| 264 | ||
| 265 | private Dialog<String> createLinkDialog() { | |
| 266 | return new LinkDialog( getWindow(), createHyperlinkModel() ); | |
| 267 | } | |
| 268 | ||
| 269 | private Dialog<String> createImageDialog() { | |
| 270 | final var path = getActiveTextEditor().getPath(); | |
| 271 | final var parentDir = path.getParent(); | |
| 272 | return new ImageDialog( getWindow(), parentDir ); | |
| 273 | } | |
| 274 | ||
| 275 | /** | |
| 276 | * Returns one of: selected text, word under cursor, or parsed hyperlink from | |
| 277 | * the Markdown AST. | |
| 278 | * | |
| 279 | * @return An instance containing the link URL and display text. | |
| 280 | */ | |
| 281 | private HyperlinkModel createHyperlinkModel() { | |
| 282 | final var context = getMainPane().createProcessorContext(); | |
| 283 | final var editor = getActiveTextEditor(); | |
| 284 | final var textArea = editor.getTextArea(); | |
| 285 | final var selectedText = textArea.getSelectedText(); | |
| 286 | ||
| 287 | // Convert current paragraph to Markdown nodes. | |
| 288 | final var mp = MarkdownProcessor.create( context ); | |
| 289 | final var p = textArea.getCurrentParagraph(); | |
| 290 | final var paragraph = textArea.getText( p ); | |
| 291 | final var node = mp.toNode( paragraph ); | |
| 292 | final var visitor = new LinkVisitor( textArea.getCaretColumn() ); | |
| 293 | final var link = visitor.process( node ); | |
| 294 | ||
| 295 | if( link != null ) { | |
| 296 | textArea.selectRange( p, link.getStartOffset(), p, link.getEndOffset() ); | |
| 297 | } | |
| 298 | ||
| 299 | return createHyperlinkModel( link, selectedText ); | |
| 300 | } | |
| 301 | ||
| 302 | private HyperlinkModel createHyperlinkModel( | |
| 303 | final Link link, final String selection ) { | |
| 304 | ||
| 305 | return link == null | |
| 306 | ? new HyperlinkModel( selection, "https://localhost" ) | |
| 307 | : new HyperlinkModel( link ); | |
| 308 | } | |
| 309 | ||
| 310 | public void insert‿heading_1() { | |
| 311 | insert‿heading( 1 ); | |
| 312 | } | |
| 313 | ||
| 314 | public void insert‿heading_2() { | |
| 315 | insert‿heading( 2 ); | |
| 316 | } | |
| 317 | ||
| 318 | public void insert‿heading_3() { | |
| 319 | insert‿heading( 3 ); | |
| 320 | } | |
| 321 | ||
| 322 | private void insert‿heading( final int level ) { | |
| 323 | getActiveTextEditor().heading( level ); | |
| 324 | } | |
| 325 | ||
| 326 | public void insert‿unordered_list() { | |
| 327 | getActiveTextEditor().unorderedList(); | |
| 328 | } | |
| 329 | ||
| 330 | public void insert‿ordered_list() { | |
| 331 | getActiveTextEditor().orderedList(); | |
| 332 | } | |
| 333 | ||
| 334 | public void insert‿horizontal_rule() { | |
| 335 | getActiveTextEditor().horizontalRule(); | |
| 336 | } | |
| 337 | ||
| 338 | public void definition‿create() { | |
| 339 | getActiveTextDefinition().createDefinition(); | |
| 340 | } | |
| 341 | ||
| 342 | public void definition‿rename() { | |
| 343 | getActiveTextDefinition().renameDefinition(); | |
| 344 | } | |
| 345 | ||
| 346 | public void definition‿delete() { | |
| 347 | getActiveTextDefinition().deleteDefinitions(); | |
| 348 | } | |
| 349 | ||
| 350 | public void definition‿autoinsert() { | |
| 351 | getMainPane().autoinsert(); | |
| 352 | } | |
| 353 | ||
| 354 | public void view‿refresh() { | |
| 355 | getMainPane().viewRefresh(); | |
| 356 | } | |
| 357 | ||
| 358 | public void view‿preview() { | |
| 359 | getMainPane().viewPreview(); | |
| 360 | } | |
| 361 | ||
| 362 | public void view‿menubar() { | |
| 363 | getMainScene().toggleMenuBar(); | |
| 364 | } | |
| 365 | ||
| 366 | public void view‿toolbar() { | |
| 367 | getMainScene().toggleToolBar(); | |
| 368 | } | |
| 369 | ||
| 370 | public void view‿statusbar() { | |
| 371 | getMainScene().toggleStatusBar(); | |
| 372 | } | |
| 373 | ||
| 374 | public void view‿issues() { | |
| 375 | StatusNotifier.viewIssues(); | |
| 7 | import com.keenwrite.editors.TextDefinition; | |
| 8 | import com.keenwrite.editors.TextEditor; | |
| 9 | import com.keenwrite.editors.markdown.HyperlinkModel; | |
| 10 | import com.keenwrite.editors.markdown.LinkVisitor; | |
| 11 | import com.keenwrite.preferences.PreferencesController; | |
| 12 | import com.keenwrite.preferences.Workspace; | |
| 13 | import com.keenwrite.processors.markdown.MarkdownProcessor; | |
| 14 | import com.keenwrite.search.SearchModel; | |
| 15 | import com.keenwrite.ui.controls.SearchBar; | |
| 16 | import com.keenwrite.ui.dialogs.ImageDialog; | |
| 17 | import com.keenwrite.ui.dialogs.LinkDialog; | |
| 18 | import com.keenwrite.ui.logging.LogView; | |
| 19 | import com.vladsch.flexmark.ast.Link; | |
| 20 | import javafx.scene.control.Alert; | |
| 21 | import javafx.scene.control.Dialog; | |
| 22 | import javafx.stage.Window; | |
| 23 | import javafx.stage.WindowEvent; | |
| 24 | ||
| 25 | import static com.keenwrite.Bootstrap.*; | |
| 26 | import static com.keenwrite.Constants.ICON_DIALOG_NODE; | |
| 27 | import static com.keenwrite.ExportFormat.*; | |
| 28 | import static com.keenwrite.Messages.get; | |
| 29 | import static com.keenwrite.events.StatusEvent.clue; | |
| 30 | import static com.keenwrite.preferences.WorkspaceKeys.KEY_UI_RECENT_DIR; | |
| 31 | import static com.keenwrite.processors.ProcessorFactory.createProcessors; | |
| 32 | import static java.nio.file.Files.writeString; | |
| 33 | import static javafx.event.Event.fireEvent; | |
| 34 | import static javafx.scene.control.Alert.AlertType.INFORMATION; | |
| 35 | import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST; | |
| 36 | ||
| 37 | /** | |
| 38 | * Responsible for abstracting how functionality is mapped to the application. | |
| 39 | * This allows users to customize accelerator keys and will provide pluggable | |
| 40 | * functionality so that different text markup languages can change documents | |
| 41 | * using their respective syntax. | |
| 42 | */ | |
| 43 | @SuppressWarnings( "NonAsciiCharacters" ) | |
| 44 | public final class ApplicationActions { | |
| 45 | private static final String STYLE_SEARCH = "search"; | |
| 46 | ||
| 47 | /** | |
| 48 | * When an action is executed, this is one of the recipients. | |
| 49 | */ | |
| 50 | private final MainPane mMainPane; | |
| 51 | ||
| 52 | private final MainScene mMainScene; | |
| 53 | ||
| 54 | private final LogView mLogView; | |
| 55 | ||
| 56 | /** | |
| 57 | * Tracks finding text in the active document. | |
| 58 | */ | |
| 59 | private final SearchModel mSearchModel; | |
| 60 | ||
| 61 | public ApplicationActions( final MainScene scene, final MainPane pane ) { | |
| 62 | mMainScene = scene; | |
| 63 | mMainPane = pane; | |
| 64 | mLogView = new LogView(); | |
| 65 | mSearchModel = new SearchModel(); | |
| 66 | mSearchModel.matchOffsetProperty().addListener( ( c, o, n ) -> { | |
| 67 | final var editor = getActiveTextEditor(); | |
| 68 | ||
| 69 | // Clear highlighted areas before highlighting a new region. | |
| 70 | if( o != null ) { | |
| 71 | editor.unstylize( STYLE_SEARCH ); | |
| 72 | } | |
| 73 | ||
| 74 | if( n != null ) { | |
| 75 | editor.moveTo( n.getStart() ); | |
| 76 | editor.stylize( n, STYLE_SEARCH ); | |
| 77 | } | |
| 78 | } ); | |
| 79 | ||
| 80 | // When the active text editor changes, update the haystack. | |
| 81 | mMainPane.activeTextEditorProperty().addListener( | |
| 82 | ( c, o, n ) -> mSearchModel.search( getActiveTextEditor().getText() ) | |
| 83 | ); | |
| 84 | } | |
| 85 | ||
| 86 | public void file‿new() { | |
| 87 | getMainPane().newTextEditor(); | |
| 88 | } | |
| 89 | ||
| 90 | public void file‿open() { | |
| 91 | getMainPane().open( createFileChooser().openFiles() ); | |
| 92 | } | |
| 93 | ||
| 94 | public void file‿close() { | |
| 95 | getMainPane().close(); | |
| 96 | } | |
| 97 | ||
| 98 | public void file‿close_all() { | |
| 99 | getMainPane().closeAll(); | |
| 100 | } | |
| 101 | ||
| 102 | public void file‿save() { | |
| 103 | getMainPane().save(); | |
| 104 | } | |
| 105 | ||
| 106 | public void file‿save_as() { | |
| 107 | final var file = createFileChooser().saveAs(); | |
| 108 | file.ifPresent( ( f ) -> getMainPane().saveAs( f ) ); | |
| 109 | } | |
| 110 | ||
| 111 | public void file‿save_all() { | |
| 112 | getMainPane().saveAll(); | |
| 113 | } | |
| 114 | ||
| 115 | public void file‿export‿html_svg() { | |
| 116 | file‿export( HTML_TEX_SVG ); | |
| 117 | } | |
| 118 | ||
| 119 | public void file‿export‿html_tex() { | |
| 120 | file‿export( HTML_TEX_DELIMITED ); | |
| 121 | } | |
| 122 | ||
| 123 | public void file‿export‿markdown() { | |
| 124 | file‿export( MARKDOWN_PLAIN ); | |
| 125 | } | |
| 126 | ||
| 127 | private void file‿export( final ExportFormat format ) { | |
| 128 | final var main = getMainPane(); | |
| 129 | final var context = main.createProcessorContext( format ); | |
| 130 | final var chain = createProcessors( context ); | |
| 131 | final var editor = main.getActiveTextEditor(); | |
| 132 | final var doc = editor.getText(); | |
| 133 | final var export = chain.apply( doc ); | |
| 134 | final var filename = format.toExportFilename( editor.getPath() ); | |
| 135 | final var chooser = createFileChooser(); | |
| 136 | final var file = chooser.exportAs( filename ); | |
| 137 | ||
| 138 | file.ifPresent( ( f ) -> { | |
| 139 | try { | |
| 140 | writeString( f.toPath(), export ); | |
| 141 | clue( get( "Main.status.export.success", f.toString() ) ); | |
| 142 | } catch( final Exception ex ) { | |
| 143 | clue( ex ); | |
| 144 | } | |
| 145 | } ); | |
| 146 | } | |
| 147 | ||
| 148 | public void file‿exit() { | |
| 149 | final var window = getWindow(); | |
| 150 | fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) ); | |
| 151 | } | |
| 152 | ||
| 153 | public void edit‿undo() { | |
| 154 | getActiveTextEditor().undo(); | |
| 155 | } | |
| 156 | ||
| 157 | public void edit‿redo() { | |
| 158 | getActiveTextEditor().redo(); | |
| 159 | } | |
| 160 | ||
| 161 | public void edit‿cut() { | |
| 162 | getActiveTextEditor().cut(); | |
| 163 | } | |
| 164 | ||
| 165 | public void edit‿copy() { | |
| 166 | getActiveTextEditor().copy(); | |
| 167 | } | |
| 168 | ||
| 169 | public void edit‿paste() { | |
| 170 | getActiveTextEditor().paste(); | |
| 171 | } | |
| 172 | ||
| 173 | public void edit‿select_all() { | |
| 174 | getActiveTextEditor().selectAll(); | |
| 175 | } | |
| 176 | ||
| 177 | public void edit‿find() { | |
| 178 | final var nodes = getMainScene().getStatusBar().getLeftItems(); | |
| 179 | ||
| 180 | if( nodes.isEmpty() ) { | |
| 181 | final var searchBar = new SearchBar(); | |
| 182 | ||
| 183 | searchBar.matchIndexProperty().bind( mSearchModel.matchIndexProperty() ); | |
| 184 | searchBar.matchCountProperty().bind( mSearchModel.matchCountProperty() ); | |
| 185 | ||
| 186 | searchBar.setOnCancelAction( ( event ) -> { | |
| 187 | final var editor = getActiveTextEditor(); | |
| 188 | nodes.remove( searchBar ); | |
| 189 | editor.unstylize( STYLE_SEARCH ); | |
| 190 | editor.getNode().requestFocus(); | |
| 191 | } ); | |
| 192 | ||
| 193 | searchBar.addInputListener( ( c, o, n ) -> { | |
| 194 | if( n != null && !n.isEmpty() ) { | |
| 195 | mSearchModel.search( n, getActiveTextEditor().getText() ); | |
| 196 | } | |
| 197 | } ); | |
| 198 | ||
| 199 | searchBar.setOnNextAction( ( event ) -> edit‿find_next() ); | |
| 200 | searchBar.setOnPrevAction( ( event ) -> edit‿find_prev() ); | |
| 201 | ||
| 202 | nodes.add( searchBar ); | |
| 203 | searchBar.requestFocus(); | |
| 204 | } | |
| 205 | else { | |
| 206 | nodes.clear(); | |
| 207 | } | |
| 208 | } | |
| 209 | ||
| 210 | public void edit‿find_next() { | |
| 211 | mSearchModel.advance(); | |
| 212 | } | |
| 213 | ||
| 214 | public void edit‿find_prev() { | |
| 215 | mSearchModel.retreat(); | |
| 216 | } | |
| 217 | ||
| 218 | public void edit‿preferences() { | |
| 219 | new PreferencesController( getWorkspace() ).show(); | |
| 220 | } | |
| 221 | ||
| 222 | public void format‿bold() { | |
| 223 | getActiveTextEditor().bold(); | |
| 224 | } | |
| 225 | ||
| 226 | public void format‿italic() { | |
| 227 | getActiveTextEditor().italic(); | |
| 228 | } | |
| 229 | ||
| 230 | public void format‿superscript() { | |
| 231 | getActiveTextEditor().superscript(); | |
| 232 | } | |
| 233 | ||
| 234 | public void format‿subscript() { | |
| 235 | getActiveTextEditor().subscript(); | |
| 236 | } | |
| 237 | ||
| 238 | public void format‿strikethrough() { | |
| 239 | getActiveTextEditor().strikethrough(); | |
| 240 | } | |
| 241 | ||
| 242 | public void insert‿blockquote() { | |
| 243 | getActiveTextEditor().blockquote(); | |
| 244 | } | |
| 245 | ||
| 246 | public void insert‿code() { | |
| 247 | getActiveTextEditor().code(); | |
| 248 | } | |
| 249 | ||
| 250 | public void insert‿fenced_code_block() { | |
| 251 | getActiveTextEditor().fencedCodeBlock(); | |
| 252 | } | |
| 253 | ||
| 254 | public void insert‿link() { | |
| 255 | insertObject( createLinkDialog() ); | |
| 256 | } | |
| 257 | ||
| 258 | public void insert‿image() { | |
| 259 | insertObject( createImageDialog() ); | |
| 260 | } | |
| 261 | ||
| 262 | private void insertObject( final Dialog<String> dialog ) { | |
| 263 | final var textArea = getActiveTextEditor().getTextArea(); | |
| 264 | dialog.showAndWait().ifPresent( textArea::replaceSelection ); | |
| 265 | } | |
| 266 | ||
| 267 | private Dialog<String> createLinkDialog() { | |
| 268 | return new LinkDialog( getWindow(), createHyperlinkModel() ); | |
| 269 | } | |
| 270 | ||
| 271 | private Dialog<String> createImageDialog() { | |
| 272 | final var path = getActiveTextEditor().getPath(); | |
| 273 | final var parentDir = path.getParent(); | |
| 274 | return new ImageDialog( getWindow(), parentDir ); | |
| 275 | } | |
| 276 | ||
| 277 | /** | |
| 278 | * Returns one of: selected text, word under cursor, or parsed hyperlink from | |
| 279 | * the Markdown AST. | |
| 280 | * | |
| 281 | * @return An instance containing the link URL and display text. | |
| 282 | */ | |
| 283 | private HyperlinkModel createHyperlinkModel() { | |
| 284 | final var context = getMainPane().createProcessorContext(); | |
| 285 | final var editor = getActiveTextEditor(); | |
| 286 | final var textArea = editor.getTextArea(); | |
| 287 | final var selectedText = textArea.getSelectedText(); | |
| 288 | ||
| 289 | // Convert current paragraph to Markdown nodes. | |
| 290 | final var mp = MarkdownProcessor.create( context ); | |
| 291 | final var p = textArea.getCurrentParagraph(); | |
| 292 | final var paragraph = textArea.getText( p ); | |
| 293 | final var node = mp.toNode( paragraph ); | |
| 294 | final var visitor = new LinkVisitor( textArea.getCaretColumn() ); | |
| 295 | final var link = visitor.process( node ); | |
| 296 | ||
| 297 | if( link != null ) { | |
| 298 | textArea.selectRange( p, link.getStartOffset(), p, link.getEndOffset() ); | |
| 299 | } | |
| 300 | ||
| 301 | return createHyperlinkModel( link, selectedText ); | |
| 302 | } | |
| 303 | ||
| 304 | private HyperlinkModel createHyperlinkModel( | |
| 305 | final Link link, final String selection ) { | |
| 306 | ||
| 307 | return link == null | |
| 308 | ? new HyperlinkModel( selection, "https://localhost" ) | |
| 309 | : new HyperlinkModel( link ); | |
| 310 | } | |
| 311 | ||
| 312 | public void insert‿heading_1() { | |
| 313 | insert‿heading( 1 ); | |
| 314 | } | |
| 315 | ||
| 316 | public void insert‿heading_2() { | |
| 317 | insert‿heading( 2 ); | |
| 318 | } | |
| 319 | ||
| 320 | public void insert‿heading_3() { | |
| 321 | insert‿heading( 3 ); | |
| 322 | } | |
| 323 | ||
| 324 | private void insert‿heading( final int level ) { | |
| 325 | getActiveTextEditor().heading( level ); | |
| 326 | } | |
| 327 | ||
| 328 | public void insert‿unordered_list() { | |
| 329 | getActiveTextEditor().unorderedList(); | |
| 330 | } | |
| 331 | ||
| 332 | public void insert‿ordered_list() { | |
| 333 | getActiveTextEditor().orderedList(); | |
| 334 | } | |
| 335 | ||
| 336 | public void insert‿horizontal_rule() { | |
| 337 | getActiveTextEditor().horizontalRule(); | |
| 338 | } | |
| 339 | ||
| 340 | public void definition‿create() { | |
| 341 | getActiveTextDefinition().createDefinition(); | |
| 342 | } | |
| 343 | ||
| 344 | public void definition‿rename() { | |
| 345 | getActiveTextDefinition().renameDefinition(); | |
| 346 | } | |
| 347 | ||
| 348 | public void definition‿delete() { | |
| 349 | getActiveTextDefinition().deleteDefinitions(); | |
| 350 | } | |
| 351 | ||
| 352 | public void definition‿autoinsert() { | |
| 353 | getMainPane().autoinsert(); | |
| 354 | } | |
| 355 | ||
| 356 | public void view‿refresh() { | |
| 357 | getMainPane().viewRefresh(); | |
| 358 | } | |
| 359 | ||
| 360 | public void view‿preview() { | |
| 361 | getMainPane().viewPreview(); | |
| 362 | } | |
| 363 | ||
| 364 | public void view‿outline() { | |
| 365 | getMainPane().viewOutline(); | |
| 366 | } | |
| 367 | ||
| 368 | public void view‿menubar() { | |
| 369 | getMainScene().toggleMenuBar(); | |
| 370 | } | |
| 371 | ||
| 372 | public void view‿toolbar() { | |
| 373 | getMainScene().toggleToolBar(); | |
| 374 | } | |
| 375 | ||
| 376 | public void view‿statusbar() { | |
| 377 | getMainScene().toggleStatusBar(); | |
| 378 | } | |
| 379 | ||
| 380 | public void view‿issues() { | |
| 381 | mLogView.view(); | |
| 376 | 382 | } |
| 377 | 383 |
| 2 | 2 | package com.keenwrite.ui.actions; |
| 3 | 3 | |
| 4 | import com.keenwrite.ui.controls.EventedStatusBar; | |
| 4 | 5 | import javafx.event.ActionEvent; |
| 5 | 6 | import javafx.event.EventHandler; |
| 6 | 7 | import javafx.scene.Node; |
| 7 | 8 | import javafx.scene.control.Menu; |
| 8 | 9 | import javafx.scene.control.MenuBar; |
| 9 | 10 | import javafx.scene.control.MenuItem; |
| 10 | 11 | import javafx.scene.control.ToolBar; |
| 12 | import org.controlsfx.control.StatusBar; | |
| 11 | 13 | |
| 12 | 14 | import java.util.HashMap; |
| ... | ||
| 34 | 36 | * @param actions The {@link ApplicationActions} that map user interface |
| 35 | 37 | * selections to executable code. |
| 36 | * @return An instance of {@link Node} that contains the menu and toolbar. | |
| 38 | * @return An instance of {@link MenuBar} that contains the menu. | |
| 37 | 39 | */ |
| 38 | public static Node createMenuBar( final ApplicationActions actions ) { | |
| 40 | public static MenuBar createMenuBar( final ApplicationActions actions ) { | |
| 39 | 41 | final var SEPARATOR_ACTION = new SeparatorAction(); |
| 40 | 42 | |
| ... | ||
| 116 | 118 | addAction( "view.refresh", e -> actions.view‿refresh() ), |
| 117 | 119 | SEPARATOR_ACTION, |
| 118 | addAction( "view.issues", e -> actions.view‿issues() ), | |
| 119 | 120 | addAction( "view.preview", e -> actions.view‿preview() ), |
| 121 | addAction( "view.outline", e -> actions.view‿outline() ), | |
| 120 | 122 | SEPARATOR_ACTION, |
| 121 | 123 | addAction( "view.menubar", e -> actions.view‿menubar() ), |
| 122 | 124 | addAction( "view.toolbar", e -> actions.view‿toolbar() ), |
| 123 | addAction( "view.statusbar", e -> actions.view‿statusbar() ) | |
| 125 | addAction( "view.statusbar", e -> actions.view‿statusbar() ), | |
| 126 | SEPARATOR_ACTION, | |
| 127 | addAction( "view.issues", e -> actions.view‿issues() ) | |
| 124 | 128 | ), |
| 125 | 129 | createMenu( |
| ... | ||
| 160 | 164 | getAction( "insert.ordered_list" ) |
| 161 | 165 | ); |
| 166 | } | |
| 167 | ||
| 168 | public static StatusBar createStatusBar() { | |
| 169 | return new EventedStatusBar(); | |
| 162 | 170 | } |
| 163 | 171 | |
| 4 | 4 | import org.xhtmlrenderer.event.DocumentListener; |
| 5 | 5 | |
| 6 | import static com.keenwrite.StatusNotifier.clue; | |
| 6 | import static com.keenwrite.events.StatusEvent.clue; | |
| 7 | 7 | |
| 8 | 8 | /** |
| 105 | 105 | try { |
| 106 | 106 | newUrl = getBasePath().relativize( file.toPath() ).toString(); |
| 107 | } catch( IllegalArgumentException ex ) { | |
| 107 | } catch( final Exception ex ) { | |
| 108 | 108 | newUrl = file.toString(); |
| 109 | 109 | } |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.ui.controls; | |
| 3 | ||
| 4 | import com.keenwrite.events.StatusEvent; | |
| 5 | import org.controlsfx.control.StatusBar; | |
| 6 | import org.greenrobot.eventbus.Subscribe; | |
| 7 | ||
| 8 | import static com.keenwrite.events.Bus.register; | |
| 9 | import static javafx.application.Platform.runLater; | |
| 10 | ||
| 11 | /** | |
| 12 | * Responsible for handling application status events. | |
| 13 | */ | |
| 14 | public class EventedStatusBar extends StatusBar { | |
| 15 | public EventedStatusBar() { | |
| 16 | register( this ); | |
| 17 | } | |
| 18 | ||
| 19 | /** | |
| 20 | * Called when an application problem is encountered. Updates the status | |
| 21 | * bar to show the first line of the given message. This method is | |
| 22 | * idempotent (if the message text is already set to the text from the | |
| 23 | * given message, no update is performed). | |
| 24 | * | |
| 25 | * @param event The event containing information about the problem. | |
| 26 | */ | |
| 27 | @Subscribe | |
| 28 | public void handle( final StatusEvent event ) { | |
| 29 | final var message = event.toString(); | |
| 30 | ||
| 31 | // Don't burden the repaint thread if there's no status bar change. | |
| 32 | if( !getText().equals( message ) ) { | |
| 33 | runLater( | |
| 34 | () -> { | |
| 35 | final var s = message == null ? "" : message; | |
| 36 | final var i = s.indexOf( '\n' ); | |
| 37 | setText( s.substring( 0, i > 0 ? i : s.length() ) ); | |
| 38 | } | |
| 39 | ); | |
| 40 | } | |
| 41 | } | |
| 42 | } | |
| 1 | 43 |
| 2 | 2 | package com.keenwrite.ui.logging; |
| 3 | 3 | |
| 4 | import com.keenwrite.MainApp; | |
| 4 | import com.keenwrite.events.StatusEvent; | |
| 5 | 5 | import javafx.beans.property.SimpleStringProperty; |
| 6 | 6 | import javafx.beans.property.StringProperty; |
| 7 | 7 | import javafx.collections.ObservableList; |
| 8 | 8 | import javafx.scene.control.*; |
| 9 | 9 | import javafx.scene.input.ClipboardContent; |
| 10 | 10 | import javafx.scene.input.KeyCodeCombination; |
| 11 | 11 | import javafx.stage.Stage; |
| 12 | import org.greenrobot.eventbus.Subscribe; | |
| 12 | 13 | |
| 13 | 14 | import java.time.LocalDateTime; |
| 14 | 15 | import java.util.Objects; |
| 15 | 16 | import java.util.TreeSet; |
| 16 | import java.util.stream.Collectors; | |
| 17 | 17 | |
| 18 | 18 | import static com.keenwrite.Constants.ICON_DIALOG; |
| 19 | import static com.keenwrite.Constants.NEWLINE; | |
| 20 | 19 | import static com.keenwrite.Messages.get; |
| 21 | import static com.keenwrite.StatusNotifier.clue; | |
| 20 | import static com.keenwrite.events.Bus.register; | |
| 21 | import static com.keenwrite.events.StatusEvent.clue; | |
| 22 | 22 | import static java.time.LocalDateTime.now; |
| 23 | 23 | import static java.time.format.DateTimeFormatter.ofPattern; |
| 24 | import static java.util.Arrays.stream; | |
| 25 | 24 | import static javafx.collections.FXCollections.observableArrayList; |
| 26 | 25 | import static javafx.event.ActionEvent.ACTION; |
| ... | ||
| 56 | 55 | initIcon(); |
| 57 | 56 | initActions(); |
| 57 | register( this ); | |
| 58 | } | |
| 59 | ||
| 60 | @Subscribe | |
| 61 | public void log( final StatusEvent event ) { | |
| 62 | final var logEntry = new LogEntry( event ); | |
| 63 | ||
| 64 | if( !mEntries.contains( logEntry ) ) { | |
| 65 | mEntries.add( logEntry ); | |
| 66 | ||
| 67 | while( mEntries.size() > CACHE_SIZE ) { | |
| 68 | mEntries.remove( 0 ); | |
| 69 | } | |
| 70 | ||
| 71 | mTable.scrollTo( logEntry ); | |
| 72 | } | |
| 58 | 73 | } |
| 59 | 74 | |
| ... | ||
| 72 | 87 | mEntries.clear(); |
| 73 | 88 | clue(); |
| 74 | } | |
| 75 | ||
| 76 | public void log( final String message ) { | |
| 77 | log( new LogEntry( message ) ); | |
| 78 | } | |
| 79 | ||
| 80 | public void log( final Throwable error ) { | |
| 81 | log( new LogEntry( error ) ); | |
| 82 | } | |
| 83 | ||
| 84 | public void log( final String message, final Throwable trace ) { | |
| 85 | log( new LogEntry( message, trace ) ); | |
| 86 | } | |
| 87 | ||
| 88 | private void log( final LogEntry logEntry ) { | |
| 89 | // Exit early if the log already contains the message. The status bar will | |
| 90 | // remain current. | |
| 91 | if( mEntries.contains( logEntry ) ) { | |
| 92 | return; | |
| 93 | } | |
| 94 | ||
| 95 | mEntries.add( logEntry ); | |
| 96 | ||
| 97 | while( mEntries.size() > CACHE_SIZE ) { | |
| 98 | mEntries.remove( 0 ); | |
| 99 | } | |
| 100 | ||
| 101 | mTable.scrollTo( logEntry ); | |
| 102 | 89 | } |
| 103 | 90 | |
| ... | ||
| 171 | 158 | private final StringProperty mMessage; |
| 172 | 159 | private final StringProperty mTrace; |
| 173 | ||
| 174 | /** | |
| 175 | * Constructs a new {@link LogEntry} for the current time, and having | |
| 176 | * no associated stack trace. | |
| 177 | * | |
| 178 | * @param message The error message. | |
| 179 | */ | |
| 180 | public LogEntry( final String message ) { | |
| 181 | this( message, null ); | |
| 182 | } | |
| 183 | ||
| 184 | /** | |
| 185 | * Constructs a new {@link LogEntry} for the current time, and using | |
| 186 | * the given error's message. | |
| 187 | * | |
| 188 | * @param error The stack trace, must not be {@code null}. | |
| 189 | */ | |
| 190 | public LogEntry( final Throwable error ) { | |
| 191 | this( error.getMessage(), error ); | |
| 192 | } | |
| 193 | 160 | |
| 194 | 161 | /** |
| 195 | * Constructs a new {@link LogEntry} with the current date and time. | |
| 196 | * | |
| 197 | * @param message The error message. | |
| 198 | * @param trace The stack trace associated with the message, may be | |
| 199 | * {@code null}. | |
| 162 | * Constructs a new {@link LogEntry} for the current time. | |
| 200 | 163 | */ |
| 201 | public LogEntry( final String message, final Throwable trace ) { | |
| 164 | public LogEntry( final StatusEvent event ) { | |
| 202 | 165 | mDate = new SimpleStringProperty( toString( now() ) ); |
| 203 | mMessage = new SimpleStringProperty( message ); | |
| 204 | mTrace = new SimpleStringProperty( toString( trace ) ); | |
| 166 | mMessage = new SimpleStringProperty( event.toString() ); | |
| 167 | mTrace = new SimpleStringProperty( event.getProblem() ); | |
| 205 | 168 | } |
| 206 | 169 | |
| ... | ||
| 219 | 182 | private String toString( final LocalDateTime date ) { |
| 220 | 183 | return date.format( ofPattern( "d MMM u HH:mm:ss" ) ); |
| 221 | } | |
| 222 | ||
| 223 | private String toString( final Throwable trace ) { | |
| 224 | final var sb = new StringBuilder( 256 ); | |
| 225 | ||
| 226 | if( trace != null ) { | |
| 227 | sb.append( trace.getMessage().trim() ).append( NEWLINE ); | |
| 228 | stream( trace.getStackTrace() ) | |
| 229 | .takeWhile( LogView::filter ) | |
| 230 | .limit( 10 ) | |
| 231 | .collect( Collectors.toList() ) | |
| 232 | .forEach( e -> sb.append( e.toString() ).append( NEWLINE ) ); | |
| 233 | } | |
| 234 | ||
| 235 | return sb.toString(); | |
| 236 | 184 | } |
| 237 | 185 | |
| ... | ||
| 248 | 196 | return mMessage != null ? mMessage.hashCode() : 0; |
| 249 | 197 | } |
| 250 | } | |
| 251 | ||
| 252 | private static boolean filter( final StackTraceElement e ) { | |
| 253 | final var clazz = e.getClassName(); | |
| 254 | return clazz.startsWith( MainApp.class.getPackageName() ) || | |
| 255 | clazz.startsWith( "org.renjin" ); | |
| 256 | 198 | } |
| 257 | 199 | |
| 13 | 13 | |
| 14 | 14 | import static com.keenwrite.Constants.FONT_DIRECTORY; |
| 15 | import static com.keenwrite.StatusNotifier.clue; | |
| 15 | import static com.keenwrite.events.StatusEvent.clue; | |
| 16 | 16 | import static com.keenwrite.util.ProtocolScheme.valueFrom; |
| 17 | 17 | import static com.keenwrite.util.ResourceWalker.GLOB_FONTS; |
| 6 | 6 | import java.net.URL; |
| 7 | 7 | |
| 8 | import static com.keenwrite.events.StatusEvent.clue; | |
| 9 | ||
| 8 | 10 | /** |
| 9 | 11 | * Represents the type of data encoding scheme used for a universal resource |
| ... | ||
| 23 | 25 | HTTP, |
| 24 | 26 | /** |
| 25 | * Denotes FTP. | |
| 27 | * Denotes the File Transfer Protocol. | |
| 26 | 28 | */ |
| 27 | 29 | FTP, |
| ... | ||
| 99 | 101 | return valueFrom( uri.toURL() ); |
| 100 | 102 | } catch( final Exception ex ) { |
| 103 | clue( ex ); | |
| 101 | 104 | return UNKNOWN; |
| 102 | 105 | } |
| 439 | 439 | App.action.view.refresh.text=Refresh |
| 440 | 440 | |
| 441 | App.action.view.issues.description=Open document issues | |
| 442 | App.action.view.issues.accelerator=F6 | |
| 443 | App.action.view.issues.text=Issues | |
| 444 | ||
| 445 | 441 | App.action.view.preview.description=Open document preview |
| 446 | App.action.view.preview.accelerator=F7 | |
| 442 | App.action.view.preview.accelerator=F6 | |
| 447 | 443 | App.action.view.preview.text=Preview |
| 444 | ||
| 445 | App.action.view.outline.description=Open document outline | |
| 446 | App.action.view.outline.accelerator=F7 | |
| 447 | App.action.view.outline.text=Outline | |
| 448 | ||
| 449 | App.action.view.files.description=Open file system browser | |
| 450 | App.action.view.files.accelerator=F8 | |
| 451 | App.action.view.files.text=File system | |
| 448 | 452 | |
| 449 | 453 | App.action.view.menubar.description=Toggle menu bar |
| 450 | App.action.view.menubar.accelerator=Ctrl+F7 | |
| 454 | App.action.view.menubar.accelerator=Ctrl+F9 | |
| 451 | 455 | App.action.view.menubar.text=Menu bar |
| 452 | 456 | |
| 453 | 457 | App.action.view.toolbar.description=Toggle tool bar |
| 454 | App.action.view.toolbar.accelerator=Ctrl+Shift+F7 | |
| 458 | App.action.view.toolbar.accelerator=Ctrl+Shift+F9 | |
| 455 | 459 | App.action.view.toolbar.text=Tool bar |
| 456 | 460 | |
| 457 | 461 | App.action.view.statusbar.description=Toggle status bar |
| 458 | App.action.view.statusbar.accelerator=Ctrl+Shift+Alt+F7 | |
| 462 | App.action.view.statusbar.accelerator=Ctrl+Shift+Alt+F9 | |
| 459 | 463 | App.action.view.statusbar.text=Status bar |
| 460 | ||
| 461 | App.action.view.outline.description=Open document outline | |
| 462 | App.action.view.outline.accelerator=F8 | |
| 463 | App.action.view.outline.text=Outline | |
| 464 | 464 | |
| 465 | App.action.view.files.description=Open file system browser | |
| 466 | App.action.view.files.accelerator=F9 | |
| 467 | App.action.view.files.text=File system | |
| 465 | App.action.view.issues.description=Open document issues | |
| 466 | App.action.view.issues.accelerator=F12 | |
| 467 | App.action.view.issues.text=Issues | |
| 468 | 468 | |
| 469 | 469 |