Dave Jarvis' Repositories

A PROGUARD.md
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
	 
19
M README.md
4949
* Real-time rendering of math using TeX notation
5050
* 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.)
5154
* R integration
5255
* XML transformation using XSLT3 or older
5356
* Customizable user interface having detachable tabs
5457
* Platform-independent (Windows, Linux, MacOS)
5558
5659
## Usage
5760
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.
6066
6167
## Screenshots
6268
6369
Diagram that includes variables:
6470
65
![GraphViz Diagram Screenshot](docs/images/screenshots/01.png)
71
![GraphViz diagram screenshot](docs/images/screenshots/01.png)
6672
6773
Poem with locale settings:
6874
69
![Korean Poem Screenshot](docs/images/screenshots/02.png)
75
![Korean poem screenshot](docs/images/screenshots/02.png)
7076
7177
TeX equations with detached preview:
7278
73
![TeX Equations Screenshot](docs/images/screenshots/03.png)
79
![TeX equations screenshot](docs/images/screenshots/03.png)
80
81
Document outline opened and docked in bottom-left corner:
82
83
![Document outline](docs/images/screenshots/04.png)
7484
7585
## License
M build.gradle
104104
  implementation 'com.googlecode.juniversalchardet:juniversalchardet:1.0.3'
105105
  implementation 'javax.validation:validation-api:2.0.1.Final'
106
  implementation 'org.greenrobot:eventbus:3.2.0'
106107
107108
  // Configuration
M docs/README.md
55
* [definitions.md](definitions.md) -- Definitions and interpolation
66
* [r.md](r.md) -- Call R functions within R Markdown documents
7
* [texample.Rmd](texample.Rmd) -- Numerous examples of formulas
87
* [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
910
* [credits.md](credits.md) -- Thanks to authors of contributing projects
1011
M docs/images/screenshots/02.png
Binary file
A docs/images/screenshots/04.png
Binary file
A docs/themes.md
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
1101
M src/main/java/com/keenwrite/Caret.java
198198
                  getParagraphCount(),
199199
                  getTextOffset() + 1 );
200
    } catch( final NullPointerException ex ) {
200
    } catch( final Exception ex ) {
201201
      return get( STATUS_BAR_LINE, 0, 0, 0 );
202202
    }
M src/main/java/com/keenwrite/Constants.java
33
44
import com.keenwrite.service.Settings;
5
import javafx.scene.control.ToolBar;
6
import javafx.scene.control.TreeItem;
57
import javafx.scene.image.Image;
68
import javafx.scene.image.ImageView;
...
215217
   */
216218
  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";
217224
218225
  /**
M src/main/java/com/keenwrite/DefinitionNameInjector.java
88
99
import static com.keenwrite.Constants.*;
10
import static com.keenwrite.StatusNotifier.clue;
10
import static com.keenwrite.events.StatusEvent.clue;
1111
1212
/**
...
5353
        }
5454
      }
55
    } catch( final Exception ignored ) {
56
      clue( STATUS_DEFINITION_BLANK );
55
    } catch( final Exception ex ) {
56
      clue( STATUS_DEFINITION_BLANK, ex );
5757
    }
5858
  }
M src/main/java/com/keenwrite/MainApp.java
3030
3131
  private Workspace mWorkspace;
32
  private MainScene mMainScene;
3233
3334
  /**
...
9697
    // After the app loses focus, when the user switches back using Alt+Tab,
9798
    // 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().
100100
    //
101
    // See: https://bugs.openjdk.java.net/browse/JDK-8090647
101
    // JavaFX Bug: https://bugs.openjdk.java.net/browse/JDK-8090647
102102
    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 ) );
108110
        }
109111
      }
...
116118
117119
  private void initScene( final Stage stage ) {
118
    stage.setScene( (new MainScene( mWorkspace )).getScene() );
120
    mMainScene = new MainScene( mWorkspace );
121
    stage.setScene( mMainScene.getScene() );
119122
  }
120123
...
139142
  public static Event keyUp( final KeyCode code, final boolean shift ) {
140143
    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 );
141154
  }
142155
M src/main/java/com/keenwrite/MainPane.java
66
import com.keenwrite.editors.TextResource;
77
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 ) {
791794
    addTabPane( getItems().size(), tabPane );
792795
  }
M src/main/java/com/keenwrite/MainScene.java
77
import com.keenwrite.ui.actions.ApplicationActions;
88
import com.keenwrite.ui.listeners.CaretListener;
9
import javafx.scene.AccessibleRole;
109
import javafx.scene.Node;
1110
import javafx.scene.Parent;
1211
import javafx.scene.Scene;
12
import javafx.scene.control.MenuBar;
1313
import javafx.scene.layout.BorderPane;
1414
import javafx.scene.layout.VBox;
1515
import org.controlsfx.control.StatusBar;
1616
1717
import java.io.File;
1818
1919
import static com.keenwrite.Constants.*;
2020
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;
2322
import static com.keenwrite.preferences.ThemeProperty.toFilename;
2423
import static com.keenwrite.preferences.WorkspaceKeys.KEY_UI_THEME_CUSTOM;
2524
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.*;
2826
import static javafx.application.Platform.runLater;
2927
import static javafx.scene.input.KeyCode.ALT;
...
3634
public final class MainScene {
3735
  private final Scene mScene;
38
  private final Node mMenuBar;
36
  private final MenuBar mMenuBar;
3937
  private final Node mToolBar;
4038
  private final StatusBar mStatusBar;
...
4846
    mMenuBar = setManagedLayout( createMenuBar( actions ) );
4947
    mToolBar = setManagedLayout( createToolBar() );
50
    mStatusBar = setManagedLayout( getStatusBar() );
48
    mStatusBar = setManagedLayout( createStatusBar() );
5149
5250
    mStatusBar.getRightItems().add( caretListener );
...
8886
    final var node = mStatusBar;
8987
    node.setVisible( !node.isVisible() );
88
  }
89
90
  MenuBar getMenuBar() {
91
    return mMenuBar;
9092
  }
93
94
  public StatusBar getStatusBar() { return mStatusBar; }
9195
9296
  private void initStylesheets( final Scene scene, final Workspace workspace ) {
...
180184
   */
181185
  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;
183204
  }
184205
185206
  /**
186207
   * Binds the visible property of the node to the managed property so that
187208
   * hiding the node also removes the screen real estate that it occupies.
188209
   * This allows the user to hide the menu bar, tool bar, etc.
189210
   *
190211
   * @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.
192213
   */
193214
  private <T extends Node> T setManagedLayout( final T node ) {
M src/main/java/com/keenwrite/Messages.java
1919
2020
  private static final ResourceBundle RESOURCE_BUNDLE =
21
      getBundle( APP_BUNDLE_NAME );
21
    getBundle( APP_BUNDLE_NAME );
2222
2323
  private Messages() {
...
3232
   * @return The value of the key with all references recursively dereferenced.
3333
   */
34
  @SuppressWarnings("SameParameterValue")
34
  @SuppressWarnings( "SameParameterValue" )
3535
  private static String resolve( final ResourceBundle props, final String s ) {
3636
    final int len = s.length();
D src/main/java/com/keenwrite/StatusNotifier.java
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
}
1001
M src/main/java/com/keenwrite/editors/TextResource.java
1212
1313
import static com.keenwrite.Constants.DEFAULT_CHARSET;
14
import static com.keenwrite.StatusNotifier.clue;
14
import static com.keenwrite.events.StatusEvent.clue;
1515
import static java.nio.charset.Charset.forName;
1616
import static java.nio.file.Files.readAllBytes;
M src/main/java/com/keenwrite/editors/definition/DefinitionEditor.java
1010
import javafx.beans.property.ReadOnlyBooleanProperty;
1111
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();
525540
    getTreeView().requestFocus();
526541
  }
D src/main/java/com/keenwrite/editors/definition/DefinitionTabSceneFactory.java
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
}
811
M src/main/java/com/keenwrite/editors/definition/yaml/YamlTreeTransformer.java
1616
import static com.fasterxml.jackson.dataformat.yaml.YAMLGenerator.Feature.MINIMIZE_QUOTES;
1717
import static com.fasterxml.jackson.dataformat.yaml.YAMLGenerator.Feature.SPLIT_LINES;
18
import static com.keenwrite.events.StatusEvent.clue;
1819
1920
/**
...
5354
      return sMapper.writeValueAsString( root );
5455
    } catch( final Exception ex ) {
56
      clue( ex );
5557
      throw new RuntimeException( ex );
5658
    }
M src/main/java/com/keenwrite/editors/markdown/MarkdownEditor.java
44
import com.keenwrite.Caret;
55
import com.keenwrite.Constants;
6
import com.keenwrite.MainApp;
76
import com.keenwrite.editors.TextEditor;
87
import com.keenwrite.preferences.LocaleProperty;
...
3534
import static com.keenwrite.MainApp.keyDown;
3635
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;
3838
import static com.keenwrite.preferences.WorkspaceKeys.*;
3939
import static java.lang.Character.isWhitespace;
...
132132
      mDirty.set( true );
133133
    } );
134
134135
    textArea.caretPositionProperty().addListener( ( c, o, n ) -> {
135136
      // Fire when the caret position has changed and the text has not.
136137
      mDirty.set( true );
137138
      mDirty.set( false );
139
    } );
140
141
    textArea.focusedProperty().addListener( ( c, o, n ) -> {
142
      if( n != null && n ) {
143
        fireTextEditorFocus( this );
144
      }
138145
    } );
139146
  }
A src/main/java/com/keenwrite/events/AppEvent.java
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
}
118
A src/main/java/com/keenwrite/events/Bus.java
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
}
127
A src/main/java/com/keenwrite/events/CaretNavigationEvent.java
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
}
133
A src/main/java/com/keenwrite/events/FileOpenEvent.java
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
}
138
A src/main/java/com/keenwrite/events/FocusEvent.java
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
}
125
A src/main/java/com/keenwrite/events/ParseHeadingEvent.java
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
}
184
A src/main/java/com/keenwrite/events/StatusEvent.java
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
}
1139
A src/main/java/com/keenwrite/events/TextDefinitionFocusEvent.java
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
}
121
A src/main/java/com/keenwrite/events/TextEditorFocusEvent.java
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
}
121
M src/main/java/com/keenwrite/io/FileWatchService.java
4848
        register( file );
4949
      }
50
    } catch( final IOException ex ) {
50
    } catch( final Exception ignored ) {
5151
      // Create a fallback that allows the class to be instantiated and used
5252
      // without without preventing the application from launching.
M src/main/java/com/keenwrite/io/HttpMediaType.java
88
import java.net.http.HttpRequest;
99
10
import static com.keenwrite.StatusNotifier.clue;
10
import static com.keenwrite.events.StatusEvent.clue;
1111
import static com.keenwrite.io.MediaType.UNDEFINED;
1212
import static java.net.http.HttpClient.Redirect.NORMAL;
M src/main/java/com/keenwrite/io/MediaType.java
2020
    APPLICATION, "x-java-serialized-object"
2121
  ),
22
  APP_DOCUMENT_OUTLINE(
23
    APPLICATION, "x-document-outline"
24
  ),
2225
2326
  FONT_OTF( "otf" ),
A src/main/java/com/keenwrite/outline/DocumentOutline.java
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
}
1148
M src/main/java/com/keenwrite/preferences/SimpleFontControl.java
1818
1919
import static com.keenwrite.Constants.ICON_DIALOG;
20
import static com.keenwrite.StatusNotifier.clue;
20
import static com.keenwrite.events.StatusEvent.clue;
2121
import static java.lang.System.currentTimeMillis;
2222
import static javafx.geometry.Pos.CENTER_LEFT;
M src/main/java/com/keenwrite/preferences/Workspace.java
2121
import static com.keenwrite.Constants.*;
2222
import static com.keenwrite.Launcher.getVersion;
23
import static com.keenwrite.StatusNotifier.clue;
23
import static com.keenwrite.events.StatusEvent.clue;
2424
import static com.keenwrite.preferences.WorkspaceKeys.*;
2525
import static java.util.Map.entry;
M src/main/java/com/keenwrite/preview/DomConverter.java
1414
import java.util.Map;
1515
16
import static com.keenwrite.StatusNotifier.clue;
16
import static com.keenwrite.events.StatusEvent.clue;
1717
import static com.keenwrite.processors.text.TextReplacementFactory.replace;
1818
M src/main/java/com/keenwrite/preview/HtmlPanel.java
1818
import java.net.URI;
1919
20
import static com.keenwrite.StatusNotifier.clue;
20
import static com.keenwrite.events.FileOpenEvent.fireFileOpenEvent;
21
import static com.keenwrite.events.StatusEvent.clue;
2122
import static com.keenwrite.util.ProtocolScheme.getProtocol;
2223
import static java.awt.Desktop.Action.BROWSE;
...
6768
  private static final class HyperlinkListener extends LinkListener {
6869
    @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();
7375
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 ) );
7978
            }
8079
          }
81
        }
82
        case FILE -> {
83
          // TODO: #88 -- publish a message to the event bus.
80
          case FILE -> fireFileOpenEvent( new URI( uri ) );
8481
        }
82
      } catch( final Exception ex ) {
83
        clue( ex );
8584
      }
8685
    }
M src/main/java/com/keenwrite/preview/HtmlPreview.java
1919
import static com.keenwrite.Constants.*;
2020
import static com.keenwrite.Messages.get;
21
import static com.keenwrite.StatusNotifier.clue;
21
import static com.keenwrite.events.StatusEvent.clue;
2222
import static com.keenwrite.preferences.WorkspaceKeys.*;
2323
import static java.lang.Math.max;
...
191191
        try {
192192
          sleep( 10 );
193
        } catch( final InterruptedException ex ) {
193
        } catch( final Exception ex ) {
194194
          clue( ex );
195195
        }
M src/main/java/com/keenwrite/preview/MathRenderer.java
88
import java.util.function.Supplier;
99
10
import static com.keenwrite.StatusNotifier.clue;
10
import static com.keenwrite.events.StatusEvent.clue;
1111
1212
/**
M src/main/java/com/keenwrite/preview/SvgRasterizer.java
1111
1212
import javax.xml.transform.Transformer;
13
import javax.xml.transform.TransformerConfigurationException;
1413
import javax.xml.transform.TransformerFactory;
1514
import javax.xml.transform.dom.DOMSource;
...
2423
import java.text.NumberFormat;
2524
26
import static com.keenwrite.StatusNotifier.clue;
25
import static com.keenwrite.events.StatusEvent.clue;
2726
import static com.keenwrite.preview.RenderingSettings.RENDERING_HINTS;
2827
import static java.awt.image.BufferedImage.TYPE_INT_RGB;
...
5453
      t.setOutputProperty( INDENT, "no" );
5554
      t.setOutputProperty( ENCODING, UTF_8.name() );
56
    } catch( final TransformerConfigurationException e ) {
55
    } catch( final Exception ignored ) {
5756
      t = null;
5857
    }
M src/main/java/com/keenwrite/preview/SvgReplacedElementFactory.java
1616
import java.nio.file.Path;
1717
18
import static com.keenwrite.StatusNotifier.clue;
18
import static com.keenwrite.events.StatusEvent.clue;
1919
import static com.keenwrite.io.MediaType.*;
2020
import static com.keenwrite.preview.MathRenderer.MATH_RENDERER;
M src/main/java/com/keenwrite/processors/ExecutorProcessor.java
55
import java.util.concurrent.atomic.AtomicReference;
66
7
import static com.keenwrite.events.StatusEvent.clue;
8
79
/**
810
 * Responsible for transforming data through a variety of chained handlers.
...
4446
    while( handler.isPresent() ) {
4547
      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
4754
        return p.next();
4855
      } );
M src/main/java/com/keenwrite/processors/HtmlPreviewProcessor.java
3131
   *
3232
   * @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.
3534
   * @return The given {@code html} string.
3635
   */
M src/main/java/com/keenwrite/processors/markdown/MarkdownProcessor.java
55
import com.keenwrite.processors.Processor;
66
import com.keenwrite.processors.ProcessorContext;
7
import com.keenwrite.processors.markdown.extensions.DocumentOutlineExtension;
78
import com.keenwrite.processors.markdown.extensions.FencedBlockExtension;
89
import com.keenwrite.processors.markdown.extensions.ImageLinkExtension;
9
import com.keenwrite.processors.markdown.extensions.caret.CaretExtension;
10
import com.keenwrite.processors.markdown.extensions.CaretExtension;
1011
import com.keenwrite.processors.markdown.extensions.r.RExtension;
1112
import com.keenwrite.processors.markdown.extensions.tex.TeXExtension;
...
7475
    extensions.add( FencedBlockExtension.create( context ) );
7576
    extensions.add( CaretExtension.create( context ) );
77
    extensions.add( DocumentOutlineExtension.create( processor ) );
7678
  }
7779
}
A src/main/java/com/keenwrite/processors/markdown/extensions/CaretExtension.java
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
}
1114
A src/main/java/com/keenwrite/processors/markdown/extensions/DocumentOutlineExtension.java
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
}
173
A src/main/java/com/keenwrite/processors/markdown/extensions/EmptyNode.java
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
}
127
M src/main/java/com/keenwrite/processors/markdown/extensions/FencedBlockExtension.java
1818
import java.util.zip.Deflater;
1919
20
import static com.keenwrite.StatusNotifier.clue;
20
import static com.keenwrite.events.StatusEvent.clue;
2121
import static com.keenwrite.processors.IdentityProcessor.IDENTITY;
2222
import static com.vladsch.flexmark.html.HtmlRenderer.Builder;
M src/main/java/com/keenwrite/processors/markdown/extensions/ImageLinkExtension.java
1818
1919
import static com.keenwrite.ExportFormat.NONE;
20
import static com.keenwrite.StatusNotifier.clue;
20
import static com.keenwrite.events.StatusEvent.clue;
2121
import static com.keenwrite.preferences.WorkspaceKeys.KEY_IMAGES_DIR;
2222
import static com.keenwrite.preferences.WorkspaceKeys.KEY_IMAGES_ORDER;
D src/main/java/com/keenwrite/processors/markdown/extensions/caret/CaretExtension.java
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
}
1151
D src/main/java/com/keenwrite/processors/markdown/extensions/r/EmptyNode.java
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
}
271
M src/main/java/com/keenwrite/processors/markdown/extensions/r/RExtension.java
2323
2424
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;
2626
import static com.vladsch.flexmark.parser.Parser.Builder;
2727
import static com.vladsch.flexmark.parser.Parser.ParserExtension;
M src/main/java/com/keenwrite/processors/r/InlineRProcessor.java
1919
import static com.keenwrite.Constants.STATUS_PARSE_ERROR;
2020
import static com.keenwrite.Messages.get;
21
import static com.keenwrite.StatusNotifier.clue;
21
import static com.keenwrite.events.StatusEvent.clue;
2222
import static com.keenwrite.preferences.WorkspaceKeys.*;
2323
import static com.keenwrite.processors.text.TextReplacementFactory.replace;
M src/main/java/com/keenwrite/service/events/Notifier.java
33
44
import javafx.scene.control.Alert;
5
import javafx.scene.control.ButtonType;
65
import javafx.stage.Window;
76
87
import java.nio.file.Path;
98
109
/**
1110
 * Provides the application with a uniform way to notify the user of events.
1211
 */
1312
public interface Notifier {
14
15
  ButtonType YES = ButtonType.YES;
16
  ButtonType NO = ButtonType.NO;
17
  ButtonType CANCEL = ButtonType.CANCEL;
1813
1914
  /**
...
2722
   */
2823
  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 );
3429
3530
  /**
...
4237
   */
4338
  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 ) {
4843
    alert( parent, path, key + ".title", key + ".message", ex );
4944
  }
...
5853
   */
5954
  Notification createNotification(
60
      String title,
61
      String message,
62
      Object... args );
55
    String title,
56
    String message,
57
    Object... args );
6358
6459
  /**
M src/main/java/com/keenwrite/service/events/impl/DefaultNotifier.java
1414
import static javafx.scene.control.Alert.AlertType.CONFIRMATION;
1515
import static javafx.scene.control.Alert.AlertType.ERROR;
16
import static javafx.scene.control.ButtonType.*;
1617
1718
/**
M src/main/java/com/keenwrite/spelling/impl/SymSpellSpeller.java
1818
1919
import static com.keenwrite.Constants.LEXICONS_DIRECTORY;
20
import static com.keenwrite.StatusNotifier.clue;
20
import static com.keenwrite.events.StatusEvent.clue;
2121
import static io.gitlab.rxp90.jsymspell.SymSpell.Verbosity;
2222
import static io.gitlab.rxp90.jsymspell.SymSpell.Verbosity.ALL;
...
160160
   */
161161
  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 );
167163
  }
168164
}
M src/main/java/com/keenwrite/ui/actions/Action.java
22
package com.keenwrite.ui.actions;
33
4
import com.keenwrite.Constants;
45
import com.keenwrite.Messages;
56
import com.keenwrite.util.GenericBuilder;
...
109110
  private Button createIconButton() {
110111
    final var button = new Button();
111
    button.setGraphic( get().createIcon( mIcon, "1.2em" ) );
112
    button.setGraphic( get().createIcon( mIcon, Constants.ICON_SIZE_DEFAULT ) );
112113
    return button;
113114
  }
M src/main/java/com/keenwrite/ui/actions/ApplicationActions.java
55
import com.keenwrite.MainPane;
66
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();
376382
  }
377383
M src/main/java/com/keenwrite/ui/actions/ApplicationBars.java
22
package com.keenwrite.ui.actions;
33
4
import com.keenwrite.ui.controls.EventedStatusBar;
45
import javafx.event.ActionEvent;
56
import javafx.event.EventHandler;
67
import javafx.scene.Node;
78
import javafx.scene.control.Menu;
89
import javafx.scene.control.MenuBar;
910
import javafx.scene.control.MenuItem;
1011
import javafx.scene.control.ToolBar;
12
import org.controlsfx.control.StatusBar;
1113
1214
import java.util.HashMap;
...
3436
   * @param actions The {@link ApplicationActions} that map user interface
3537
   *                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.
3739
   */
38
  public static Node createMenuBar( final ApplicationActions actions ) {
40
  public static MenuBar createMenuBar( final ApplicationActions actions ) {
3941
    final var SEPARATOR_ACTION = new SeparatorAction();
4042
...
116118
      addAction( "view.refresh", e -> actions.view‿refresh() ),
117119
      SEPARATOR_ACTION,
118
      addAction( "view.issues", e -> actions.view‿issues() ),
119120
      addAction( "view.preview", e -> actions.view‿preview() ),
121
      addAction( "view.outline", e -> actions.view‿outline() ),
120122
      SEPARATOR_ACTION,
121123
      addAction( "view.menubar", e -> actions.view‿menubar() ),
122124
      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() )
124128
    ),
125129
    createMenu(
...
160164
      getAction( "insert.ordered_list" )
161165
    );
166
  }
167
168
  public static StatusBar createStatusBar() {
169
    return new EventedStatusBar();
162170
  }
163171
M src/main/java/com/keenwrite/ui/adapters/DocumentAdapter.java
44
import org.xhtmlrenderer.event.DocumentListener;
55
6
import static com.keenwrite.StatusNotifier.clue;
6
import static com.keenwrite.events.StatusEvent.clue;
77
88
/**
M src/main/java/com/keenwrite/ui/controls/BrowseFileButton.java
105105
    try {
106106
      newUrl = getBasePath().relativize( file.toPath() ).toString();
107
    } catch( IllegalArgumentException ex ) {
107
    } catch( final Exception ex ) {
108108
      newUrl = file.toString();
109109
    }
A src/main/java/com/keenwrite/ui/controls/EventedStatusBar.java
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
}
143
M src/main/java/com/keenwrite/ui/logging/LogView.java
22
package com.keenwrite.ui.logging;
33
4
import com.keenwrite.MainApp;
4
import com.keenwrite.events.StatusEvent;
55
import javafx.beans.property.SimpleStringProperty;
66
import javafx.beans.property.StringProperty;
77
import javafx.collections.ObservableList;
88
import javafx.scene.control.*;
99
import javafx.scene.input.ClipboardContent;
1010
import javafx.scene.input.KeyCodeCombination;
1111
import javafx.stage.Stage;
12
import org.greenrobot.eventbus.Subscribe;
1213
1314
import java.time.LocalDateTime;
1415
import java.util.Objects;
1516
import java.util.TreeSet;
16
import java.util.stream.Collectors;
1717
1818
import static com.keenwrite.Constants.ICON_DIALOG;
19
import static com.keenwrite.Constants.NEWLINE;
2019
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;
2222
import static java.time.LocalDateTime.now;
2323
import static java.time.format.DateTimeFormatter.ofPattern;
24
import static java.util.Arrays.stream;
2524
import static javafx.collections.FXCollections.observableArrayList;
2625
import static javafx.event.ActionEvent.ACTION;
...
5655
    initIcon();
5756
    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
    }
5873
  }
5974
...
7287
    mEntries.clear();
7388
    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 );
10289
  }
10390
...
171158
    private final StringProperty mMessage;
172159
    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
    }
193160
194161
    /**
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.
200163
     */
201
    public LogEntry( final String message, final Throwable trace ) {
164
    public LogEntry( final StatusEvent event ) {
202165
      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() );
205168
    }
206169
...
219182
    private String toString( final LocalDateTime date ) {
220183
      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();
236184
    }
237185
...
248196
      return mMessage != null ? mMessage.hashCode() : 0;
249197
    }
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" );
256198
  }
257199
M src/main/java/com/keenwrite/util/FontLoader.java
1313
1414
import static com.keenwrite.Constants.FONT_DIRECTORY;
15
import static com.keenwrite.StatusNotifier.clue;
15
import static com.keenwrite.events.StatusEvent.clue;
1616
import static com.keenwrite.util.ProtocolScheme.valueFrom;
1717
import static com.keenwrite.util.ResourceWalker.GLOB_FONTS;
M src/main/java/com/keenwrite/util/ProtocolScheme.java
66
import java.net.URL;
77
8
import static com.keenwrite.events.StatusEvent.clue;
9
810
/**
911
 * Represents the type of data encoding scheme used for a universal resource
...
2325
  HTTP,
2426
  /**
25
   * Denotes FTP.
27
   * Denotes the File Transfer Protocol.
2628
   */
2729
  FTP,
...
99101
      return valueFrom( uri.toURL() );
100102
    } catch( final Exception ex ) {
103
      clue( ex );
101104
      return UNKNOWN;
102105
    }
M src/main/resources/com/keenwrite/messages.properties
439439
App.action.view.refresh.text=Refresh
440440
441
App.action.view.issues.description=Open document issues
442
App.action.view.issues.accelerator=F6
443
App.action.view.issues.text=Issues
444
445441
App.action.view.preview.description=Open document preview
446
App.action.view.preview.accelerator=F7
442
App.action.view.preview.accelerator=F6
447443
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
448452
449453
App.action.view.menubar.description=Toggle menu bar
450
App.action.view.menubar.accelerator=Ctrl+F7
454
App.action.view.menubar.accelerator=Ctrl+F9
451455
App.action.view.menubar.text=Menu bar
452456
453457
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
455459
App.action.view.toolbar.text=Tool bar
456460
457461
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
459463
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
464464
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
468468
469469